diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b619047e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.* +Dockerfile +docs +LICENSE +README.md diff --git a/.eslintrc.json b/.eslintrc.json index ad6c04399..b89aa7c55 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,6 @@ "helpers/google/functions.js", "helpers/oracle/functions.js", "plugins/github/", - "plugins/google/", "plugins/oracle/", "*.spec.js" ], @@ -50,6 +49,19 @@ ], "no-eval": [ "error" + ], + "keyword-spacing": [ + "error", + { + "before": true + } + ], + "brace-style": [ + "error", + "1tbs", + { + "allowSingleLine": true + } ] } } \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index fa089418f..ca3b07430 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,4 +1,4 @@ -# CloudSploit Code of Conduct +# CloudExploit Code of Conduct ## Our Pledge @@ -55,7 +55,7 @@ a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@cloudsploit.com. All +reported by contacting the project team at support@khulnasoft.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c48e3c345..149d87c74 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,14 +1,14 @@ -# Contributing to CloudSploit -Thank you for your interest in contributing to CloudSploit! We welcome your PRs, issues, feedback, and other contributions to this open source repository. To keep things moving smoothly, please use the following guidelines when working with the CloudSploit source code. +# Contributing to CloudExploit +Thank you for your interest in contributing to CloudExploit! We welcome your PRs, issues, feedback, and other contributions to this open source repository. To keep things moving smoothly, please use the following guidelines when working with the CloudExploit source code. ## Code of Conduct -The CloudSploit project, maintainers, and contributors are governed by the [CloudSploit Code of Conduct](CODE_OF_CONDUCT.md). By contributing, you are agreeing to uphold this code in your interactions with the CloudSploit community. +The CloudExploit project, maintainers, and contributors are governed by the [CloudExploit Code of Conduct](CODE_OF_CONDUCT.md). By contributing, you are agreeing to uphold this code in your interactions with the CloudExploit community. ## License -By contributing code to CloudSploit, you attest that you have the rights to all code and that you are assigning these rights to Khulnasoft Security, Ltd. for use within its projects. +By contributing code to CloudExploit, you attest that you have the rights to all code and that you are assigning these rights to KhulnaSoft Security, Ltd. for use within its projects. ## Getting Started -Please read our [README](../README.md#installation) for information on getting setup to use and develop CloudSploit scans locally. We also have a [guide for writing new plugins](../docs/writing-plugins.md). +Please read our [README](../README.md#installation) for information on getting setup to use and develop CloudExploit scans locally. We also have a [guide for writing new plugins](../docs/writing-plugins.md). ## Proposing Large Changes While we welcome all contributions, large pull requests that make significant changes to the codebase are difficult to review are merge without prior discussion. Please open an issue to discuss these changes before beginning work on them. diff --git a/.github/workflows/scans_ci.yml b/.github/workflows/scans_ci.yml new file mode 100644 index 000000000..52815e234 --- /dev/null +++ b/.github/workflows/scans_ci.yml @@ -0,0 +1,25 @@ +name: +on: [push, pull_request, create, delete, issue_comment] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: '12.x' + - uses: codespell-project/actions-codespell@master + with: + check_filenames: true + skip: ./.github/*,.git,./package.json,./package-lock.json,./node_modules,./tests,./config,*.png,Dockerfile,./scripts,*.spec.js,./plugins/azure/storageaccounts/storageAccountsAADEnabled.js,./plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js,./helpers/google/index.js,*zip + ignore_words_list: iam,\"tRe\",AKS,aks,optin,callInt,callInt + - run: npm install + + - name: Lint + run: npm run lint + + - name: NPM Test + run: npm test diff --git a/.gitignore b/.gitignore index b12a7ce31..cb657bfa0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ vscode/* .idea coverage/ .nyc_output -config.js \ No newline at end of file +config.js +package-lock.json diff --git a/.travis.yml b/.travis.yml index 956d3e810..e9910ef82 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ jobs: - stage: Lint script: npm run lint - stage: NPM release - # Deployment of cloudsploit is setup to work nicely both for the master repo + # Deployment of cloudexploit is setup to work nicely both for the master repo # any forks. That is, a fork can also publish to NPM to a custom scope. To do # this, you need to do the following in the Travis CI settings for your account # @@ -42,7 +42,7 @@ jobs: before_deploy: # You need to create a variable in your Travis CI settings to provide the # npm package scope. This allows forks to publish under their own name. - - sed -i 's#"cloudsploit"#"'$NPM_SCOPE'cloudsploit"#g' package.json + - sed -i 's#"cloudexploit"#"'$NPM_SCOPE'cloudexploit"#g' package.json deploy: # We modify package.json to change the package name automatically, so # don't to cleanup otherwise that would revert the package name change. @@ -61,18 +61,18 @@ jobs: # # 2. Similarly, add an environment variable with your DOCKER_PASSWORD # - # 3. If you want to install a different version of cloudsploit in the container + # 3. If you want to install a different version of cloudexploit in the container # (for example because you are building a fork), then set DOCKER_NPMPACKAGENAME - # to the name of NPM package you want to install, for example @myscope/cloudsploit + # to the name of NPM package you want to install, for example @myscope/cloudexploit # # There is probably a strong relationship between NPM_SCOPE and DOCKER_NPMPACKAGENAME # but that's up for you in Travis CI to define. if: env(DOCKER_USERNAME) AND env(DOCKER_PASSWORD) script: - - DOCKER_BUILD_NPMPACKAGENAME="${DOCKER_NPMPACKAGENAME:-cloudsploit}" + - DOCKER_BUILD_NPMPACKAGENAME="${DOCKER_NPMPACKAGENAME:-cloudexploit}" - echo "Creating docker image from NPM package ${DOCKER_NPMPACKAGENAME}" - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker build --build-arg PACKAGENAME=$DOCKER_BUILD_NPMPACKAGENAME -t cloudsploit-scans . + - docker build --build-arg PACKAGENAME=$DOCKER_BUILD_NPMPACKAGENAME -t cloudexploit-scans . - docker images - - docker tag cloudsploit-scans $DOCKER_USERNAME/cloudsploit-scans - - docker push $DOCKER_USERNAME/cloudsploit-scans + - docker tag cloudexploit-scans $DOCKER_USERNAME/cloudexploit-scans + - docker push $DOCKER_USERNAME/cloudexploit-scans diff --git a/Dockerfile b/Dockerfile index ea99638c6..e931b5c90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,38 @@ -FROM node:8-alpine +FROM node:lts-alpine3.12 # Define a build argment that can be supplied when building the container # You can then do the following: # -# docker build --build-arg PACKAGENAME=@myscope/cloudsploit +# docker build --build-arg PACKAGENAME=@myscope/cloudexploit # # This allows a fork to build their own container from this common Dockerfile. # You could also use this to specify a particular version number. -ARG PACKAGENAME=cloudsploit +ARG PACKAGENAME=cloudexploit -# Install cloudsploit/scan into the container using npm from NPM -RUN mkdir /var/scan \ -&& cd /var/scan \ -&& npm init --yes \ -&& npm install ${PACKAGENAME} +# Create a non-root user and group +RUN addgroup -S cloudexploit && adduser -S cloudexploit -G cloudexploit -# Setup the container's path so that you can run cloudsploit directly +COPY . /var/scan/cloudexploit/ + +# Set the working directory to /var/scan +WORKDIR /var/scan + +# Install cloudexploit/scan into the container using npm from NPM +RUN npm init --yes \ +&& npm install ${PACKAGENAME} \ +&& npm link /var/scan/cloudexploit \ +&& chown -R cloudexploit:cloudexploit /var/scan + +# Setup the container's path so that you can run cloudexploit directly # in case someone wants to customize it when running the container. ENV PATH "$PATH:/var/scan/node_modules/.bin" +# Switch to non-root user +USER cloudexploit + # By default, run the scan. CMD allows consumers of the container to supply # command line arguments to the run command to control how this executes. # Thus, you can use the parameters that you would normally give to index.js # when running in a container. -ENTRYPOINT ["cloudsploitscan"] +ENTRYPOINT ["cloudexploit"] CMD [] diff --git a/README.md b/README.md index 00f799daf..f4a0387fb 100644 --- a/README.md +++ b/README.md @@ -1,341 +1,309 @@ -[](https://cloud.khulnasoft.com/signup) +# ☁️ CloudExploit by Khulnasoft — Next‑Gen Cloud Security Scanning Platform -[![Build Status](https://travis-ci.org/khulnasoft/cloudsploit.svg?branch=master)](https://travis-ci.org/khulnasoft/cloudsploit) +--- -CloudSploit by Khulnasoft - Cloud Security Scans -================= +## 🔥 Overview -[](https://cloud.khulnasoft.com/signup) +**CloudExploit** is a powerful **multi‑cloud security scanning engine** built for: -## Quick Start -``` -$ git clone git@github.com:khulnasoft/cloudsploit.git -$ cd scans -$ npm install -$ ./index.js -h -``` +* ☁️ AWS, Azure, GCP, Oracle Cloud +* 🔐 Compliance auditing (HIPAA, PCI, CIS) +* ⚙️ CI/CD security automation +* 🤖 AI‑driven security workflows (future‑ready) -## Documentation -* [Background](#background) -* [Deployment Options](#deployment-options) - + [Self-Hosted](#self-hosted) - + [Hosted at Khulnasoft Wave](#hosted-at-Khulnasoft-wave) -* [Installation](#installation) -* [Configuration](#configuration) - + [Amazon Web Services](docs/aws.md#cloud-provider-configuration) - + [Microsoft Azure](docs/azure.md#cloud-provider-configuration) - + [Google Cloud Platform](docs/gcp.md#cloud-provider-configuration) - + [Oracle Cloud Infrastructure](docs/oracle.md#cloud-provider-configuration) - + [CloudSploit Config File](#cloudsploit-config-file) - + [Credential Files](#credential-files) - + [AWS](#aws) - + [Azure](#azur) - + [GCP](#gcp) - + [Oracle OCI](#oracle-oci) - + [Environment Variables](#environment-variables) -* [Running](#running) -* [CLI Options](#cli-options) -* [Compliance](#compliance) - + [HIPAA](#hipaa) - + [PCI](#pci) - + [CIS Benchmarks](#cis-benchmarks) -* [Output Formats](#output-formats) - + [Console Output](#console-output) - + [Ignoring Passing Results](#ignoring-passing-results) - + [CSV](#csv) - + [JSON](#json) - + [JUnit XML](#junit-xml) - + [Collection Output](#collection-output) -* [Suppressions](#suppressions) -* [Running a Single Plugin](#running-a-single-plugin) -* [Architecture](#architecture) -* [Writing a Plugin](#writing-a-plugin) -* [Other Notes](#other-notes) - -## Background -CloudSploit by Khulnasoft is an open-source project designed to allow detection of security risks in cloud infrastructure accounts, including: Amazon Web Services (AWS), Microsoft Azure, Google Cloud Platform (GCP), Oracle Cloud Infrastructure (OCI), and GitHub. These scripts are designed to return a series of potential misconfigurations and security risks. - -## Deployment Options -CloudSploit is available in two deployment options: - -### Self-Hosted -Follow the instructions below to deploy the open-source version of CloudSploit on your machine in just a few simple steps. - -### Hosted at Khulnasoft Wave -A commercial version of CloudSploit hosted at Khulnasoft Wave. Try [Khulnasoft Wave](https://cloud.khulnasoft.com/signup) today! - -## Installation -Ensure that NodeJS is installed. If not, install it from [here](https://nodejs.org/download/). +It detects **misconfigurations, vulnerabilities, and risky policies** across your cloud infrastructure. -``` -$ git clone git@github.com:khulnasoft/cloudsploit.git -$ npm install -``` +--- -## Configuration -CloudSploit requires read-only permission to your cloud account. Follow the guides below to provision this access: +# 🚀 New Capabilities (Enhanced Runtime Platform) -* [Amazon Web Services](docs/aws.md#cloud-provider-configuration) -* [Microsoft Azure](docs/azure.md#cloud-provider-configuration) -* [Google Cloud Platform](docs/gcp.md#cloud-provider-configuration) -* [Oracle Cloud Infrastructure](docs/oracle.md#cloud-provider-configuration) +## ⚡ Accelerated Runtime Modes -For AWS, you can run CloudSploit directly and it will detect credentials using the default [AWS credential chain](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CredentialProviderChain.html). +CloudExploit supports multiple execution strategies: -### CloudSploit Config File -The CloudSploit config file allows you to pass cloud provider credentials by: -1. A JSON file on your file system -1. Environment variables -1. Hard-coding (not recommended) +| Mode | Description | +| ------------ | ---------------------------------- | +| `standard` | Full scan, all plugins | +| `fast` | Parallel scan, optimized for speed | +| `targeted` | Plugin‑specific scanning | +| `compliance` | Compliance‑only execution | +| `ci` | Optimized for CI/CD pipelines | +| `low-memory` | Reduced resource usage | -Start by copying the example config file: -``` -$ cp config_example.js config.js -``` +**Example** -Edit the config file by uncommenting the relevant sections for the cloud provider you are testing. Each cloud has both a `credential_file` option, as well as inline options. For example: -``` -azure: { - // OPTION 1: If using a credential JSON file, enter the path below - // credential_file: '/path/to/file.json', - // OPTION 2: If using hard-coded credentials, enter them below - // application_id: process.env.AZURE_APPLICATION_ID || '', - // key_value: process.env.AZURE_KEY_VALUE || '', - // directory_id: process.env.AZURE_DIRECTORY_ID || '', - // subscription_id: process.env.AZURE_SUBSCRIPTION_ID || '' -} +```bash +./index.js --mode=fast ``` -### Credential Files -If you use the `credential_file` option, point to a file in your file system that follows the correct format for the cloud you are using. +--- -#### AWS -``` -{ - "accessKeyId": "YOURACCESSKEY", - "secretAccessKey": "YOURSECRETKEY" -} -``` +## 🧪 Emulator / Sandbox Execution Mode -#### Azure -``` -{ - "ApplicationID": "YOURAZUREAPPLICATIONID", - "KeyValue": "YOURAZUREKEYVALUE", - "DirectoryID": "YOURAZUREDIRECTORYID", - "SubscriptionID": "YOURAZURESUBSCRIPTIONID" -} -``` +Run scans **without touching real cloud accounts**: -#### GCP -Note: For GCP, you [generate a JSON file](docs/gcp.md) directly from the GCP console, which you should not edit. -``` -{ - "type": "service_account", - "project": "GCPPROJECTNAME", - "client_email": "GCPCLIENTEMAIL", - "private_key": "GCPPRIVATEKEY" -} +```bash +./index.js --emulator=local ``` -#### Oracle OCI -``` -{ - "tenancyId": "YOURORACLETENANCYID", - "compartmentId": "YOURORACLECOMPARTMENTID", - "userId": "YOURORACLEUSERID", - "keyFingerprint": "YOURORACLEKEYFINGERPRINT", - "keyValue": "YOURORACLEKEYVALUE", -} -``` +**Use cases** -### Environment Variables -CloudSploit supports passing environment variables, but you must first uncomment the section of your `config.js` file relevant to the cloud provider being scanned. +* 🔍 Plugin development +* 🧪 Security testing +* 🎓 Training environments +* 🧱 CI sandbox validation -You can then pass the variables listed in each section. For example, for AWS: -``` -{ - access_key: process.env.AWS_ACCESS_KEY_ID || '', - secret_access_key: process.env.AWS_SECRET_ACCESS_KEY || '', - session_token: process.env.AWS_SESSION_TOKEN || '', -} -``` +--- -## Running -To run a standard scan, showing all outputs and results, simply run: -``` -$ ./index.js -``` +## 🌐 Web Frontend Dashboard -## CLI Options -CloudSploit supports many options to customize the run time. Some popular options include: -* AWS GovCloud support: `--govcloud` -* AWS China support: `--china` -* Save the raw cloud provider response data: `--collection=file.json` -* Ignore passing (OK) results: `--ignore-ok` -* Exit with a non-zero code if non-passing results are found: `--exit-code` - * This is a good option for CI/CD systems -* Change the output from a table to raw text: `--console=text` - -See [Output Formats](#output-formates) below for more output options. - -
- Click for a full list of options - - ``` - $ ./index.js -h - - _____ _ _ _____ _ _ _ - / ____| | | |/ ____| | | (_) | - | | | | ___ _ _ __| | (___ _ __ | | ___ _| |_ - | | | |/ _ \| | | |/ _` |\___ \| '_ \| |/ _ \| | __| - | |____| | (_) | |_| | (_| |____) | |_) | | (_) | | |_ - \_____|_|\___/ \__,_|\__,_|_____/| .__/|_|\___/|_|\__| - | | - |_| - - CloudSploit by Khulnasoft Security, Ltd. - Cloud security auditing for AWS, Azure, GCP, Oracle, and GitHub - - usage: index.js [-h] --config CONFIG [--compliance {hipaa,cis,cis1,cis2,pci}] [--plugin PLUGIN] [--govcloud] [--china] [--csv CSV] [--json JSON] [--junit JUNIT] - [--table] [--console {none,text,table}] [--collection COLLECTION] [--ignore-ok] [--exit-code] [--skip-paginate] [--suppress SUPPRESS] - - optional arguments: - -h, --help show this help message and exit - --config CONFIG - The path to a cloud provider credentials file. - --compliance {hipaa,cis,cis1,cis2,pci} - Compliance mode. Only return results applicable to the selected program. - --plugin PLUGIN A specific plugin to run. If none provided, all plugins will be run. Obtain from the exports.js file. E.g. acmValidation - --govcloud AWS only. Enables GovCloud mode. - --china AWS only. Enables AWS China mode. - --csv CSV Output: CSV file - --json JSON Output: JSON file - --junit JUNIT Output: Junit file - --table Output: table - --console {none,text,table} - Console output format. Default: table - --collection COLLECTION - Output: full collection JSON as file - --ignore-ok Ignore passing (OK) results - --exit-code Exits with a non-zero status code if non-passing results are found - --skip-paginate AWS only. Skips pagination (for debugging). - --suppress SUPPRESS Suppress results matching the provided Regex. Format: pluginId:region:resourceId - ``` -
- -## Compliance - -CloudSploit supports mapping of its plugins to particular compliance policies. To run the compliance scan, use the `--compliance` flag. For example: -``` -$ ./index.js --compliance=hipaa -$ ./index.js --compliance=pci -``` +CloudExploit includes an optional **web UI** for real‑time visibility. -Multiple compliance modes can be run at the same time: -``` -$ ./index.js --compliance=cis1 --compliance=cis2 -``` +### Features -CloudSploit currently supports the following compliance mappings: +* 📊 Live scan monitoring +* 🧠 Risk scoring visualization +* 📁 Historical scan explorer +* 📉 Compliance dashboards +* 🔐 Multi‑account view -### HIPAA -``` -$ ./index.js --compliance=hipaa -``` -HIPAA scans map CloudSploit plugins to the Health Insurance Portability and Accountability Act of 1996. +**Run the frontend** -### PCI -``` -$ ./index.js --compliance=pci +```bash +cd web +npm install +npm run dev ``` -PCI scans map CloudSploit plugins to the Payment Card Industry Data Security Standard. -### CIS Benchmarks -``` -$ ./index.js --compliance=cis -$ ./index.js --compliance=cis1 -$ ./index.js --compliance=cis2 -``` +Open: **[http://localhost:3000](http://localhost:3000)** -CIS Benchmarks are supported, both for Level 1 and Level 2 controls. Passing `--compliance=cis` will run both level 1 and level 2 controls. +--- -## Output Formats -CloudSploit supports output in several formats for consumption by other tools. If you do not specify otherwise, CloudSploit writes output to standard output (the console) as a table. +# ⚡ Quick Start -Note: You can pass multiple output formats and combine options for further customization. For example: -``` -# Print a table to the console and save a CSV file -$ ./index.js --csv=file.csv --console=table +## 🧰 Local Install -# Print text to the console and save a JSON and JUnit file while ignoring passing results -$ ./index.js --json=file.json --junit=file.xml --console=text --ignore-ok +```bash +git clone https://github.com/khulnasoft/cloudexploit.git +cd cloudexploit +npm install +./index.js -h ``` -### Console Output -By default, CloudSploit results are printed to the console in a table format (with colors). You can override this and use plain text instead, by running: -``` -$ ./index.js --console=text -``` +## 🐳 Docker -Alternatively, you can suppress the console output entirely by running: -``` -$ ./index.js --console=none +```bash +docker build . -t cloudexploit:latest + +docker run cloudexploit:latest -h + +docker run \ + -e AWS_ACCESS_KEY_ID=XX \ + -e AWS_SECRET_ACCESS_KEY=YY \ + cloudexploit:latest --compliance=pci ``` -### Ignoring Passing Results -You can ignore results from output that return an OK status by passing a `--ignore-ok` commandline argument. +--- + +# ⚙️ Runtime Examples + +### Full Scan -### CSV +```bash +./index.js ``` -$ ./index.js --csv=file.csv + +### Fast Parallel Scan + +```bash +./index.js --mode=fast ``` -### JSON +### Compliance Scan + +```bash +./index.js --compliance=pci ``` -$ ./index.js --json=file.json + +### CI/CD Fail on Risk + +```bash +./index.js --exit-code --ignore-ok ``` -### JUnit XML +### Save Output + +```bash +./index.js --json=report.json --csv=report.csv ``` -$ ./index.js --junit=file.xml + +--- + +# 🔐 Supported Cloud Providers + +* Amazon Web Services +* Microsoft Azure +* Google Cloud Platform +* Oracle Cloud Infrastructure +* GitHub + +--- + +# ⚙️ Configuration + +CloudExploit requires **read‑only security audit access**. + +### Setup credentials + +* AWS → `docs/aws.md` +* Azure → `docs/azure.md` +* GCP → `docs/gcp.md` +* OCI → `docs/oracle.md` + +**Example config** + +```js +azure: { + application_id: process.env.AZURE_APPLICATION_ID, + key_value: process.env.AZURE_KEY_VALUE, + directory_id: process.env.AZURE_DIRECTORY_ID, + subscription_id: process.env.AZURE_SUBSCRIPTION_ID +} ``` -### Collection Output -CloudSploit saves the data queried from the cloud provider APIs in JSON format, which can be saved alongside other files for debugging or historical purposes. +--- + +# 🧠 Architecture + +CloudExploit uses a **two‑phase scanning pipeline**: + ``` -$ ./index.js --collection=file.json +[ Collection Engine ] + ↓ +[ Data Normalizer ] + ↓ +[ Plugin Scanner Engine ] + ↓ +[ Risk Scoring + Compliance Mapper ] + ↓ +[ Output Engine / API / Web UI ] ``` -## Suppressions -Results can be suppressed by passing the `--suppress` flag (multiple options are supported) with the following format: +--- + +# 🧩 Plugin System + +Each plugin represents: + +* A security control +* A misconfiguration detection rule +* A compliance mapping + +**Run a single plugin** + +```bash +./index.js --plugin acmValidation ``` + +--- + +# 🛠 Writing Plugins + +See: + +* `docs/writing-plugins.md` +* `docs/writing-remediation.md` + +--- + +# 📤 Output Formats + +| Format | Usage | +| -------------- | ------------------------ | +| Console Table | default | +| JSON | `--json=file.json` | +| CSV | `--csv=file.csv` | +| JUnit XML | `--junit=file.xml` | +| Raw Collection | `--collection=data.json` | + +--- + +# 🔕 Suppression System + +Suppress known acceptable risks: + +```bash --suppress pluginId:region:resourceId ``` -For example: +**Example** + +```bash +--suppress *:*:certificate/* ``` -# Suppress all results for the acmValidation plugin -$ ./index.js --suppress acmValidation:*:* -# Suppress all us-east-1 region results -$ ./index.js --suppress *:us-east-1:* +--- -# Suppress all results matching the regex "certificate/*" in all regions for all plugins -$ ./index.js --suppress *:*:certificate/* -``` +# 🧪 CI/CD Integration -## Running a Single Plugin -The `--plugin` flag can be used if you only wish to run one plugin. -``` -$ ./index.js --plugin acmValidation +Perfect for: + +* GitHub Actions +* GitLab CI +* Jenkins +* DevSecOps pipelines + +**Example** + +```bash +./index.js --exit-code --ignore-ok --json=report.json ``` -## Architecture -CloudSploit works in two phases. First, it queries the cloud infrastructure APIs for various metadata about your account, namely the "collection" phase. Once all the necessary data is collected, the result is passed to the "scanning" phase. The scan uses the collected data to search for potential misconfigurations, risks, and other security issues, which are the resulting output. +--- + +# ☁️ Hosted Version + +Use the fully managed SaaS platform: + +👉 [https://cloud.khulnasoft.com/signup](https://cloud.khulnasoft.com/signup) + +--- + +# 🧭 Roadmap + +* 🤖 AI risk scoring engine +* 🧠 LLM‑based remediation suggestions +* 🔗 SIEM & SOAR integrations +* 📡 Real‑time cloud event scanning +* 🛰 Attack path simulation engine + +--- + +# 🤝 Contributing + +We welcome contributions: + +* Plugins +* Remediation scripts +* New cloud providers +* Performance optimizations + +See `.github/CONTRIBUTING.md` + +--- + +# ⚠️ Disclaimer + +CloudExploit is intended for: + +* Security auditing +* Compliance validation +* Defensive security research + +Users must ensure **legal authorization** before scanning cloud environments. + +--- -## Writing a Plugin -Please see our [contribution guidelines](.github/CONTRIBUTING.md) and [complete guide](docs/writing-plugins.md) to writing CloudSploit plugins. +# 💙 Credits -## Other Notes -For other details about the Khulnasoft Wave SaaS product, AWS security policies, and more, [click here](docs/notes.md). +Built with ❤️ by Khulnasoft Security Team diff --git a/bannerGenerator.js b/bannerGenerator.js new file mode 100644 index 000000000..3aeefdd4f --- /dev/null +++ b/bannerGenerator.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +const figlet = require('figlet'); +const chalk = require('chalk'); +const gradient = require('gradient-string'); +const boxen = require('boxen').default; // <-- fix here + +// Generate FIGlet banner +const banner = figlet.textSync('CloudSploit', { font: 'Slant' }); + +// Apply gradient +const colored = gradient.rainbow(banner); + +// Wrap in a box +const boxed = boxen(colored, { + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'magenta' +}); + +// Print to console +console.log(boxed); diff --git a/collectors/alibaba/ack/describeClusterDetail.js b/collectors/alibaba/ack/describeClusterDetail.js new file mode 100644 index 000000000..9a563d446 --- /dev/null +++ b/collectors/alibaba/ack/describeClusterDetail.js @@ -0,0 +1,47 @@ +var ROAClient = require('@alicloud/pop-core').ROAClient; + +var apiVersion = '2015-12-15'; +var httpMethod = 'GET'; +var uriPathClusterDetail = '/clusters/'; +var body = '{}'; +var headers = { + 'Content-Type': 'application/json' +}; +var requestOption = {timeout: 30000}; + +module.exports = function(AlibabaConfig, collection, region, callback) { + let localConfig = { ...AlibabaConfig }; + localConfig['endpoint'] = `https://cs.${region}.aliyuncs.com`; + localConfig['apiVersion'] = apiVersion; + var client = new ROAClient(localConfig); + + var clusters = collection.ack.describeClustersV1[region].data; + var totalClusters = clusters.length; + var completedRequests = 0; + var allRequestsCompleted = function() { + callback(); + }; + + var executeSingleClusterDetail = function(cluster) { + var clusterId = cluster.cluster_id; + var uriPath = `${uriPathClusterDetail}${clusterId}`; + client.request(httpMethod, uriPath, {}, body, headers, requestOption).then((res) => { + collection.ack.describeClusterDetail[region][clusterId] = {}; + collection.ack.describeClusterDetail[region][clusterId].data = res; + completedRequests++; + if (completedRequests === totalClusters) { + allRequestsCompleted(); + } + }, (err) => { + collection.ack.describeClusterDetail[region][clusterId] = { err: err }; + completedRequests++; + if (completedRequests === totalClusters) { + allRequestsCompleted(); + } + }); + }; + + for (var i = 0; i < totalClusters; i++) { + executeSingleClusterDetail(clusters[i]); + } +}; diff --git a/collectors/alibaba/ack/describeClustersV1.js b/collectors/alibaba/ack/describeClustersV1.js new file mode 100644 index 000000000..b60386772 --- /dev/null +++ b/collectors/alibaba/ack/describeClustersV1.js @@ -0,0 +1,49 @@ +var ROAClient = require('@alicloud/pop-core').ROAClient; + +var apiVersion = '2015-12-15'; +var httpMethod = 'GET'; +var uriPath = '/api/v1/clusters'; +var body = '{}'; +var headers = { + 'Content-Type': 'application/json' +}; +var requestOption = {timeout: 30000}; +var pageNumber = 1; + +module.exports = function(AlibabaConfig, collection, region, callback) { + let localConfig = {...AlibabaConfig}; + localConfig['endpoint'] = `https://cs.${region}.aliyuncs.com`; + localConfig['apiVersion'] = apiVersion; + var client = new ROAClient(localConfig); + + collection.ack.describeClustersV1[region].data = []; + + var execute = function() { + var queries = { + 'RegionId': region, + 'page_size': 50, + 'page_number': pageNumber + }; + client.request(httpMethod, uriPath, queries, body, headers, requestOption).then((res) => { + callCB(null, res); + }, (err) => { + callCB(err); + }); + }; + + var callCB = function(err, data) { + if (err) { + collection.ack.describeClustersV1[region].err = err; + return callback(); + } + collection.ack.describeClustersV1[region].data = collection.ack.describeClustersV1[region].data.concat(data.clusters); + if (data['page_info'] && data['page_info']['page_size'] && + data['page_info']['page_number'] &&data['page_info']['total_count'] && + (data['page_info']['page_size'] * data['page_info']['page_number']) < data['page_info']['total_count']){ + pageNumber += 1; + execute(); + } else return callback(); + }; + + execute(); +}; diff --git a/collectors/alibaba/collector.js b/collectors/alibaba/collector.js new file mode 100644 index 000000000..d23fc5e2b --- /dev/null +++ b/collectors/alibaba/collector.js @@ -0,0 +1,563 @@ +/********************* +Collector - The collector will query Alibaba APIs for the information required +to run the CloudExploit scans. This data will be returned in the callback +as a JSON object. + +Arguments: +- AlibabaConfig: If using an access key/secret, pass in the config object. Pass null if not. +- settings: custom settings for the scan. Properties: +- skip_regions: (Optional) List of regions to skip +- api_calls: (Optional) If provided, will only query these APIs. +- Example: +{ + "skip_regions": ["cn-hangzhou", "cn-shanghai"], + "api_calls": ["ECS:DescribeInstances", "VPC:DescribeFlowLogs"] +} +- callback: Function to call when the collection is complete +*********************/ + +var alicloud = require('@alicloud/pop-core'); +var async = require('async'); +var helpers = require(__dirname + '/../../helpers/alibaba'); +var collectors = require(__dirname + '/../../collectors/alibaba'); + + +var globalServices = [ + 'OSS', + 'RAM' +]; + +var calls = { + OSS: { + listBuckets: { + override: true + } + }, + ECS: { + DescribeSecurityGroups: { + property: 'SecurityGroups', + subProperty: 'SecurityGroup', + paginate: 'Pages', + apiVersion: '2014-05-26' + }, + DescribeInstances: { + property: 'Instances', + subProperty: 'Instance', + paginate: 'NextToken', + apiVersion: '2014-05-26' + }, + DescribeDisks: { + property: 'Disks', + subProperty: 'Disk', + paginate: 'NextToken', + apiVersion: '2014-05-26' + } + }, + RAM: { + ListPolicies: { + property: 'Policies', + subProperty: 'Policy', + apiVersion: '2015-05-01', + paginate: 'Marker' + }, + ListUsers: { + property: 'Users', + subProperty: 'User', + apiVersion: '2015-05-01', + paginate: 'Marker' + }, + GetPasswordPolicy: { + property: 'PasswordPolicy', + apiVersion: '2015-05-01' + } + }, + GBDB: { + DescribeDBInstances: { + property: 'Items', + subProperty: 'DBInstance', + apiVersion: '2015-05-01', + paginate: 'Pages' + } + }, + VPC: { + DescribeVpcs: { + property: 'Vpcs', + subProperty: 'Vpc', + apiVersion: '2016-04-28', + paginate: 'Pages' + }, + DescribeVSwitches: { + property: 'VSwitches', + subProperty: 'VSwitch', + apiVersion: '2016-04-28', + paginate: 'Pages' + } + }, + RDS: { + DescribeDBInstances: { + property: 'Items', + subProperty: 'DBInstance', + apiVersion: '2014-08-15', + paginate: 'Pages' + } + }, + POLARDB: { + DescribeDBClusters: { + property: 'Items', + subProperty: 'DBCluster', + apiVersion: '2017-08-01', + paginate: 'Pages' + } + }, + STS: { + GetCallerIdentity: { + property: 'AccountId', + apiVersion: '2015-04-01' + } + }, + KMS: { + ListKeys: { + property: 'Keys', + subProperty: 'Key', + apiVersion: '2016-01-20', + paginate: 'Pages' + } + }, + ActionTrail: { + DescribeTrails: { + property: 'TrailList', + apiVersion: '2020-07-06' + } + }, + ApiGateway: { + DescribeApis: { + property: 'ApiSummarys', + subProperty: 'ApiSummary', + apiVersion: '2016-07-14', + paginate: 'Pages' + }, + DescribeApiGroups: { + property: 'ApiGroupAttributes', + subProperty: 'ApiGroupAttribute', + apiVersion: '2016-07-14', + paginate: 'Pages' + } + }, + ACK: { + describeClustersV1: { + override: true + } + }, + TDS: { + DescribeNoticeConfig: { + property: 'NoticeConfigList', + apiVersion: '2018-12-03' + }, + DescribeFieldStatistics: { + property: 'GroupedFields', + apiVersion: '2018-12-03', + }, + DescribeVersionConfig: { + apiVersion: '2018-12-03' + }, + DescribeVulConfig: { + apiVersion: '2018-12-03' + } + } +}; + +var postcalls = [ + { + ECS: { + DescribeInstanceStatus: { + reliesOnService: 'ecs', + reliesOnCall: 'DescribeInstances', + filterKey: ['InstanceId'], + filterValue: ['InstanceId'], + apiVersion: '2014-05-26' + }, + DescribeSecurityGroupAttribute: { + reliesOnService: 'ecs', + reliesOnCall: 'DescribeSecurityGroups', + filterKey: ['SecurityGroupId'], + filterValue: ['SecurityGroupId'], + apiVersion: '2014-05-26' + } + }, + RAM: { + GetPolicy: { + reliesOnService: 'ram', + reliesOnCall: 'ListPolicies', + filterKey: ['PolicyName', 'PolicyType'], + filterValue: ['PolicyName', 'PolicyType'], + apiVersion: '2015-05-01', + resultFilter: 'DefaultPolicyVersion' + }, + GetUser: { + reliesOnService: 'ram', + reliesOnCall: 'ListUsers', + filterKey: ['UserName'], + filterValue: ['UserName'], + resultFilter: 'User', + apiVersion: '2015-05-01' + }, + GetUserMFAInfo: { + reliesOnService: 'ram', + reliesOnCall: 'ListUsers', + filterKey: ['UserName'], + filterValue: ['UserName'], + apiVersion: '2015-05-01' + }, + ListAccessKeys: { + reliesOnService: 'ram', + reliesOnCall: 'ListUsers', + filterKey: ['UserName'], + filterValue: ['UserName'], + apiVersion: '2015-05-01' + }, + ListPoliciesForUser: { + reliesOnService: 'ram', + reliesOnCall: 'ListUsers', + filterKey: ['UserName'], + filterValue: ['UserName'], + apiVersion: '2015-05-01' + }, + GetLoginProfile: { + reliesOnService: 'ram', + reliesOnCall: 'ListUsers', + filterKey: ['UserName'], + filterValue: ['UserName'], + apiVersion: '2015-05-01' + } + }, + KMS: { + DescribeKey: { + reliesOnService: 'kms', + reliesOnCall: 'ListKeys', + filterKey: ['KeyId'], + filterValue: ['KeyId'], + resultFilter: 'KeyMetadata', + apiVersion: '2016-01-20' + } + }, + RDS: { + DescribeParameters: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + DescribeDBInstanceSSL: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + DescribeSQLCollectorPolicy: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + DescribeSQLCollectorRetention: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + DescribeDBInstanceIPArrayList: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + DescribeDBInstanceTDE: { + reliesOnService: 'rds', + reliesOnCall: 'DescribeDBInstances', + filterKey: ['DBInstanceId'], + filterValue: ['DBInstanceId'], + apiVersion: '2014-08-15' + }, + + }, + OSS: { + getBucketInfo: { + reliesOnService: 'oss', + reliesOnCall: 'listBuckets', + override: true + }, + getBucketLifecycle: { + reliesOnService: 'oss', + reliesOnCall: 'listBuckets', + override: true + }, + getBucketRequestPayment: { + reliesOnService: 'oss', + reliesOnCall: 'listBuckets', + override: true + }, + getBucketPolicy: { + reliesOnService: 'oss', + reliesOnCall: 'listBuckets', + override: true + } + }, + ApiGateway: { + DescribeApi: { + reliesOnService: 'apigateway', + reliesOnCall: 'DescribeApis', + filterKey: ['ApiId', 'GroupId'], + filterValue: ['ApiId', 'GroupId'], + apiVersion: '2016-07-14' + } + }, + ACK: { + describeClusterDetail: { + reliesOnService: 'ack', + reliesOnCall: 'describeClustersV1', + override: true + } + }, + } +]; + +var collect = function(AlibabaConfig, settings, callback) { + if (settings.gather) { + return callback(null, calls, postcalls); + } + + var regions = helpers.regions(settings); + + var regionEndpointMap = { + ecs: [ 'cn-hangzhou', 'cn-wulanchabu', 'cn-zhangjiak', 'cn-huhehaote', 'cn-heyuan', 'cn-chengdu', 'ap-southeast-2', 'cn-guangzhou', + 'ap-southeast-3', 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'me-east-1' ], + kms: regions['kms'], + rds: [ 'cn-zhangjiakou', 'cn-huhehaote', 'cn-chengdu', 'ap-southeast-2', 'ap-southeast-3', 'ap-southeast-5', + 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'me-east-1' ], + actiontrail: regions['actiontrail'], + apigateway: regions['apigateway'], + tds: ['ap-southeast-3', 'ap-southeast-1'] + }; + + var collection = {}; + + async.eachOfLimit(calls, 10, function(call, service, serviceCb) { + let serviceLower = service.toLowerCase(); + if (!collection[serviceLower]) collection[serviceLower] = {}; + + async.eachOfLimit(call, 15, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); + if (!collection[serviceLower][callKey]) collection[serviceLower][callKey] = {}; + + let callRegions = regions[serviceLower]; + let requestOption = { + timeout: 300000, //default 3000 ms + method: callObj.method || 'POST' + }; + + async.eachLimit(callRegions, helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + globalServices.indexOf(service) === -1) return regionCb(); + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + + let LocalAlibabaConfig = JSON.parse(JSON.stringify(AlibabaConfig)); + + if (callObj.override) { + collectors[serviceLower][callKey](LocalAlibabaConfig, collection, region, function() { + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + let endpoint = (regionEndpointMap[serviceLower] && regionEndpointMap[serviceLower].includes(region)) ? + `https://${serviceLower}.${region}.aliyuncs.com` : `https://${serviceLower}.aliyuncs.com`; + LocalAlibabaConfig['endpoint'] = endpoint; + LocalAlibabaConfig['apiVersion'] = callObj.apiVersion; + let client = new alicloud(LocalAlibabaConfig); + let paginating = false; + let pageNumber = 1; + var clientCb = function(err, data) { + if (err) collection[serviceLower][callKey][region].err = err; + if (!data) return regionCb(); + if (callObj.property && !data[callObj.property]) return regionCb(); + if (callObj.subProperty && !data[callObj.property][callObj.subProperty]) return regionCb(); + + var dataToAdd = callObj.subProperty ? data[callObj.property][callObj.subProperty] : + callObj.property ? data[callObj.property] : data; + + if (paginating) { + collection[serviceLower][callKey][region].data = collection[serviceLower][callKey][region].data.concat(dataToAdd); + } else { + collection[serviceLower][callKey][region].data = dataToAdd; + } + + if (callObj.paginate && callObj.paginate == 'Pages' && settings.paginate) { + if (data['PageNumber'] && data['PageSize'] && data['TotalCount']) { + let pageSize = callObj.pageSize || parseInt(data['PageSize']); + let totalCount = parseInt(data['TotalCount']); + + if ((pageNumber*pageSize) < totalCount) { + paginating = true; + pageNumber += 1; + let paginateParams = { PageNumber: pageNumber, PageSize: pageSize}; + return execute(null, paginateParams); + } + } + } + + var nextToken = callObj.paginate; + if (settings.paginate && nextToken && data[nextToken]) { + paginating = true; + var paginateProp = callObj.paginateReqProp ? callObj.paginateReqProp : nextToken; + return execute([paginateProp, data[nextToken]]); + } + + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }; + + function execute(nextToken, paginateParams) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(callObj.params || {})); + localParams['RegionId'] = region; + if (nextToken) localParams[nextToken[0]] = nextToken[1]; + else if (paginateParams) localParams = {...localParams, ...paginateParams}; + + client.request(callKey, localParams, requestOption).then((result) => { + clientCb(null, result); + }, (err) => { + clientCb(err); + }); + } + + execute(); + } + }, function() { + callCb(); + }); + }, function() { + serviceCb(); + }); + }, function() { + async.eachSeries(postcalls, function(postcallObj, postcallCb) { + async.eachOfLimit(postcallObj, 10, function(serviceObj, service, serviceCb) { + let serviceLower = service.toLowerCase(); + if (!collection[serviceLower]) collection[serviceLower] = {}; + + async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); + if (!collection[serviceLower][callKey]) collection[serviceLower][callKey] = {}; + + let requestOption = { method: callObj.method || 'POST' }; + async.eachLimit(regions[serviceLower], helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + globalServices.indexOf(service) === -1) return regionCb(); + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + + if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return regionCb(); + + if (callObj.reliesOnCall && + (!collection[callObj.reliesOnService] || + !collection[callObj.reliesOnService][callObj.reliesOnCall] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data.length)) + return regionCb(); + + let LocalAlibabaConfig = JSON.parse(JSON.stringify(AlibabaConfig)); + + if (callObj.override) { + collectors[serviceLower][callKey](LocalAlibabaConfig, collection, region, function() { + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + LocalAlibabaConfig['endpoint'] = (regionEndpointMap[serviceLower] && regionEndpointMap[serviceLower].includes(region)) ? + `https://${serviceLower}.${region}.aliyuncs.com` : `https://${serviceLower}.aliyuncs.com`; + LocalAlibabaConfig['apiVersion'] = callObj.apiVersion; + let client = new alicloud(LocalAlibabaConfig); + + async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall][region].data, 10, function(val, valCb) { + let resultKey = callObj.filterValue[0]; + collection[serviceLower][callKey][region][val[resultKey]] = {}; + + let params = {}; + if (callObj.params) params = JSON.parse(JSON.stringify(callObj.params)); + + for (let key in callObj.filterKey) { + params[callObj.filterKey[key]] = val[callObj.filterValue[key]]; + } + + params['RegionId'] = region; + + var requestCb = function(err, data) { + if (err) collection[serviceLower][callKey][region][val[resultKey]].err = err; + if (!data) return valCb(); + + collection[serviceLower][callKey][region][val[resultKey]].data = (callObj.resultFilter && data[callObj.resultFilter]) ? + data[callObj.resultFilter] : data; + + if (callObj.rateLimit) { + setTimeout(function() { + valCb(); + }, callObj.rateLimit); + } else { + valCb(); + } + }; + + var execute = function() { + client.request(callKey, params, requestOption).then((result) => { + requestCb(null, result); + }, (err) => { + requestCb(err); + }); + }; + + execute(); + }, function() { + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } + }, function() { + callCb(); + }); + }, function() { + serviceCb(); + }); + }, function() { + postcallCb(); + }); + }, function() { + callback(null, collection); + }); + }); +}; + +module.exports = collect; diff --git a/collectors/alibaba/index.js b/collectors/alibaba/index.js new file mode 100644 index 000000000..b5824d95c --- /dev/null +++ b/collectors/alibaba/index.js @@ -0,0 +1,26 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var collectors = {}; + +var directories = fs.readdirSync(__dirname).filter(function(file) { + return fs.statSync(path.join(__dirname, file)).isDirectory(); +}); + +directories.forEach(function(directory) { + collectors[directory] = {}; + + fs + .readdirSync(__dirname + '/' + directory) + .filter(function(file) { + return (file.indexOf('.') !== 0); + }) + .forEach(function(file) { + var collector = require(path.join(__dirname + '/' + directory, file)); + var name = file.substring(0, file.indexOf('.js')); + collectors[directory][name] = collector; + }); +}); + +module.exports = collectors; \ No newline at end of file diff --git a/collectors/alibaba/oss/getBucketInfo.js b/collectors/alibaba/oss/getBucketInfo.js new file mode 100644 index 000000000..8c4a537d5 --- /dev/null +++ b/collectors/alibaba/oss/getBucketInfo.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AlibabaConfig, collection, region, callback) { + index('getBucketInfo', AlibabaConfig, collection, region, callback); +}; \ No newline at end of file diff --git a/collectors/alibaba/oss/getBucketLifecycle.js b/collectors/alibaba/oss/getBucketLifecycle.js new file mode 100644 index 000000000..2ca437c46 --- /dev/null +++ b/collectors/alibaba/oss/getBucketLifecycle.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AlibabaConfig, collection, region, callback) { + index('getBucketLifecycle', AlibabaConfig, collection, region, callback); +}; \ No newline at end of file diff --git a/collectors/alibaba/oss/getBucketPolicy.js b/collectors/alibaba/oss/getBucketPolicy.js new file mode 100644 index 000000000..ae664754f --- /dev/null +++ b/collectors/alibaba/oss/getBucketPolicy.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AlibabaConfig, collection, region, callback) { + index('getBucketPolicy', AlibabaConfig, collection, region, callback); +}; \ No newline at end of file diff --git a/collectors/alibaba/oss/getBucketRequestPayment.js b/collectors/alibaba/oss/getBucketRequestPayment.js new file mode 100644 index 000000000..d110786dd --- /dev/null +++ b/collectors/alibaba/oss/getBucketRequestPayment.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AlibabaConfig, collection, region, callback) { + index('getBucketRequestPayment', AlibabaConfig, collection, region, callback); +}; \ No newline at end of file diff --git a/collectors/alibaba/oss/index.js b/collectors/alibaba/oss/index.js new file mode 100644 index 000000000..abc2bc1e1 --- /dev/null +++ b/collectors/alibaba/oss/index.js @@ -0,0 +1,25 @@ +var async = require('async'); +const OSS = require('ali-oss'); + +module.exports = function(callKey, AlibabaConfig, collection, region, callback) { + async.eachLimit(collection.oss.listBuckets[region].data, 10, function(bucket, bcb){ + if (!bucket) return bcb(); + var localAlibabaConfig = JSON.parse(JSON.stringify(AlibabaConfig)); + if (bucket.region) localAlibabaConfig['region'] = bucket.region; + var store = new OSS(localAlibabaConfig); + let bucketName = bucket.name; + collection.oss[callKey][region][bucketName] = {}; + + store[callKey](bucketName).then((result) => { + if (callKey == 'getBucketPolicy') collection.oss[callKey][region][bucketName].data = (result.policy)? + result.policy : result; + else collection.oss[callKey][region][bucketName].data = result.bucket || result; + bcb(); + }, (err) => { + collection.oss[callKey][region][bucketName].err = err; + bcb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/alibaba/oss/listBuckets.js b/collectors/alibaba/oss/listBuckets.js new file mode 100644 index 000000000..f1d0ea96e --- /dev/null +++ b/collectors/alibaba/oss/listBuckets.js @@ -0,0 +1,34 @@ +const OSS = require('ali-oss'); + +module.exports = function(AlibabaConfig, collection, region, callback) { + let localAlibabaConfig = JSON.parse(JSON.stringify(AlibabaConfig)); + localAlibabaConfig['timeout'] = 300000; + const store = new OSS(localAlibabaConfig); + collection.oss.listBuckets[region].data = []; + + var execute = function(nextToken) { + store.listBuckets({ + 'max-keys': 100, + 'marker': nextToken + }).then((result) => { + callCB(null, result); + }, (err) => { + callCB(err); + }); + }; + + var callCB = function(err, data) { + if (err) { + collection.oss.listBuckets[region].err = err; + return callback(); + } + if (!data || !data.buckets || !data.buckets.length) { + return callback(); + } + collection.oss.listBuckets[region].data = collection.oss.listBuckets[region].data.concat(data.buckets); + if (data.nextMarker) execute(data.nextMarker); + else return callback(); + }; + + execute(); +}; \ No newline at end of file diff --git a/collectors/aws/accessanalyzer/listFindings.js b/collectors/aws/accessanalyzer/listFindings.js new file mode 100644 index 000000000..10c9cb933 --- /dev/null +++ b/collectors/aws/accessanalyzer/listFindings.js @@ -0,0 +1,49 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var accessanalyzer = new AWS.AccessAnalyzer(AWSConfig); + async.eachLimit(collection.accessanalyzer.listAnalyzers[AWSConfig.region].data, 15, function(analyzer, cb) { + collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn] = {}; + var params = { + analyzerArn: analyzer.arn + }; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].err = err; + + if (!data) return cb(); + + if (paginating && data.findings && data.findings.length && + collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].data.findings && + collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].data.findings.length) { + collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].data.findings = collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].data.findings.concat(data.findings); + } else { + collection.accessanalyzer.listFindings[AWSConfig.region][analyzer.arn].data = data; + } + + if (data.nextToken && data.nextToken.length) { + paginating = true; + return execute(data.nextToken); + } + + cb(); + }; + + function execute(nextToken) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (nextToken) localParams['nextToken'] = nextToken; + if (nextToken) { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindings', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindings', params, retries, null, null, null, paginateCb); + } + } + + execute(); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/accessanalyzer/listFindingsV2.js b/collectors/aws/accessanalyzer/listFindingsV2.js new file mode 100644 index 000000000..3f9240b56 --- /dev/null +++ b/collectors/aws/accessanalyzer/listFindingsV2.js @@ -0,0 +1,49 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var accessanalyzer = new AWS.AccessAnalyzer(AWSConfig); + async.eachLimit(collection.accessanalyzer.listAnalyzers[AWSConfig.region].data, 15, function(analyzer, cb) { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn] = {}; + var params = { + analyzerArn: analyzer.arn + }; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].err = err; + + if (!data) return cb(); + + if (paginating && data.findings && data.findings.length && + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings && + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings.length) { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings = collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings.concat(data.findings); + } else { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data = data; + } + + if (data.nextToken && data.nextToken.length) { + paginating = true; + return execute(data.nextToken); + } + + cb(); + }; + + function execute(nextToken) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (nextToken) localParams['nextToken'] = nextToken; + if (nextToken) { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindingsV2', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindingsV2', params, retries, null, null, null, paginateCb); + } + } + + execute(); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/apigateway/getClientCertificate.js b/collectors/aws/apigateway/getClientCertificate.js new file mode 100644 index 000000000..7c68bcfb2 --- /dev/null +++ b/collectors/aws/apigateway/getClientCertificate.js @@ -0,0 +1,41 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var apigateway = new AWS.APIGateway(AWSConfig); + + async.eachLimit(collection.apigateway.getRestApis[AWSConfig.region].data, 5, function(api, cb){ + if (!collection.apigateway.getStages || + !collection.apigateway.getStages[AWSConfig.region] || + !collection.apigateway.getStages[AWSConfig.region][api.id] || + !collection.apigateway.getStages[AWSConfig.region][api.id].data || + !collection.apigateway.getStages[AWSConfig.region][api.id].data.item) { + return cb(); + } + + async.eachLimit(collection.apigateway.getStages[AWSConfig.region][api.id].data.item, 3, function(stage, pCb){ + collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId] = {}; + + let params = { + clientCertificateId: stage.clientCertificateId + }; + + helpers.makeCustomCollectorCall(apigateway, 'getClientCertificate', params, retries, null, null, null, function(err, data) { + if (err) { + collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId].err = err; + return pCb(); + } + if (data) collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId].data = data; + + pCb(); + }); + + }, function(){ + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/apigateway/getIntegration.js b/collectors/aws/apigateway/getIntegration.js new file mode 100644 index 000000000..9a4ce5ca3 --- /dev/null +++ b/collectors/aws/apigateway/getIntegration.js @@ -0,0 +1,52 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var apigateway = new AWS.APIGateway(AWSConfig); + + async.eachLimit(collection.apigateway.getRestApis[AWSConfig.region].data, 5, function(api, cb){ + if (!collection.apigateway.getResources || + !collection.apigateway.getResources[AWSConfig.region] || + !collection.apigateway.getResources[AWSConfig.region][api.id] || + !collection.apigateway.getResources[AWSConfig.region][api.id].data || + !collection.apigateway.getResources[AWSConfig.region][api.id].data.items) { + return cb(); + } + + collection.apigateway.getIntegration[AWSConfig.region][api.id] = {}; + async.eachLimit(collection.apigateway.getResources[AWSConfig.region][api.id].data.items, 3, function(resource, pCb){ + + collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id] = {}; + + async.eachOfLimit(resource.resourceMethods, 3, function(methodVal,methodKey, mCb){ + + collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id][methodKey] = {}; + + let params = { + resourceId: resource.id, + httpMethod: methodKey, + restApiId : api.id, + }; + + helpers.makeCustomCollectorCall(apigateway, 'getIntegration', params, retries, null, null, null, function(err, data) { + if (err) { + collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id][methodKey].err = err; + return mCb(); + } + + if (data) collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id][methodKey].data = data; + mCb(); + }); + }, function(){ + pCb(); + }); + + }, function(){ + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/appmesh/describeVirtualGateway.js b/collectors/aws/appmesh/describeVirtualGateway.js new file mode 100644 index 000000000..e6a55fd82 --- /dev/null +++ b/collectors/aws/appmesh/describeVirtualGateway.js @@ -0,0 +1,42 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var appmesh = new AWS.AppMesh(AWSConfig); + + if (!collection.appmesh || + !collection.appmesh.listMeshes || + !collection.appmesh.listMeshes[AWSConfig.region] || + !collection.appmesh.listMeshes[AWSConfig.region].data) return callback(); + async.eachLimit(collection.appmesh.listMeshes[AWSConfig.region].data, 5, function(mesh, cb){ + + if (!mesh.meshName || !collection.appmesh || + !collection.appmesh.listVirtualGateways || + !collection.appmesh.listVirtualGateways[AWSConfig.region] || + !collection.appmesh.listVirtualGateways[AWSConfig.region][mesh.meshName] || + !collection.appmesh.listVirtualGateways[AWSConfig.region][mesh.meshName].data || + !collection.appmesh.listVirtualGateways[AWSConfig.region][mesh.meshName].data.virtualGateways || + !collection.appmesh.listVirtualGateways[AWSConfig.region][mesh.meshName].data.virtualGateways.length) { + return cb(); + } + + async.eachLimit(collection.appmesh.listVirtualGateways[AWSConfig.region][mesh.meshName].data.virtualGateways, 3, function(gateway, pCb){ + collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName] = {}; + + helpers.makeCustomCollectorCall(appmesh, 'describeVirtualGateway', {virtualGatewayName: gateway.virtualGatewayName,meshName: mesh.meshName}, retries, null, null, null, function(err, data) { + if (err) { + collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName].err = err; + } + + if (data) collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName].data = data; + pCb(); + }); + + }, function() { + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/autoscaling/describeLaunchConfigurations.js b/collectors/aws/autoscaling/describeLaunchConfigurations.js new file mode 100644 index 000000000..eb47515c0 --- /dev/null +++ b/collectors/aws/autoscaling/describeLaunchConfigurations.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var autoscaling = new AWS.AutoScaling(AWSConfig); + + async.eachLimit(collection.autoscaling.describeAutoScalingGroups[AWSConfig.region].data, 15, function(asg, cb){ + collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN] = {}; + var params = { + 'LaunchConfigurationNames': [asg.LaunchConfigurationName] + }; + + helpers.makeCustomCollectorCall(autoscaling, 'describeLaunchConfigurations', params, retries, null, null, null, function(err, data) { + if (err) { + collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].err = err; + } + if (data) collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].data = data; + + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/autoscaling/describeNotificationConfigurations.js b/collectors/aws/autoscaling/describeNotificationConfigurations.js new file mode 100644 index 000000000..34e4adcb6 --- /dev/null +++ b/collectors/aws/autoscaling/describeNotificationConfigurations.js @@ -0,0 +1,26 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var autoscaling = new AWS.AutoScaling(AWSConfig); + + async.eachLimit(collection.autoscaling.describeAutoScalingGroups[AWSConfig.region].data, 15, function(asg, cb){ + var params = { + 'AutoScalingGroupNames':[asg.AutoScalingGroupName] + }; + + helpers.makeCustomCollectorCall(autoscaling, 'describeNotificationConfigurations', params, retries, null, null, null, function(err, data) { + collection.autoscaling.describeNotificationConfigurations[AWSConfig.region][asg.AutoScalingGroupARN] = {}; + if (err || !data) { + collection.autoscaling.describeNotificationConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].err = err; + } else { + collection.autoscaling.describeNotificationConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].data = data; + } + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudfront/getDistribution.js b/collectors/aws/cloudfront/getDistribution.js index 104d48131..f0f979c06 100644 --- a/collectors/aws/cloudfront/getDistribution.js +++ b/collectors/aws/cloudfront/getDistribution.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var cloudfront = new AWS.CloudFront(AWSConfig); async.eachLimit(collection.cloudfront.listDistributions[AWSConfig.region].data, 15, function(distribution, cb){ @@ -10,11 +11,11 @@ module.exports = function(AWSConfig, collection, callback) { 'Id':distribution.Id }; - cloudfront.getDistribution(params, function(err, data) { + helpers.makeCustomCollectorCall(cloudfront, 'getDistribution', params, retries, null, null, null, function(err, data) { if (err) { collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].err = err; } - collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].data = data; + if (data) collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].data = data; cb(); }); diff --git a/collectors/aws/cloudtrail/listTags.js b/collectors/aws/cloudtrail/listTags.js index c699e2581..dc3c2cbc9 100644 --- a/collectors/aws/cloudtrail/listTags.js +++ b/collectors/aws/cloudtrail/listTags.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var cloudtrail = new AWS.CloudTrail(AWSConfig); async.eachLimit(collection.cloudtrail.describeTrails[AWSConfig.region].data, 15, function(trail, cb) { @@ -9,7 +10,7 @@ module.exports = function(AWSConfig, collection, callback) { ResourceIdList: [trail.TrailARN] }; - cloudtrail.listTags(params, function(err, data) { + helpers.makeCustomCollectorCall(cloudtrail, 'listTags', params, retries, null, null, null, function(err, data) { collection.cloudtrail.listTags[AWSConfig.region][trail.TrailARN] = {}; if (err || !data) { collection.cloudtrail.listTags[AWSConfig.region][trail.TrailARN].err = err; diff --git a/collectors/aws/cloudwatch/getEc2MetricStatistics.js b/collectors/aws/cloudwatch/getEc2MetricStatistics.js new file mode 100644 index 000000000..3742ea876 --- /dev/null +++ b/collectors/aws/cloudwatch/getEc2MetricStatistics.js @@ -0,0 +1,38 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.ec2.describeInstances[AWSConfig.region].data, 10, function(reservation, cb) { + reservation.Instances.forEach(instance => { + collection.cloudwatch.getEc2MetricStatistics[AWSConfig.region][instance.InstanceId] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 1); + var params = { + 'MetricName': 'CPUUtilization', + 'Namespace': 'AWS/EC2', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 3600, + 'Statistics': ['Average'], + 'Dimensions': [ + { + Name: 'InstanceId', + Value: instance.InstanceId + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params,retries, null, null, null, function(err, data) { + if (err) collection.cloudwatch.getEc2MetricStatistics[AWSConfig.region][instance.InstanceId].err = err; + if (data) collection.cloudwatch.getEc2MetricStatistics[AWSConfig.region][instance.InstanceId].data = data; + }); + }); + cb(); + }, function() { + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getEcMetricStatistics.js b/collectors/aws/cloudwatch/getEcMetricStatistics.js new file mode 100644 index 000000000..5ecb621e2 --- /dev/null +++ b/collectors/aws/cloudwatch/getEcMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.elasticache.describeCacheClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 1); + var params = { + 'MetricName': 'CPUUtilization', + 'Namespace':'AWS/ElastiCache', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 3600, + 'Statistics': ['Average'], + 'Dimensions' : [ + { + Name: 'CacheClusterId', + Value: cluster.CacheClusterId + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId].err = err; + } + if (data) collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getEsMetricStatistics.js b/collectors/aws/cloudwatch/getEsMetricStatistics.js new file mode 100644 index 000000000..559a3ee40 --- /dev/null +++ b/collectors/aws/cloudwatch/getEsMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.opensearch.listDomainNames[AWSConfig.region].data, 10, function(domain, cb){ + collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 1); + var params = { + 'MetricName': 'ClusterStatus.Red', + 'Namespace':'AWS/ES', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 3600, + 'Statistics': ['Maximum'], + 'Dimensions' : [ + { + Name: 'DomainName', + Value: domain.DomainName + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName].err = err; + } + if (data) collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getRdsMetricStatistics.js b/collectors/aws/cloudwatch/getRdsMetricStatistics.js new file mode 100644 index 000000000..afc81333a --- /dev/null +++ b/collectors/aws/cloudwatch/getRdsMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.rds.describeDBInstances[AWSConfig.region].data, 10, function(instance, cb){ + collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + var params = { + 'MetricName': 'CPUUtilization', + 'Namespace':'AWS/RDS', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 86400, + 'Statistics': ['Average'], + 'Dimensions' : [ + { + Name: 'DBInstanceIdentifier', + Value: instance.DBInstanceIdentifier + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; + } + if (data) collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js b/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js new file mode 100644 index 000000000..a08077075 --- /dev/null +++ b/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.rds.describeDBInstances[AWSConfig.region].data, 10, function(instance, cb){ + collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + var params = { + 'MetricName': 'ReadIOPS', + 'Namespace':'AWS/RDS', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 86400, + 'Statistics': ['Sum'], + 'Dimensions' : [ + { + Name: 'DBInstanceIdentifier', + Value: instance.DBInstanceIdentifier + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; + } + if (data) collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js b/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js new file mode 100644 index 000000000..5f68bfecd --- /dev/null +++ b/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.rds.describeDBInstances[AWSConfig.region].data, 10, function(instance, cb){ + collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + var params = { + 'MetricName': 'WriteIOPS', + 'Namespace':'AWS/RDS', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 86400, + 'Statistics': ['Sum'], + 'Dimensions' : [ + { + Name: 'DBInstanceIdentifier', + Value: instance.DBInstanceIdentifier + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; + } + if (data) collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/cloudwatch/getredshiftMetricStatistics.js b/collectors/aws/cloudwatch/getredshiftMetricStatistics.js new file mode 100644 index 000000000..979f8543e --- /dev/null +++ b/collectors/aws/cloudwatch/getredshiftMetricStatistics.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var cloudwatch = new AWS.CloudWatch(AWSConfig); + + async.eachLimit(collection.redshift.describeClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier] = {}; + var endTime = new Date(); + var startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + var params = { + 'MetricName': 'CPUUtilization', + 'Namespace':'AWS/Redshift', + 'StartTime': startTime.toISOString(), + 'EndTime': endTime.toISOString(), + 'Period': 3600, + 'Statistics': ['Average'], + 'Dimensions' : [ + { + Name: 'ClusterIdentifier', + Value: cluster.ClusterIdentifier + } + ] + }; + + helpers.makeCustomCollectorCall(cloudwatch, 'getMetricStatistics', params, retries, null, null, null, function(err, data) { + if (err) { + collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier].err = err; + } + if (data) collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/codebuild/batchGetProjects.js b/collectors/aws/codebuild/batchGetProjects.js new file mode 100644 index 000000000..e176ce5d7 --- /dev/null +++ b/collectors/aws/codebuild/batchGetProjects.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var codebuild = new AWS.CodeBuild(AWSConfig); + + async.eachLimit(collection.codebuild.listProjects[AWSConfig.region].data, 15, function(project, cb){ + collection.codebuild.batchGetProjects[AWSConfig.region][project] = {}; + + var params = { + names: [project], + }; + + helpers.makeCustomCollectorCall(codebuild, 'batchGetProjects', params, retries, null, null, null, function(err, data) { + if (err) { + collection.codebuild.batchGetProjects[AWSConfig.region][project].err = err; + } + if (data) collection.codebuild.batchGetProjects[AWSConfig.region][project].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/collector.js b/collectors/aws/collector.js index d3a73639d..a0080d53b 100644 --- a/collectors/aws/collector.js +++ b/collectors/aws/collector.js @@ -1,6 +1,6 @@ /********************* Collector - The collector will query AWS APIs for the information required - to run the CloudSploit scans. This data will be returned in the callback + to run the CloudExploit scans. This data will be returned in the callback as a JSON object. Arguments: @@ -21,1127 +21,407 @@ var async = require('async'); var https = require('https'); var helpers = require(__dirname + '/../../helpers/aws'); var collectors = require(__dirname + '/../../collectors/aws'); - +var collectData = require(__dirname + '/../../helpers/shared.js'); // Override max sockets var agent = new https.Agent({maxSockets: 100}); AWS.config.update({httpOptions: {agent: agent}}); -var globalServices = [ - 'S3', - 'IAM', - 'CloudFront', - 'Route53', - 'Route53Domains', - 'WAFRegional' -]; - -var calls = { - ACM: { - listCertificates: { - property: 'CertificateSummaryList', - paginate: 'NextToken' - } - }, - Athena: { - listWorkGroups: { - property: 'WorkGroups', - paginate: 'NextToken', - params: { - MaxResults: 50 - } - } - }, - AutoScaling: { - describeAutoScalingGroups: { - property: 'AutoScalingGroups', - paginate: 'NextToken', - params: { - MaxRecords: 100 - } - } - }, - CloudFormation: { - describeStacks: { - property: 'Stacks', - paginate: 'NextToken' - } - }, - CloudFront: { - // TODO: Pagination is using an older format - listDistributions: { - property: 'DistributionList', - secondProperty: 'Items' - } - }, - CloudTrail: { - describeTrails: { - property: 'trailList' - } - }, - CloudWatchLogs: { - describeLogGroups: { - property: 'logGroups', - paginate: 'nextToken', - params: { - limit: 50 - } - }, - describeMetricFilters: { - property: 'metricFilters', - paginate: 'nextToken', - params: { - limit: 50 // The max available - } - } - }, - ConfigService: { - describeConfigurationRecorders: { - property: 'ConfigurationRecorders' - }, - describeConfigurationRecorderStatus: { - property: 'ConfigurationRecordersStatus' - } - }, - DirectConnect: { - describeDirectConnectGateways: { - property: 'directConnectGateways', - paginate: 'nextToken' - } - }, - DirectoryService: { - describeDirectories: { - property: 'DirectoryDescriptions', - paginate: 'NextToken' - } - }, - DMS: { - describeReplicationInstances: { - property: 'ReplicationInstances', - paginate: 'Marker' - } - }, - DynamoDB: { - listTables: { - property: 'TableNames', - paginate: 'LastEvaluatedTableName', - paginateReqProp: 'ExclusiveStartTableName' - } - }, - EC2: { - describeAccountAttributes: { - property: 'AccountAttributes' - }, - describeSubnets: { - property: 'Subnets', - paginate: 'NextToken' - }, - describeAddresses: { - property: 'Addresses' - }, - describeVolumes: { - property: 'Volumes' - }, - describeSnapshots: { - // This call must be overridden because the - // default call retrieves every snapshot - // available, including public ones - override: true - }, - describeInstances: { - property: 'Reservations', - paginate: 'NextToken', - params: { - MaxResults: 1000, - Filters: [ - { - Name: 'instance-state-name', - Values: [ - 'pending', - 'running', - 'shutting-down', - 'stopping', - 'stopped' - ] - } - ] - } - }, - describeSecurityGroups: { - property: 'SecurityGroups' - }, - describeVpcs: { - property: 'Vpcs', - paginate: 'NextToken' - }, - describeFlowLogs: { - // TODO: override bc flowlogs are not available in all regions? - property: 'FlowLogs' - }, - describeImages: { - property: 'Images', - params: { - Owners: [ - 'self' - ], - Filters: [ - { - Name: 'state', - Values: [ - 'available' - ] - } - ] - } - }, - describeNatGateways: { - property: 'NatGateways', - paginate: 'NextToken', - params: { - Filter: [ - { - Name: 'state', - Values: [ - 'available' - ] - } - ] - } - }, - describeVpcPeeringConnections: { - property: 'VpcPeeringConnections', - paginate: 'NextToken', - params: { - Filters: [ - { - Name: 'status-code', - Values: [ - 'pending-acceptance', - 'provisioning', - 'active' - ] - } - ] - } - }, - describeVpnGateways: { - property: 'VpnGateways', - params: { - Filters: [ - { - Name: 'state', - Values: [ - 'available' - ] - } - ] - } - }, - describeVpcEndpointServices: { - property: 'ServiceDetails', - paginate: 'NextToken' - }, - describeRouteTables: { - property: 'RouteTables', - paginate: 'NextToken' - }, - describeTags: { - property: 'Tags', - paginate: 'NextToken', - }, - }, - ECR: { - describeRepositories: { - property: 'repositories', - paginate: 'nextToken', - params: { - maxResults: 1000 - } - } - }, - EFS: { - describeFileSystems: { - property: 'FileSystems', - paginate: 'NextMarker', - paginateReqProp: 'Marker' - } - }, - EKS: { - listClusters: { - property: 'clusters', - paginate: 'nextToken' - } - }, - ElasticTranscoder: { - // TODO: Pagination via NextPageToken and PageToken - listPipelines: { - property: 'Pipelines', - paginate: 'NextPageToken', - paginateReqProp: 'PageToken' - } - }, - ELB: { - describeLoadBalancers: { - property: 'LoadBalancerDescriptions', - paginate: 'NextMarker', - paginateReqProp: 'Marker' - } - }, - ELBv2: { - describeLoadBalancers: { - property: 'LoadBalancers', - paginate: 'NextMarker', - paginateReqProp: 'Marker' - }, - describeTargetGroups: { - property: 'TargetGroups', - paginate: 'NextMarker', - paginateReqProp: 'Marker' - } - }, - ES: { - listDomainNames: { - property: 'DomainNames' - } - }, - IAM: { - listServerCertificates: { - property: 'ServerCertificateMetadataList', - paginate: 'Marker' - }, - listGroups: { - property: 'Groups', - paginate: 'Marker' - }, - listUsers: { - property: 'Users', - paginate: 'Marker' - }, - listRoles: { - property: 'Roles', - paginate: 'Marker' - }, - listVirtualMFADevices: { - property: 'VirtualMFADevices', - paginate: 'Marker' - }, - getAccountPasswordPolicy: { - property: 'PasswordPolicy' - }, - getAccountSummary: { - property: 'SummaryMap' - }, - generateCredentialReport: { - override: true - } - }, - Kinesis: { - listStreams: { - property: 'StreamNames' - } - }, - Firehose: { - listDeliveryStreams: { - property: 'DeliveryStreamNames' - } - }, - GuardDuty: { - listDetectors: { - property: 'DetectorIds', - paginate: 'NextToken', - } - }, - KMS: { - listKeys: { - property: 'Keys', - paginate: 'NextMarker', - paginateReqProp: 'Marker', - params: { - Limit: 1000 - } - }, - listAliases: { - property: 'Aliases', - paginate: 'NextMarker', - paginateReqProp: 'Marker', - params: { - Limit: 100 - } - } - }, - Lambda: { - listFunctions: { - property: 'Functions', - paginate: 'NextMarker', - paginateReqProp: 'Marker' - } - }, - Organizations: { - describeOrganization: { - property: 'Organization', - }, - listHandshakesForAccount: { - property: 'Handshakes', - }, - }, - RDS: { - describeDBInstances: { - property: 'DBInstances', - paginate: 'Marker' - }, - describeDBClusters: { - property: 'DBClusters', - paginate: 'Marker' - }, - describeDBEngineVersions: { - property: 'DBEngineVersions', - paginate: 'Marker', - default: true - }, - describeDBSnapshots: { - property: 'DBSnapshots', - paginate: 'Marker' - } - }, - Redshift: { - describeClusters: { - property: 'Clusters', - paginate: 'Marker' - } - }, - Route53Domains: { - listDomains: { - property: 'Domains', - paginate: 'NextPageMarker', - paginateReqProp: 'Marker' - } - }, - S3: { - listBuckets: { - property: 'Buckets' - } - }, - SageMaker: { - listNotebookInstances: { - property: 'NotebookInstances', - paginate: 'NextToken' - } - }, - ServiceQuotas: { - listServiceQuotas: { - property: 'Quotas', - paginate: 'NextToken', - params: { - ServiceCode: 'ec2' - }, - } - }, - SES: { - listIdentities: { - property: 'Identities', - paginate: 'NextToken', - params: { - IdentityType: 'Domain', // TODO: maybe don't filter these? - MaxItems: 1000 - }, - rateLimit: 1000 // ms to rate limit between regions - }, - describeActiveReceiptRuleSet: { - property: 'Rules' - } - }, - Shield: { - describeSubscription: { - property: 'Subscription' - }, - describeEmergencyContactSettings: { - property: 'EmergencyContactList' - }, - listProtections: { - property: 'Protections' - } - }, - SNS: { - listTopics: { - property: 'Topics', - paginate: 'NextToken' - } - }, - SQS: { - listQueues: { - property: 'QueueUrls' - } - }, - SSM: { - describeInstanceInformation: { - property: 'InstanceInformationList', - params: { - MaxResults: 50 - }, - paginate: 'NextToken' - }, - describeParameters: { - property: 'Parameters', - params: { - MaxResults: 50 - }, - paginate: 'NextToken' - } - }, - STS: { - getCallerIdentity: { - property: 'Account' - } - }, - Support: { - describeTrustedAdvisorChecks: { - property: 'checks', - params: { language: 'en' }, - }, - }, - Transfer: { - listServers: { - property: 'Servers', - paginate: 'NextToken', - params: { - MaxResults: 1000 - } - } - }, - WAFRegional: { - listWebACLs: { - property: 'WebACLs', - paginate: 'NextMarker' - } - }, - WorkSpaces: { - describeWorkspaces: { - property: 'Workspaces', - paginate: 'NextToken' - } - }, - XRay: { - getEncryptionConfig: { - property: 'EncryptionConfig' - } - } -}; +var rateError = {message: 'rate', statusCode: 429}; -var postcalls = [ - { - ACM: { - describeCertificate: { - reliesOnService: 'acm', - reliesOnCall: 'listCertificates', - filterKey: 'CertificateArn', - filterValue: 'CertificateArn' - } - }, - Athena: { - getWorkGroup: { - reliesOnService: 'athena', - reliesOnCall: 'listWorkGroups', - filterKey: 'WorkGroup', - filterValue: 'Name' - } - }, - CloudFront: { - getDistribution: { - reliesOnService: 'cloudfront', - reliesOnCall: 'listDistributions', - override: true - } - }, - CloudTrail: { - getTrailStatus: { - reliesOnService: 'cloudtrail', - reliesOnCall: 'describeTrails', - filterKey: 'Name', - filterValue: 'TrailARN' - }, - listTags: { - reliesOnService: 'cloudtrail', - reliesOnCall: 'describeTrails', - override: true - } - }, - DynamoDB: { - describeTable: { - reliesOnService: 'dynamodb', - reliesOnCall: 'listTables', - override: true - } - }, - ES: { - describeElasticsearchDomain: { - reliesOnService: 'es', - reliesOnCall: 'listDomainNames', - filterKey: 'DomainName', - filterValue: 'DomainName' - } - }, - S3: { - getBucketLogging: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketVersioning: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketAcl: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketPolicy: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketEncryption: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketTagging: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketLocation: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getPublicAccessBlock: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - }, - getBucketWebsite: { - reliesOnService: 's3', - reliesOnCall: 'listBuckets', - deleteRegion: true, - signatureVersion: 'v4', - override: true - } - }, - EC2: { - describeSubnets: { - reliesOnService: 'ec2', - reliesOnCall: 'describeVpcs', - override: true - }, - }, - ECR: { - getRepositoryPolicy: { - reliesOnService: 'ecr', - reliesOnCall: 'describeRepositories', - filterKey: 'repositoryName', - filterValue: 'repositoryName' - } - }, - EKS: { - describeCluster: { - reliesOnService: 'eks', - reliesOnCall: 'listClusters', - override: true - } - }, - ELB: { - describeLoadBalancerPolicies: { - reliesOnService: 'elb', - reliesOnCall: 'describeLoadBalancers', - override: true - }, - describeLoadBalancerAttributes: { - reliesOnService: 'elb', - reliesOnCall: 'describeLoadBalancers', - override: true - } - }, - ELBv2: { - describeTargetHealth: { - reliesOnService: 'elbv2', - reliesOnCall: 'describeTargetGroups', - filterKey: 'TargetGroupArn', - filterValue: 'TargetGroupArn' - }, - describeLoadBalancerAttributes: { - reliesOnService: 'elbv2', - reliesOnCall: 'describeLoadBalancers', - override: true - }, - describeListeners: { - reliesOnService: 'elbv2', - reliesOnCall: 'describeLoadBalancers', - override: true - }, - describeTargetGroups: { - reliesOnService: 'elbv2', - reliesOnCall: 'describeLoadBalancers', - override: true - } - }, - IAM: { - getGroup: { - reliesOnService: 'iam', - reliesOnCall: 'listGroups', - filterKey: 'GroupName', - filterValue: 'GroupName' - }, - listAttachedUserPolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - filterKey: 'UserName', - filterValue: 'UserName' - }, - listAttachedGroupPolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listGroups', - filterKey: 'GroupName', - filterValue: 'GroupName' - }, - listAttachedRolePolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listRoles', - filterKey: 'RoleName', - filterValue: 'RoleName' - }, - listUserPolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - filterKey: 'UserName', - filterValue: 'UserName' - }, - listGroupPolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listGroups', - filterKey: 'GroupName', - filterValue: 'GroupName' - }, - listRolePolicies: { - reliesOnService: 'iam', - reliesOnCall: 'listRoles', - filterKey: 'RoleName', - filterValue: 'RoleName' - }, - listSSHPublicKeys: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - filterKey: 'UserName', - filterValue: 'UserName' - }, - listMFADevices: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - filterKey: 'UserName', - filterValue: 'UserName' - }, - listGroupsForUser: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - filterKey: 'UserName', - filterValue: 'UserName', - rateLimit: 100 - } - }, - Kinesis: { - describeStream: { - reliesOnService: 'kinesis', - reliesOnCall: 'listStreams', - override: true - } - }, - Firehose: { - describeDeliveryStream: { - reliesOnService: 'firehose', - reliesOnCall: 'listDeliveryStreams', - override: true - } - }, - KMS: { - describeKey: { - reliesOnService: 'kms', - reliesOnCall: 'listKeys', - filterKey: 'KeyId', - filterValue: 'KeyId' - }, - getKeyRotationStatus: { - reliesOnService: 'kms', - reliesOnCall: 'listKeys', - filterKey: 'KeyId', - filterValue: 'KeyId' - }, - getKeyPolicy: { - reliesOnService: 'kms', - reliesOnCall: 'listKeys', - override: true - } - }, - Lambda: { - getPolicy: { - reliesOnService: 'lambda', - reliesOnCall: 'listFunctions', - filterKey: 'FunctionName', - filterValue: 'FunctionName', - rateLimit: 100, // it's not documented but experimentially 10/second works. - }, - listTags: { - reliesOnService: 'lambda', - reliesOnCall: 'listFunctions', - filterKey: 'Resource', - filterValue: 'FunctionArn' - } - }, - SageMaker: { - describeNotebookInstance: { - reliesOnService: 'sagemaker', - reliesOnCall: 'listNotebookInstances', - filterKey: 'NotebookInstanceName', - filterValue: 'NotebookInstanceName' - } - }, - SES: { - getIdentityDkimAttributes: { - reliesOnService: 'ses', - reliesOnCall: 'listIdentities', - override: true, - rateLimit: 1000 - } - }, - SNS: { - getTopicAttributes: { - reliesOnService: 'sns', - reliesOnCall: 'listTopics', - filterKey: 'TopicArn', - filterValue: 'TopicArn' - } - }, - SQS: { - getQueueAttributes: { - reliesOnService: 'sqs', - reliesOnCall: 'listQueues', - override: true - } - }, - Support: { - describeTrustedAdvisorCheckResult: { - reliesOnService: 'support', - reliesOnCall: 'describeTrustedAdvisorChecks', - filterKey: 'checkId', - filterValue: 'id' - }, - }, - WAFRegional: { - listResourcesForWebACL: { - reliesOnService: 'wafregional', - reliesOnCall: 'listWebACLs', - filterKey: 'WebACLId', - filterValue: 'WebACLId', - checkMultiple: ['APPLICATION_LOAD_BALANCER', 'API_GATEWAY'], - checkMultipleKey: 'ResourceType' - } - }, - GuardDuty: { - getDetector: { - reliesOnService: 'guardduty', - reliesOnCall: 'listDetectors', - override: true, - }, - getMasterAccount: { - reliesOnService: 'guardduty', - reliesOnCall: 'listDetectors', - override: true, - }, - }, - }, - { - IAM: { - getUserPolicy: { - reliesOnService: 'iam', - reliesOnCall: 'listUsers', - override: true - }, - getGroupPolicy: { - reliesOnService: 'iam', - reliesOnCall: 'listGroups', - override: true - }, - getRolePolicy: { - reliesOnService: 'iam', - reliesOnCall: 'listRoles', - override: true - } - } - } -]; +var apiRetryAttempts = 2; +var apiRetryBackoff = 500; +var apiRetryCap = 1000; // Loop through all of the top-level collectors for each service var collect = function(AWSConfig, settings, callback) { + let apiCallErrors = 0; + let apiCallTypeErrors = 0; + let totalApiCallErrors = 0; + + // Used to track rate limiting retries + let retries = []; + // Used to gather info only if (settings.gather) { - return callback(null, calls, postcalls); + return callback(null, helpers.calls, helpers.postcalls); } - + + // Configure an opt-in debug logger + var AWSXRay; + var debugMode = settings.debug_mode; + if (debugMode) AWSXRay = require('aws-xray-sdk'); + AWSConfig.maxRetries = 8; AWSConfig.retryDelayOptions = {base: 100}; var regions = helpers.regions(settings); var collection = {}; - var myDate = new Date(); - var callsTime = myDate.getTime(); - var debugTime = settings.debugTime; - - async.eachOfLimit(calls, 10, function(call, service, serviceCb) { - var serviceLower = service.toLowerCase(); + var errors = {}; + var errorSummary = {}; + var errorTypeSummary = {}; + + let runApiCalls = []; + + var AWSEC2 = new AWS.EC2(AWSConfig); + var params = {AllRegions: true}; + var excludeRegions = []; + + AWSEC2.describeRegions(params, function(err, accountRegions) { + if (err) { + console.log(`[INFO][REGIONS] Could not load all regions from EC2: ${JSON.stringify(err)}`); + } else { + if (accountRegions && + accountRegions.Regions) { + excludeRegions = accountRegions.Regions.filter(region => { + return region.OptInStatus == 'not-opted-in'; + }); + } + } - if (!collection[serviceLower]) collection[serviceLower] = {}; + async.eachOfLimit(helpers.calls, 10, function(call, service, serviceCb) { + var serviceName = service; + var serviceLower = service.toLowerCase(); + if (!collection[serviceLower]) collection[serviceLower] = {}; - // Loop through each of the service's functions - async.eachOfLimit(call, 10, function(callObj, callKey, callCb) { - if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); - if (!collection[serviceLower][callKey]) collection[serviceLower][callKey] = {}; + // Loop through each of the service's functions + async.eachOfLimit(call, 15, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(serviceName + ':' + callKey) === -1) return callCb(); - var callRegions; + runApiCalls.push(serviceName + ':' + callKey); - if (callObj.default) { - callRegions = regions.default; - } else { - callRegions = regions[serviceLower]; - } + if (!collection[serviceLower][callKey]) { + collection[serviceLower][callKey] = {}; + apiCallErrors = 0; + apiCallTypeErrors = 0; + } - async.eachLimit(callRegions, helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { - if (settings.skip_regions && - settings.skip_regions.indexOf(region) > -1 && - globalServices.indexOf(service) === -1) return regionCb(); - if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + helpers.debugApiCalls(callKey, serviceName, debugMode); - var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); - LocalAWSConfig.region = region; + var callRegions; - if (callObj.override) { - collectors[serviceLower][callKey](LocalAWSConfig, collection, function() { - if (callObj.rateLimit) { - setTimeout(function() { - regionCb(); - }, callObj.rateLimit); - } else { - regionCb(); - } - }); + if (callObj.default) { + callRegions = regions.default; } else { - var executor = new AWS[service](LocalAWSConfig); - var paginating = false; - var executorCb = function(err, data) { - if (debugTime) { - var innerDate = new Date(); - var callInnerTime = innerDate.getTime(); - var thisTime = callInnerTime - callsTime; + callRegions = regions[serviceLower]; + } - console.log(`${callKey} - ${thisTime}`); - } + async.eachLimit(callRegions, helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + helpers.globalServices.indexOf(serviceName) === -1) return regionCb(); - if (err) collection[serviceLower][callKey][region].err = err; + if (excludeRegions && + excludeRegions.filter(excluded=> { + if (excluded.RegionName == region) { + return true; + } + }).length){ + return regionCb(); + } - if (!data) return regionCb(); - if (callObj.property && !data[callObj.property]) return regionCb(); - if (callObj.secondProperty && !data[callObj.secondProperty]) return regionCb(); + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; - var dataToAdd = callObj.secondProperty ? data[callObj.property][callObj.secondProperty] : data[callObj.property]; + var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + LocalAWSConfig.region = region; - if (paginating) { - collection[serviceLower][callKey][region].data = collection[serviceLower][callKey][region].data.concat(dataToAdd); - } else { - collection[serviceLower][callKey][region].data = dataToAdd; - } + if (callObj.override) { + collectors[serviceLower][callKey](LocalAWSConfig, collection, retries, function() { + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + var executor = debugMode ? (AWSXRay.captureAWSClient(new AWS[serviceName](LocalAWSConfig))) : new AWS[serviceName](LocalAWSConfig); + var paginating = false; + var executorCb = function(err, data) { + if (err) { + collection[serviceLower][callKey][region].err = err; + helpers.logError(serviceLower, callKey, region, err, errors, apiCallErrors, apiCallTypeErrors, totalApiCallErrors, errorSummary, errorTypeSummary, debugMode); + } - // If a "paginate" property is set, e.g. NextToken - var nextToken = callObj.paginate; - if (settings.paginate && nextToken && data[nextToken]) { - paginating = true; - var paginateProp = callObj.paginateReqProp ? callObj.paginateReqProp : nextToken; - return execute([paginateProp, data[nextToken]]); - } + if (!data) return regionCb(); + if (callObj.property && !data[callObj.property]) return regionCb(); + if (callObj.secondProperty && !data[callObj.secondProperty]) return regionCb(); - if (callObj.rateLimit) { - setTimeout(function() { - regionCb(); - }, callObj.rateLimit); - } else { - regionCb(); - } - }; + var dataToAdd = callObj.secondProperty ? data[callObj.property][callObj.secondProperty] : data[callObj.property] ? data[callObj.property] : data; - function execute(nextTokens) { // eslint-disable-line no-inner-declarations - // Each region needs its own local copy of callObj.params - // so that the injection of the NextToken doesn't break other calls - var localParams = JSON.parse(JSON.stringify(callObj.params || {})); - if (nextTokens) localParams[nextTokens[0]] = nextTokens[1]; + if (paginating) { + collection[serviceLower][callKey][region].data = collection[serviceLower][callKey][region].data.concat(dataToAdd); + } else { + collection[serviceLower][callKey][region].data = dataToAdd; + } - if (callObj.params || nextTokens) { - executor[callKey](localParams, executorCb); - } else { - executor[callKey](executorCb); + // If a "paginate" property is set, e.g. NextToken + var nextToken = callObj.paginate; + if (settings.paginate && nextToken && data[nextToken]) { + paginating = true; + var paginateProp = callObj.paginateReqProp ? callObj.paginateReqProp : nextToken; + return execute([paginateProp, data[nextToken]]); + } + + regionCb(); + }; + + function execute(nextTokens) { // eslint-disable-line no-inner-declarations + // Each region needs its own local copy of callObj.params + // so that the injection of the NextToken doesn't break other calls + var localParams = JSON.parse(JSON.stringify(callObj.params || {})); + if (nextTokens) localParams[nextTokens[0]] = nextTokens[1]; + if (callObj.params || nextTokens) { + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds/1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); + } + }, function(cb) { + executor[callKey](localParams, function(err, data) { + return cb(err, data); + }); + }, function(err, data){ + executorCb(err, data); + }); + } else { + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds/1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); + } + }, function(cb) { + executor[callKey](function(err, data) { + return cb(err, data); + }); + }, function(err, data){ + executorCb(err, data); + }); + } } + execute(); } - - execute(); - } + }, function() { + helpers.debugApiCalls(callKey, serviceName, debugMode); + callCb(); + }); }, function() { - callCb(); + return serviceCb(); }); }, function() { - serviceCb(); - }); - }, function() { - // Now loop through the follow up calls - async.eachSeries(postcalls, function(postcallObj, postcallCb) { - async.eachOfLimit(postcallObj, 10, function(serviceObj, service, serviceCb) { - var serviceLower = service.toLowerCase(); - if (!collection[serviceLower]) collection[serviceLower] = {}; - - async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { - if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); - if (!collection[serviceLower][callKey]) collection[serviceLower][callKey] = {}; + // Now loop through the follow up calls + async.eachSeries(helpers.postcalls, function(postcallObj, postcallCb) { + async.eachOfLimit(postcallObj, 10, function(serviceObj, service, serviceCb) { + var serviceName = service; + var serviceLower = service.toLowerCase(); + var serviceIntegration = { + enabled : postcallObj && postcallObj[serviceName] && postcallObj[serviceName].sendIntegration && postcallObj[serviceName].sendIntegration.enabled ? true : false, + sendLast : postcallObj && postcallObj[serviceName] && postcallObj[serviceName].sendIntegration && postcallObj[serviceName].sendIntegration.sendLast ? true : false + }; - async.eachLimit(regions[serviceLower], helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { - if (settings.skip_regions && - settings.skip_regions.indexOf(region) > -1 && - globalServices.indexOf(service) === -1) return regionCb(); - if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + if (!collection[serviceLower]) collection[serviceLower] = {}; - // Ensure pre-requisites are met - if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return regionCb(); + async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(serviceName + ':' + callKey) === -1) return callCb(); - if (callObj.reliesOnCall && - (!collection[callObj.reliesOnService] || - !collection[callObj.reliesOnService][callObj.reliesOnCall] || - !collection[callObj.reliesOnService][callObj.reliesOnCall][region] || - !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data || - !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data.length)) return regionCb(); + runApiCalls.push(serviceName + ':' + callKey); - var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); - if (callObj.deleteRegion) { - //delete LocalAWSConfig.region; - LocalAWSConfig.region = settings.govcloud ? 'us-gov-west-1' : settings.china ? 'cn-north-1' : 'us-east-1'; - } else { - LocalAWSConfig.region = region; + if (!collection[serviceLower][callKey]) { + collection[serviceLower][callKey] = {}; + apiCallErrors = 0; + apiCallTypeErrors = 0; } - if (callObj.signatureVersion) LocalAWSConfig.signatureVersion = callObj.signatureVersion; - if (callObj.override) { - collectors[serviceLower][callKey](LocalAWSConfig, collection, function() { - if (callObj.rateLimit) { - setTimeout(function() { - regionCb(); - }, callObj.rateLimit); - } else { - regionCb(); - } - }); - } else { - var executor = new AWS[service](LocalAWSConfig); + helpers.debugApiCalls(callKey, serviceName, debugMode); - if (!collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region] || - !collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data) { + async.eachLimit(regions[serviceLower], helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + helpers.globalServices.indexOf(serviceName) === -1) return regionCb(); + + if (excludeRegions && + excludeRegions.filter(excluded=> { + if (excluded.RegionName == region) { + return true; + } + }).length){ return regionCb(); } - async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data, 10, function(dep, depCb) { - if (callObj.checkMultiple) { - async.each(callObj.checkMultiple, function(thisCheck, tcCb){ - collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]] = {}; + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; - var filter = {}; - filter[callObj.filterKey] = dep[callObj.filterValue]; - filter[callObj.checkMultipleKey] = thisCheck; + // Ensure pre-requisites are met + if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return regionCb(); - executor[callKey](filter, function(err, data) { - if (debugTime) { - var innerDate = new Date(); - var callInnerTime = innerDate.getTime(); - var thisTime = callInnerTime - callsTime; + if (callObj.reliesOnCall && + (!collection[callObj.reliesOnService] || + !collection[callObj.reliesOnService][callObj.reliesOnCall] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data.length)) + return regionCb(); - console.log(`${callKey} - ${thisTime}`); - } - if (err) { - collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].err = err; - } - if (data) { - if (!collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].data) { - collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].data = data; - } - } - tcCb(); - }); - }, function() { - depCb(); - }); - } else { + var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + if (callObj.deleteRegion) { + //delete LocalAWSConfig.region; + LocalAWSConfig.region = settings.govcloud ? 'us-gov-west-1' : settings.china ? 'cn-north-1' : 'us-east-1'; + } else { + LocalAWSConfig.region = region; + } + if (callObj.signatureVersion) LocalAWSConfig.signatureVersion = callObj.signatureVersion; + + if (callObj.override) { + collectors[serviceLower][callKey](LocalAWSConfig, collection, retries, function() { + + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + var executor = debugMode ? (AWSXRay.captureAWSClient(new AWS[serviceName](LocalAWSConfig))) : new AWS[serviceName](LocalAWSConfig); + + if (!collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data) { + return regionCb(); + } + + async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data, 10, function(dep, depCb) { collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]] = {}; var filter = {}; filter[callObj.filterKey] = dep[callObj.filterValue]; - executor[callKey](filter, function(err, data) { - if (debugTime) { - var innerDate = new Date(); - var callInnerTime = innerDate.getTime(); - var thisTime = callInnerTime - callsTime; - console.log(`${callKey} - ${thisTime}`); + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds/1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); } - if (err) { - collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].err = err; - depCb(); + }, function(cb) { + executor[callKey](filter, function(err, data) { + if (helpers.collectRateError(err, rateError)) { + return cb(err); + } else if (err) { + collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].err = err; + helpers.logError(serviceLower, callKey, region, err, errors, apiCallErrors, apiCallTypeErrors, totalApiCallErrors, errorSummary, errorTypeSummary, debugMode); + return cb(); + } else { + collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].data = data; + return cb(); + } + }); + }, function(){ + + if (callObj.rateLimit) { + setTimeout(function() { + depCb(); + }, callObj.rateLimit); } else { - collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].data = data; depCb(); } }); - } - }, function() { - if (callObj.rateLimit) { - setTimeout(function() { - regionCb(); - }, callObj.rateLimit); - } else { + }, function() { regionCb(); - } - }); - } + }); + } + }, function() { + helpers.debugApiCalls(callKey, serviceName, debugMode); + callCb(); + }); }, function() { - callCb(); + if (serviceIntegration.enabled && + !serviceIntegration.sendLast && + settings.identifier && + collection[serviceLower] && + Object.keys(collection[serviceLower]) && + Object.keys(collection[serviceLower]).length && + collectData.callsCollected(serviceName, collection, helpers.calls, helpers.postcalls) + ) { + try { + collectData.processIntegration(serviceName, settings, collection, helpers.calls, helpers.postcalls, debugMode,function() { + return serviceCb(); + }); + } catch (e) { + return serviceCb(); + } + } else { + return serviceCb(); + } }); }, function() { - serviceCb(); + postcallCb(); }); }, function() { - postcallCb(); + if (settings.identifier) { + async.each(helpers.integrationSendLast, function(serv, cb) { + settings.identifier.service = serv.toLowerCase(); + + if (collection[serv.toLowerCase()] && + Object.keys(collection[serv.toLowerCase()]) && + Object.keys(collection[serv.toLowerCase()]).length && + collectData.callsCollected(serv, collection, helpers.calls, helpers.postcalls) + ) { + try { + collectData.processIntegration(serv, settings, collection, helpers.calls, helpers.postcalls, debugMode, function() { + console.log(`Integration for service ${serv} processed.`); + cb(); + }); + } catch (e) { + cb(); + } + + } else { + cb(); + } + }, function() { + callback(null, collection, runApiCalls, errorSummary, errorTypeSummary, errors, retries); + }); + + } else { + callback(null, collection, runApiCalls, errorSummary, errorTypeSummary, errors, retries); + + } + }); - }, function() { - callback(null, collection); }); }); }; -module.exports = collect; +module.exports = collect; \ No newline at end of file diff --git a/collectors/aws/collector_multipart.js b/collectors/aws/collector_multipart.js new file mode 100644 index 000000000..1033207ee --- /dev/null +++ b/collectors/aws/collector_multipart.js @@ -0,0 +1,457 @@ +/********************* + Collector - The collector will query AWS APIs for the information required + to run the CloudExploit scans. This data will be returned in the callback + as a JSON object. + + Arguments: + - AWSConfig: If using an access key/secret, pass in the config object. Pass null if not. + - settings: custom settings for the scan. Properties: + - skip_regions: (Optional) List of regions to skip + - api_calls: (Optional) If provided, will only query these APIs. + - Example: + { + "skip_regions": ["us-east-2", "eu-west-1"], + "api_calls": ["EC2:describeInstances", "S3:listBuckets"] + } + - callback: Function to call when the collection is complete + *********************/ + +var AWS = require('aws-sdk'); +var async = require('async'); +var https = require('https'); +var helpers = require(__dirname + '/../../helpers/aws'); +var collectors = require(__dirname + '/../../collectors/aws'); +var collectData = require(__dirname + '/../../helpers/shared.js'); + +// Override max sockets +var agent = new https.Agent({maxSockets: 100}); +AWS.config.update({httpOptions: {agent: agent}}); + +var CALLS_CONFIG = { + TOTAL_PARTS: 14, + CALLS_PARTS: 4, + POSTCALLS_PARTS: 10 +}; + +var rateError = {message: 'rate', statusCode: 429}; + +var apiRetryAttempts = 2; +var apiRetryBackoff = 500; +var apiRetryCap = 1000; +var hasReturned = false; + +// Loop through all of the top-level collectors for each service +var collect = function(AWSConfig, settings, callback) { + // Used to set locally the position of the calls to be + // used in the calls or postcalls array + let callsPart = 0; + let apiCallErrors = 0; + let apiCallTypeErrors = 0; + let totalApiCallErrors = 0; + + // Used to track rate limiting retries + let retries = []; + + // Used to gather info only + if (settings.gather) { + return callback(null, helpers.callsMultipart, helpers.postcallsMultipart); + } + + // Configure an opt-in debug logger + var AWSXRay; + var debugMode = settings.debug_mode; + if (debugMode) AWSXRay = require('aws-xray-sdk'); + + AWSConfig.maxRetries = 8; + AWSConfig.retryDelayOptions = {base: 100}; + + var regions = helpers.regions(settings); + + var collection = {}; + var errors = {}; + var errorSummary = {}; + var errorTypeSummary = {}; + + if (settings.collection) { + collection = settings.collection; + } + + callsPart = settings.part - 1; + + let runApiCalls = []; + + var AWSEC2 = new AWS.EC2(AWSConfig); + var params = {AllRegions: true}; + var excludeRegions = []; + var timeoutCheck; + + AWSEC2.describeRegions(params, function(err, accountRegions) { + if (err) { + console.log(`[INFO][REGIONS] Could not load all regions from EC2: ${JSON.stringify(err)}`); + } else { + if (accountRegions && + accountRegions.Regions) { + excludeRegions = accountRegions.Regions.filter(region => { + return region.OptInStatus == 'not-opted-in'; + }); + } + } + if (settings.context && settings.context.getRemainingTimeInMillis) { + timeoutCheck = setInterval(function(){ + if (process.env['LOCAL']) return 37000; + + if (settings.context.getRemainingTimeInMillis() < 15000) { + clearInterval(timeoutCheck); + hasReturned = true; + return callback(null, collection, runApiCalls, errorSummary, errorTypeSummary, errors, retries); + } + }, 4000); + } + async.eachOfLimit(helpers.callsMultipart[callsPart], 10, function(call, service, serviceCb) { + if (callsPart >= CALLS_CONFIG.CALLS_PARTS) return serviceCb(); + var serviceName = service; + var serviceLower = service.toLowerCase(); + if (!collection[serviceLower]) collection[serviceLower] = {}; + + // Loop through each of the service's functions + async.eachOfLimit(call, 15, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(serviceName + ':' + callKey) === -1) return callCb(); + + runApiCalls.push(serviceName + ':' + callKey); + + if (!collection[serviceLower][callKey]) { + collection[serviceLower][callKey] = {}; + apiCallErrors = 0; + apiCallTypeErrors = 0; + } + + helpers.debugApiCalls(callKey, serviceName, debugMode); + + var callRegions; + + if (callObj.default) { + callRegions = regions.default; + } else { + callRegions = regions[serviceLower]; + } + + async.eachLimit(callRegions, helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + helpers.globalServicesMultipart.indexOf(serviceName) === -1) return regionCb(); + + if (excludeRegions && + excludeRegions.filter(excluded=> { + if (excluded.RegionName == region) { + return true; + } + }).length){ + return regionCb(); + } + + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + + var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + LocalAWSConfig.region = region; + + if (callObj.override) { + collectors[serviceLower][callKey](LocalAWSConfig, collection, retries, function() { + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + var executor = debugMode ? (AWSXRay.captureAWSClient(new AWS[serviceName](LocalAWSConfig))) : new AWS[serviceName](LocalAWSConfig); + var paginating = false; + var executorCb = function(err, data) { + if (err) { + collection[serviceLower][callKey][region].err = err; + helpers.logError(serviceLower, callKey, region, err, errors, apiCallErrors, apiCallTypeErrors, totalApiCallErrors, errorSummary, errorTypeSummary, debugMode); + } + + if (!data) return regionCb(); + if (callObj.property && !data[callObj.property]) return regionCb(); + if (callObj.secondProperty && !data[callObj.secondProperty]) return regionCb(); + + var dataToAdd = callObj.secondProperty ? data[callObj.property][callObj.secondProperty] : data[callObj.property] ? data[callObj.property] : data; + + if (paginating) { + collection[serviceLower][callKey][region].data = collection[serviceLower][callKey][region].data.concat(dataToAdd); + } else { + collection[serviceLower][callKey][region].data = dataToAdd; + } + + // If a "paginate" property is set, e.g. NextToken + var nextToken = callObj.paginate; + if (settings.paginate && nextToken && data[nextToken]) { + paginating = true; + var paginateProp = callObj.paginateReqProp ? callObj.paginateReqProp : nextToken; + return execute([paginateProp, data[nextToken]]); + } + + regionCb(); + }; + + function execute(nextTokens) { // eslint-disable-line no-inner-declarations + // Each region needs its own local copy of callObj.params + // so that the injection of the NextToken doesn't break other calls + var localParams = JSON.parse(JSON.stringify(callObj.params || {})); + if (nextTokens) localParams[nextTokens[0]] = nextTokens[1]; + if (callObj.params || nextTokens) { + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds / 1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); + } + }, function(cb) { + executor[callKey](localParams, function(err, data) { + return cb(err, data); + }); + }, function(err, data){ + executorCb(err, data); + }); + } else { + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds / 1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); + } + }, function(cb) { + executor[callKey](function(err, data) { + return cb(err, data); + }); + }, function(err, data){ + executorCb(err, data); + }); + } + } + execute(); + } + }, function() { + helpers.debugApiCalls(callKey, serviceName, debugMode, true); + callCb(); + }); + }, function() { + return serviceCb(); + }); + }, function() { + // Now loop through the follow up calls + if (settings.part > CALLS_CONFIG.CALLS_PARTS) { + callsPart = settings.part - CALLS_CONFIG.CALLS_PARTS - 1; + } else { + if (timeoutCheck) { + clearInterval(timeoutCheck); + } + if (!hasReturned) { + return callback(null, collection, runApiCalls, errorSummary, errorTypeSummary, errors); + } + } + + async.eachOfLimit(helpers.postcallsMultipart[callsPart], 10, function(serviceObj, service, serviceCb) { + var serviceName = service; + var serviceLower = service.toLowerCase(); + var sendIntegration = helpers.postcallsMultipart[callsPart] && helpers.postcallsMultipart[callsPart][serviceName] && helpers.postcallsMultipart[callsPart][serviceName].sendIntegration ? helpers.postcallsMultipart[callsPart][serviceName].sendIntegration : false; + var serviceIntegration = { + enabled : sendIntegration && sendIntegration.enabled ? true : false, + sendLast : sendIntegration && sendIntegration.sendLast ? true : false + }; + + if (!collection[serviceLower]) collection[serviceLower] = {}; + + async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(serviceName + ':' + callKey) === -1) return callCb(); + + runApiCalls.push(serviceName + ':' + callKey); + + if (!collection[serviceLower][callKey]) { + collection[serviceLower][callKey] = {}; + apiCallErrors = 0; + apiCallTypeErrors = 0; + } + + helpers.debugApiCalls(callKey, serviceName, debugMode); + + async.eachLimit(regions[serviceLower], helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (settings.skip_regions && + settings.skip_regions.indexOf(region) > -1 && + helpers.globalServices.indexOf(serviceName) === -1) return regionCb(); + + if (excludeRegions && + excludeRegions.filter(excluded=> { + if (excluded.RegionName == region) { + return true; + } + }).length){ + return regionCb(); + } + + if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; + + // Ensure pre-requisites are met + if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return regionCb(); + + if (callObj.reliesOnCall && + (!collection[callObj.reliesOnService] || + !collection[callObj.reliesOnService][callObj.reliesOnCall] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data || + !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data.length)) + return regionCb(); + + var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + if (callObj.deleteRegion) { + //delete LocalAWSConfig.region; + LocalAWSConfig.region = settings.govcloud ? 'us-gov-west-1' : settings.china ? 'cn-north-1' : 'us-east-1'; + } else { + LocalAWSConfig.region = region; + } + if (callObj.signatureVersion) LocalAWSConfig.signatureVersion = callObj.signatureVersion; + + if (callObj.override) { + collectors[serviceLower][callKey](LocalAWSConfig, collection, retries, function() { + + if (callObj.rateLimit) { + setTimeout(function() { + regionCb(); + }, callObj.rateLimit); + } else { + regionCb(); + } + }); + } else { + var executor = debugMode ? (AWSXRay.captureAWSClient(new AWS[serviceName](LocalAWSConfig))) : new AWS[serviceName](LocalAWSConfig); + + if (!collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region] || + !collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data) { + return regionCb(); + } + + async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall][LocalAWSConfig.region].data, 10, function(dep, depCb) { + collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]] = {}; + + var filter = {}; + filter[callObj.filterKey] = dep[callObj.filterValue]; + + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds / 1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return helpers.collectRateError(err, rateError); + } + }, function(cb) { + executor[callKey](filter, function(err, data) { + if (helpers.collectRateError(err, rateError)) { + return cb(err); + } else if (err) { + collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].err = err; + helpers.logError(serviceLower, callKey, region, err, errors, apiCallErrors, apiCallTypeErrors, totalApiCallErrors, errorSummary, errorTypeSummary, debugMode); + return cb(); + } else { + collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].data = data; + return cb(); + } + }); + }, function(err){ + if (err) collection[serviceLower][callKey][LocalAWSConfig.region][dep[callObj.filterValue]].err = err; + + if (callObj.rateLimit) { + setTimeout(function() { + depCb(); + }, callObj.rateLimit); + } else { + depCb(); + } + }); + }, function() { + regionCb(); + }); + } + }, function() { + helpers.debugApiCalls(callKey, serviceName, debugMode, true); + callCb(); + }); + }, function() { + // Note: We intentionally verify against the full api calls and postcalls array below instead of the multipart + if (serviceIntegration.enabled && + !serviceIntegration.sendLast && + settings.identifier && + collection[serviceLower] && + Object.keys(collection[serviceLower]) && + Object.keys(collection[serviceLower]).length && + collectData.callsCollected(serviceName, collection, helpers.calls, helpers.postcalls)) { + collectData.processIntegration(serviceName, settings, collection, helpers.calls, helpers.postcalls, debugMode, function() { + return serviceCb(); + }); + } else { + return serviceCb(); + } + }); + }, function() { + if (settings.identifier && + settings.part == CALLS_CONFIG.TOTAL_PARTS) { + for (let serv of helpers.integrationSendLast) { + settings.identifier.service = serv.toLowerCase(); + + if (collection[serv.toLowerCase()] && + Object.keys(collection[serv.toLowerCase()]) && + Object.keys(collection[serv.toLowerCase()]).length && + collectData.callsCollected(serv, collection, helpers.calls, helpers.postcalls) + ) { + collectData.processIntegration(serv, settings, collection, helpers.calls, helpers.postcalls, debugMode, function() { + console.log(`Integration for service ${serv} processed.`); + }); + } + } + } + if (timeoutCheck) { + clearInterval(timeoutCheck); + } + if (!hasReturned) { + callback(null, collection, runApiCalls, errorSummary, errorTypeSummary, errors, retries); + } + }); + }); + }); +}; + +module.exports = { + collect: collect, + calls: helpers.callsMultipart, + postcalls: helpers.postcallsMultipart +}; \ No newline at end of file diff --git a/collectors/aws/connect/instanceAttachmentStorageConfigs.js b/collectors/aws/connect/instanceAttachmentStorageConfigs.js new file mode 100644 index 000000000..3d147ab58 --- /dev/null +++ b/collectors/aws/connect/instanceAttachmentStorageConfigs.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var connect = new AWS.Connect(AWSConfig); + + async.eachLimit(collection.connect.listInstances[AWSConfig.region].data, 15, function(instance, cb){ + collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id] = {}; + var params = { + 'InstanceId': instance.Id, + 'ResourceType': 'ATTACHMENTS' + }; + + helpers.makeCustomCollectorCall(connect, 'listInstanceStorageConfigs', params, retries, null, null, null, function(err, data) { + if (err) { + collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id].err = err; + } + if (data) collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js b/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js new file mode 100644 index 000000000..11bd73101 --- /dev/null +++ b/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var connect = new AWS.Connect(AWSConfig); + + async.eachLimit(collection.connect.listInstances[AWSConfig.region].data, 15, function(instance, cb){ + collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id] = {}; + var params = { + 'InstanceId': instance.Id, + 'ResourceType': 'CALL_RECORDINGS' + }; + + helpers.makeCustomCollectorCall(connect, 'listInstanceStorageConfigs', params, retries, null, null, null, function(err, data) { + if (err) { + collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id].err = err; + } + if (data) collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js b/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js new file mode 100644 index 000000000..b65aafe66 --- /dev/null +++ b/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var connect = new AWS.Connect(AWSConfig); + + async.eachLimit(collection.connect.listInstances[AWSConfig.region].data, 15, function(instance, cb){ + collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id] = {}; + var params = { + 'InstanceId': instance.Id, + 'ResourceType': 'CHAT_TRANSCRIPTS' + }; + + helpers.makeCustomCollectorCall(connect, 'listInstanceStorageConfigs', params, retries, null, null, null, function(err, data) { + if (err) { + collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id].err = err; + } + if (data) collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js b/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js new file mode 100644 index 000000000..851b7ddf9 --- /dev/null +++ b/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var connect = new AWS.Connect(AWSConfig); + + async.eachLimit(collection.connect.listInstances[AWSConfig.region].data, 15, function(instance, cb){ + collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id] = {}; + var params = { + 'InstanceId': instance.Id, + 'ResourceType': 'SCHEDULED_REPORTS' + }; + + helpers.makeCustomCollectorCall(connect, 'listInstanceStorageConfigs', params, retries, null, null, null, function(err, data) { + if (err) { + collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id].err = err; + } + if (data) collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js b/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js new file mode 100644 index 000000000..4873a4ebe --- /dev/null +++ b/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var connect = new AWS.Connect(AWSConfig); + + async.eachLimit(collection.connect.listInstances[AWSConfig.region].data, 15, function(instance, cb){ + collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id] = {}; + var params = { + 'InstanceId': instance.Id, + 'ResourceType': 'MEDIA_STREAMS' + }; + + helpers.makeCustomCollectorCall(connect, 'listInstanceStorageConfigs', params, retries, null, null, null, function(err, data) { + if (err) { + collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id].err = err; + } + if (data) collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/dynamodb/describeContinuousBackups.js b/collectors/aws/dynamodb/describeContinuousBackups.js new file mode 100644 index 000000000..d7884b6b2 --- /dev/null +++ b/collectors/aws/dynamodb/describeContinuousBackups.js @@ -0,0 +1,24 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var dynamodb = new AWS.DynamoDB(AWSConfig); + + async.eachLimit(collection.dynamodb.listTables[AWSConfig.region].data, 15, function(table, cb){ + collection.dynamodb.describeContinuousBackups[AWSConfig.region][table] = {}; + var params = { + 'TableName': table + }; + + helpers.makeCustomCollectorCall(dynamodb, 'describeContinuousBackups', params, retries, null, null, null, function(err, data) { + if (err) { + collection.dynamodb.describeContinuousBackups[AWSConfig.region][table].err = err; + } + if (data) collection.dynamodb.describeContinuousBackups[AWSConfig.region][table].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/dynamodb/describeTable.js b/collectors/aws/dynamodb/describeTable.js index 65012b255..c6ab50676 100644 --- a/collectors/aws/dynamodb/describeTable.js +++ b/collectors/aws/dynamodb/describeTable.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var dynamodb = new AWS.DynamoDB(AWSConfig); async.eachLimit(collection.dynamodb.listTables[AWSConfig.region].data, 15, function(table, cb){ @@ -10,11 +11,11 @@ module.exports = function(AWSConfig, collection, callback) { 'TableName': table }; - dynamodb.describeTable(params, function(err, data) { + helpers.makeCustomCollectorCall(dynamodb, 'describeTable', params, retries, null, null, null, function(err, data) { if (err) { collection.dynamodb.describeTable[AWSConfig.region][table].err = err; } - collection.dynamodb.describeTable[AWSConfig.region][table].data = data; + if (data) collection.dynamodb.describeTable[AWSConfig.region][table].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/dynamodb/listBackups.js b/collectors/aws/dynamodb/listBackups.js new file mode 100644 index 000000000..dd4138ed6 --- /dev/null +++ b/collectors/aws/dynamodb/listBackups.js @@ -0,0 +1,27 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var dynamodb = new AWS.DynamoDB(AWSConfig); + + async.eachLimit(collection.dynamodb.listTables[AWSConfig.region].data, 15, function(table, cb){ + collection.dynamodb.listBackups[AWSConfig.region][table] = {}; + + var params = { + BackupType : 'ALL', + TableName : table + + }; + + helpers.makeCustomCollectorCall(dynamodb, 'listBackups', params, retries, null, null, null, function(err, data) { + if (err) { + collection.dynamodb.listBackups[AWSConfig.region][table].err = err; + } + if (data) collection.dynamodb.listBackups[AWSConfig.region][table].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/ec2/describeSnapshotAttribute.js b/collectors/aws/ec2/describeSnapshotAttribute.js new file mode 100644 index 000000000..8439c61db --- /dev/null +++ b/collectors/aws/ec2/describeSnapshotAttribute.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ec2 = new AWS.EC2(AWSConfig); + + async.eachLimit(collection.ec2.describeSnapshots[AWSConfig.region].data, 20, function(snapshot, cb){ + collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId] = {}; + var params = { + Attribute: 'createVolumePermission', + SnapshotId: snapshot.SnapshotId + }; + + helpers.makeCustomCollectorCall(ec2, 'describeSnapshotAttribute', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId].err = err; + } + if (data) collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/ec2/describeSnapshots.js b/collectors/aws/ec2/describeSnapshots.js index 6bd845a9f..11a65e486 100644 --- a/collectors/aws/ec2/describeSnapshots.js +++ b/collectors/aws/ec2/describeSnapshots.js @@ -1,20 +1,26 @@ var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); // This call must be overridden because the // default call retrieves every snapshot // available, including public ones -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var ec2 = new AWS.EC2(AWSConfig); var sts = new AWS.STS(AWSConfig); + var paginating = false; + var maxSnapshots = 30000; // Limit the collection to 30,000 snapshots + var createdTime = new Date(); + createdTime.setDate(createdTime.getDate() - 30); - sts.getCallerIdentity({}, function(stsErr, stsData) { + helpers.makeCustomCollectorCall(sts, 'getCallerIdentity', {}, retries, null, null, null, function(stsErr, stsData) { if (stsErr || !stsData.Account) { collection.ec2.describeSnapshots[AWSConfig.region].err = 'Unable to filter by owner ID'; return callback(); } var params = { + MaxResults: 1000, Filters: [ { Name: 'owner-id', @@ -31,14 +37,42 @@ module.exports = function(AWSConfig, collection, callback) { ] }; - ec2.describeSnapshots(params, function(err, data){ + var paginateCb = function(err, data) { if (err) { collection.ec2.describeSnapshots[AWSConfig.region].err = err; - } else { - collection.ec2.describeSnapshots[AWSConfig.region].data = data.Snapshots; + } else if (data) { + const filteredSnapshots = data.Snapshots? data.Snapshots.filter(snapshot => { + return new Date(snapshot.StartTime) > createdTime; + }) : []; + + if (paginating && filteredSnapshots && filteredSnapshots.length && + collection.ec2.describeSnapshots[AWSConfig.region].data && + collection.ec2.describeSnapshots[AWSConfig.region].data.length && + collection.ec2.describeSnapshots[AWSConfig.region].data.length < maxSnapshots) { + collection.ec2.describeSnapshots[AWSConfig.region].data = collection.ec2.describeSnapshots[AWSConfig.region].data.concat(filteredSnapshots); + } else if (!paginating) { + collection.ec2.describeSnapshots[AWSConfig.region].data = filteredSnapshots; + } + if (data.NextToken && data.NextToken.length && + collection.ec2.describeSnapshots[AWSConfig.region].data && + collection.ec2.describeSnapshots[AWSConfig.region].data.length && + collection.ec2.describeSnapshots[AWSConfig.region].data.length < maxSnapshots) { + paginating = true; + return execute(data.NextToken); + } } callback(); - }); + }; + function execute(nextToken) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (nextToken) localParams['NextToken'] = nextToken; + if (nextToken) { + helpers.makeCustomCollectorCall(ec2, 'describeSnapshots', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(ec2, 'describeSnapshots', params, retries, null, null, null, paginateCb); + } + } + execute(); }); -}; \ No newline at end of file +}; diff --git a/collectors/aws/ec2/describeSubnets.js b/collectors/aws/ec2/describeSubnets.js index 46d303766..0ecc6857c 100644 --- a/collectors/aws/ec2/describeSubnets.js +++ b/collectors/aws/ec2/describeSubnets.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var ec2 = new AWS.EC2(AWSConfig); async.eachLimit(collection.ec2.describeVpcs[AWSConfig.region].data, 15, function(vpc, cb){ @@ -19,12 +20,12 @@ module.exports = function(AWSConfig, collection, callback) { ] }; - ec2.describeSubnets(params, function(err, data) { + helpers.makeCustomCollectorCall(ec2, 'describeSubnets', params, retries, null, null, null, function(err, data) { if (err) { collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].err = err; } - collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].data = data; + if (data) collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].data = data; cb(); }); diff --git a/collectors/aws/ecs/describeCluster.js b/collectors/aws/ecs/describeCluster.js new file mode 100644 index 000000000..a53968309 --- /dev/null +++ b/collectors/aws/ecs/describeCluster.js @@ -0,0 +1,29 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachLimit(collection.ecs.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.ecs.describeCluster[AWSConfig.region][cluster] = {}; + + // Check for the multiple subnets in that single VPC + var params = { + clusters: [cluster], + 'include': ['SETTINGS'] + }; + + helpers.makeCustomCollectorCall(ecs, 'describeClusters', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.describeCluster[AWSConfig.region][cluster].err = err; + } + + if (data) collection.ecs.describeCluster[AWSConfig.region][cluster].data = data; + + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/ecs/describeContainerInstances.js b/collectors/aws/ecs/describeContainerInstances.js new file mode 100644 index 000000000..a03285a20 --- /dev/null +++ b/collectors/aws/ecs/describeContainerInstances.js @@ -0,0 +1,36 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachOfLimit(collection.ecs.listContainerInstances[AWSConfig.region], 10, function(containerInstanceData,instance, cb){ + async.eachLimit(containerInstanceData.data, 5, function(containerInstance, ccb){ + collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance] = {}; + + var parts = containerInstance.split('/'); + const clusterName = parts[parts.length - 2]; + // Check for the multiple subnets in that single VPC + var params = { + containerInstances: [containerInstance], + cluster : clusterName, + };collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance] = {}; + + helpers.makeCustomCollectorCall(ecs, 'describeContainerInstances', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance].err = err; + } + + if (data) collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance].data = data; + + ccb(); + }); + }, function(){ + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/ecs/describeServices.js b/collectors/aws/ecs/describeServices.js new file mode 100644 index 000000000..87fa1d703 --- /dev/null +++ b/collectors/aws/ecs/describeServices.js @@ -0,0 +1,36 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachOfLimit(collection.ecs.listServices[AWSConfig.region], 10, function(servicesData,instance, cb){ + async.eachLimit(servicesData.data, 5, function(service, ccb){ + collection.ecs.describeServices[AWSConfig.region][service] = {}; + + var parts = service.split('/'); + const clusterName = parts[parts.length - 2]; + // Check for the multiple subnets in that single VPC + var params = { + services: [service], + cluster : clusterName, + }; + + helpers.makeCustomCollectorCall(ecs, 'describeServices', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.describeServices[AWSConfig.region][service].err = err; + } + + if (data) collection.ecs.describeServices[AWSConfig.region][service].data = data; + + ccb(); + }); + }, function(){ + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/ecs/describeTasks.js b/collectors/aws/ecs/describeTasks.js new file mode 100644 index 000000000..fad0738b3 --- /dev/null +++ b/collectors/aws/ecs/describeTasks.js @@ -0,0 +1,36 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachOfLimit(collection.ecs.listTasks[AWSConfig.region], 10, function(tasksData,instance, cb){ + async.eachLimit(tasksData.data, 5, function(task, ccb){ + collection.ecs.describeTasks[AWSConfig.region][task] = {}; + + var parts = task.split('/'); + const clusterName = parts[parts.length - 2]; + // Check for the multiple subnets in that single VPC + var params = { + tasks: [task], + cluster : clusterName, + }; + + helpers.makeCustomCollectorCall(ecs, 'describeTasks', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.describeTasks[AWSConfig.region][task].err = err; + } + + if (data) collection.ecs.describeTasks[AWSConfig.region][task].data = data; + + ccb(); + }); + }, function(){ + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/ecs/listContainerInstances.js b/collectors/aws/ecs/listContainerInstances.js new file mode 100644 index 000000000..c7b9eb175 --- /dev/null +++ b/collectors/aws/ecs/listContainerInstances.js @@ -0,0 +1,28 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachLimit(collection.ecs.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.ecs.listContainerInstances[AWSConfig.region][cluster] = {}; + + // Check for the multiple subnets in that single VPC + var params = { + cluster: cluster + }; + + helpers.makeCustomCollectorCall(ecs, 'listContainerInstances', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.listContainerInstances[AWSConfig.region][cluster].err = err; + } else if (data && data.containerInstanceArns) { + collection.ecs.listContainerInstances[AWSConfig.region][cluster].data = data.containerInstanceArns; + } + + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/ecs/listServices.js b/collectors/aws/ecs/listServices.js new file mode 100644 index 000000000..4898290ad --- /dev/null +++ b/collectors/aws/ecs/listServices.js @@ -0,0 +1,29 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachLimit(collection.ecs.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.ecs.listServices[AWSConfig.region][cluster] = {}; + + var parts = cluster.split('/'); + const clusterName = parts[parts.length - 1]; + + var params = { + cluster: clusterName + }; + + helpers.makeCustomCollectorCall(ecs, 'listServices', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.listServices[AWSConfig.region][cluster].err = err; + } + if (data && data.serviceArns) collection.ecs.listServices[AWSConfig.region][cluster].data = data.serviceArns; + + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/ecs/listTasks.js b/collectors/aws/ecs/listTasks.js new file mode 100644 index 000000000..082cad3ec --- /dev/null +++ b/collectors/aws/ecs/listTasks.js @@ -0,0 +1,29 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.ECS(AWSConfig); + + async.eachLimit(collection.ecs.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.ecs.listTasks[AWSConfig.region][cluster] = {}; + + var parts = cluster.split('/'); + const clusterName = parts[parts.length - 1]; + + var params = { + cluster: clusterName + }; + + helpers.makeCustomCollectorCall(ecs, 'listTasks', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ecs.listTasks[AWSConfig.region][cluster].err = err; + } else if (data && data.taskArns) { + collection.ecs.listTasks[AWSConfig.region][cluster].data = data.taskArns; + } + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/eks/describeCluster.js b/collectors/aws/eks/describeCluster.js index 7cedff74d..e5e485bad 100644 --- a/collectors/aws/eks/describeCluster.js +++ b/collectors/aws/eks/describeCluster.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var eks = new AWS.EKS(AWSConfig); async.eachLimit(collection.eks.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ @@ -12,12 +13,10 @@ module.exports = function(AWSConfig, collection, callback) { name: cluster }; - eks.describeCluster(params, function(err, data) { - if (err) { - collection.eks.describeCluster[AWSConfig.region][cluster].err = err; - } + helpers.makeCustomCollectorCall(eks, 'describeCluster', params, retries, null, null, null, function(err, data) { + if (err) collection.eks.describeCluster[AWSConfig.region][cluster].err = err; - collection.eks.describeCluster[AWSConfig.region][cluster].data = data; + if (data) collection.eks.describeCluster[AWSConfig.region][cluster].data = data; cb(); }); diff --git a/collectors/aws/eks/describeNodegroups.js b/collectors/aws/eks/describeNodegroups.js new file mode 100644 index 000000000..bba92226e --- /dev/null +++ b/collectors/aws/eks/describeNodegroups.js @@ -0,0 +1,52 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var eks = new AWS.EKS(AWSConfig); + //var autoscaling = new AWS.AutoScaling(AWSConfig); + + async.eachLimit(collection.eks.listClusters[AWSConfig.region].data, 5, function(cluster, cb){ + collection.eks.describeNodegroups[AWSConfig.region][cluster] = {}; + async.eachLimit(collection.eks.listNodegroups[AWSConfig.region][cluster].data, 3, function(nodeGroup, cb){ + collection.eks.describeNodegroups[AWSConfig.region][cluster][nodeGroup] = {}; + // Check for the multiple subnets in that single VPC + var params = { + clusterName: cluster, + nodegroupName: nodeGroup + }; + + helpers.makeCustomCollectorCall(eks, 'describeNodegroup', params, retries, null, null, null, function(err, data) { + if (err) collection.eks.describeNodegroups[AWSConfig.region][cluster][nodeGroup].err = err; + //var autoScalingGroupNames = []; + if (data) collection.eks.describeNodegroups[AWSConfig.region][cluster][nodeGroup].data = data.nodegroup; + // right now we are taking the maxSize to count the node. But if we decide to count the exact node count + // we need to uncomment the below code. + // collection.eks.describeNodegroups[AWSConfig.region][cluster][nodeGroup].data['nodecount'] = 0; + // data.nodegroup.resources.autoScalingGroups.forEach( autoscalingGroup => { + // autoScalingGroupNames.push(autoscalingGroup.name); + // }); + // var params = { + // 'AutoScalingGroupNames': + // autoScalingGroupNames + // }; + // console.log(params); + // autoscaling.describeAutoScalingGroups(params, function(err, data1){ + // if (err) console.log(err); + // data1.AutoScalingGroups.forEach(autoscalingGroup =>{ + // collection.eks.describeNodegroups[AWSConfig.region][cluster][nodeGroup].data['nodecount'] += + // autoscalingGroup.Instances.length; + // }); + // cb(); + // }); + cb(); + }); + }, function(){ + setTimeout(function(){ + cb(); + }, 100); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/eks/listNodegroups.js b/collectors/aws/eks/listNodegroups.js new file mode 100644 index 000000000..3679225bf --- /dev/null +++ b/collectors/aws/eks/listNodegroups.js @@ -0,0 +1,26 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var eks = new AWS.EKS(AWSConfig); + + async.eachLimit(collection.eks.listClusters[AWSConfig.region].data, 10, function(cluster, cb){ + collection.eks.listNodegroups[AWSConfig.region][cluster] = {}; + + // Check for the multiple subnets in that single VPC + var params = { + clusterName: cluster + }; + + helpers.makeCustomCollectorCall(eks, 'listNodegroups', params, retries, null, null, null, function(err, data) { + if (err) collection.eks.listNodegroups[AWSConfig.region][cluster].err = err; + + if (data) collection.eks.listNodegroups[AWSConfig.region][cluster].data = data.nodegroups; + + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/elasticache/describeCacheSubnetGroups.js b/collectors/aws/elasticache/describeCacheSubnetGroups.js new file mode 100644 index 000000000..41736b495 --- /dev/null +++ b/collectors/aws/elasticache/describeCacheSubnetGroups.js @@ -0,0 +1,24 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var elasticache = new AWS.ElastiCache(AWSConfig); + + async.eachLimit(collection.elasticache.describeCacheClusters[AWSConfig.region].data, 15, function(cluster, cb){ + collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName] = {}; + var params = { + CacheSubnetGroupName: cluster.CacheSubnetGroupName + }; + + helpers.makeCustomCollectorCall(elasticache, 'describeCacheSubnetGroups', params, retries, null, null, null, function(err, data) { + if (err) { + collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName].err = err; + } + if (data) collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/elasticbeanstalk/describeConfigurationSettings.js b/collectors/aws/elasticbeanstalk/describeConfigurationSettings.js new file mode 100644 index 000000000..287476cd5 --- /dev/null +++ b/collectors/aws/elasticbeanstalk/describeConfigurationSettings.js @@ -0,0 +1,26 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var elasticbeanstalk = new AWS.ElasticBeanstalk(AWSConfig); + + async.eachLimit(collection.elasticbeanstalk.describeEnvironments[AWSConfig.region].data, 15, function(environment, cb) { + var params = { + ApplicationName: environment.ApplicationName, + EnvironmentName: environment.EnvironmentName + }; + + helpers.makeCustomCollectorCall(elasticbeanstalk, 'describeConfigurationSettings', params, retries, null, null, null, function(err, data) { + collection.elasticbeanstalk.describeConfigurationSettings[AWSConfig.region][environment.EnvironmentArn] = {}; + if (err || !data) { + collection.elasticbeanstalk.describeConfigurationSettings[AWSConfig.region][environment.EnvironmentArn].err = err; + } else { + collection.elasticbeanstalk.describeConfigurationSettings[AWSConfig.region][environment.EnvironmentArn].data = data; + } + cb(); + }); + }, function() { + callback(); + }); +}; diff --git a/collectors/aws/elb/describeInstanceHealth.js b/collectors/aws/elb/describeInstanceHealth.js new file mode 100644 index 000000000..d7bfa57eb --- /dev/null +++ b/collectors/aws/elb/describeInstanceHealth.js @@ -0,0 +1,24 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var elb = new AWS.ELB(AWSConfig); + + async.eachLimit(collection.elb.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ + collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName] = {}; + var params = { + 'LoadBalancerName':lb.LoadBalancerName + }; + + helpers.makeCustomCollectorCall(elb, 'describeInstanceHealth', params, retries, null, null, null, function(err, data) { + if (err) { + collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName].err = err; + } + if (data) collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/elb/describeLoadBalancerAttributes.js b/collectors/aws/elb/describeLoadBalancerAttributes.js index 7a5e1ff1b..282ee9ef2 100644 --- a/collectors/aws/elb/describeLoadBalancerAttributes.js +++ b/collectors/aws/elb/describeLoadBalancerAttributes.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var elb = new AWS.ELB(AWSConfig); async.eachLimit(collection.elb.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ @@ -9,11 +10,12 @@ module.exports = function(AWSConfig, collection, callback) { var params = { 'LoadBalancerName':lb.LoadBalancerName }; - elb.describeLoadBalancerAttributes(params, function(err, data) { + + helpers.makeCustomCollectorCall(elb, 'describeLoadBalancerAttributes', params, retries, null, null, null, function(err, data) { if (err) { collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].err = err; } - collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elb/describeLoadBalancerPolicies.js b/collectors/aws/elb/describeLoadBalancerPolicies.js index 851d9f4e2..e0c70dc93 100644 --- a/collectors/aws/elb/describeLoadBalancerPolicies.js +++ b/collectors/aws/elb/describeLoadBalancerPolicies.js @@ -2,8 +2,9 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var elb = new AWS.ELB(AWSConfig); // Gather list of policies from load balancers @@ -41,10 +42,15 @@ module.exports = function(AWSConfig, collection, callback) { async.eachLimit(policies, 15, function(policy, cb){ collection.elb.describeLoadBalancerPolicies[AWSConfig.region][policy.DNSName] = {}; - elb.describeLoadBalancerPolicies({LoadBalancerName: policy.LoadBalancerName, PolicyNames:policy.PolicyNames}, function(err, data){ + let params = { + LoadBalancerName: policy.LoadBalancerName, + PolicyNames: policy.PolicyNames + }; + + helpers.makeCustomCollectorCall(elb, 'describeLoadBalancerPolicies', params, retries, null, null, null, function(err, data) { if (err) { collection.elb.describeLoadBalancerPolicies[AWSConfig.region][policy.DNSName].err = err; - } else { + } else if (data) { collection.elb.describeLoadBalancerPolicies[AWSConfig.region][policy.DNSName].data = data; } diff --git a/collectors/aws/elb/describeTags.js b/collectors/aws/elb/describeTags.js new file mode 100644 index 000000000..d673d44d4 --- /dev/null +++ b/collectors/aws/elb/describeTags.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var elb = new AWS.ELB(AWSConfig); + + async.eachLimit(collection.elb.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ + collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName] = {}; + var params = { + 'LoadBalancerNames': [lb.LoadBalancerName] + }; + + helpers.makeCustomCollectorCall(elb, 'describeTags', params, retries, null, null, null, function(err, data) { + if (err) { + collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName].err = err; + } + + if (data) collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/elbv2/describeListeners.js b/collectors/aws/elbv2/describeListeners.js index 1f0990d67..794a1fd53 100644 --- a/collectors/aws/elbv2/describeListeners.js +++ b/collectors/aws/elbv2/describeListeners.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var elb = new AWS.ELBv2(AWSConfig); async.eachLimit(collection.elbv2.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ @@ -9,11 +10,12 @@ module.exports = function(AWSConfig, collection, callback) { var params = { 'LoadBalancerArn':lb.LoadBalancerArn }; - elb.describeListeners(params, function(err, data) { + + helpers.makeCustomCollectorCall(elb, 'describeListeners', params, retries, null, null, null, function(err, data) { if (err) { collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/elbv2/describeLoadBalancerAttributes.js b/collectors/aws/elbv2/describeLoadBalancerAttributes.js index 67c528c75..0e36f521a 100644 --- a/collectors/aws/elbv2/describeLoadBalancerAttributes.js +++ b/collectors/aws/elbv2/describeLoadBalancerAttributes.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var elb = new AWS.ELBv2(AWSConfig); async.eachLimit(collection.elbv2.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ @@ -9,11 +10,12 @@ module.exports = function(AWSConfig, collection, callback) { var params = { 'LoadBalancerArn':lb.LoadBalancerArn }; - elb.describeLoadBalancerAttributes(params, function(err, data) { + + helpers.makeCustomCollectorCall(elb, 'describeLoadBalancerAttributes', params, retries, null, null, null, function(err, data) { if (err) { collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/elbv2/describeTags.js b/collectors/aws/elbv2/describeTags.js new file mode 100644 index 000000000..cb41af06d --- /dev/null +++ b/collectors/aws/elbv2/describeTags.js @@ -0,0 +1,24 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var elb = new AWS.ELBv2(AWSConfig); + + async.eachLimit(collection.elbv2.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ + collection.elbv2.describeTags[AWSConfig.region][lb.DNSName] = {}; + var params = { + 'ResourceArns': [lb.LoadBalancerArn] + }; + + helpers.makeCustomCollectorCall(elb, 'describeTags', params, retries, null, null, null, function(err, data) { + if (err) { + collection.elbv2.describeTags[AWSConfig.region][lb.DNSName].err = err; + } + if (data) collection.elbv2.describeTags[AWSConfig.region][lb.DNSName].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/elbv2/describeTargetGroups.js b/collectors/aws/elbv2/describeTargetGroups.js index 03100dcaa..410b9a9ff 100644 --- a/collectors/aws/elbv2/describeTargetGroups.js +++ b/collectors/aws/elbv2/describeTargetGroups.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var elb = new AWS.ELBv2(AWSConfig); async.eachLimit(collection.elbv2.describeLoadBalancers[AWSConfig.region].data, 15, function(lb, cb){ @@ -9,11 +10,12 @@ module.exports = function(AWSConfig, collection, callback) { var params = { 'LoadBalancerArn':lb.LoadBalancerArn }; - elb.describeTargetGroups(params, function(err, data) { + + helpers.makeCustomCollectorCall(elb, 'describeTargetGroups', params, retries, null, null, null, function(err, data) { if (err) { collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/emr/describeSecurityConfiguration.js b/collectors/aws/emr/describeSecurityConfiguration.js new file mode 100644 index 000000000..f919eb8da --- /dev/null +++ b/collectors/aws/emr/describeSecurityConfiguration.js @@ -0,0 +1,35 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var emr = new AWS.EMR(AWSConfig); + + async.eachLimit(collection.emr.listClusters[AWSConfig.region].data, 15, function(cluster, cb){ + if (!collection.emr.describeCluster || + !collection.emr.describeCluster[AWSConfig.region] || + !collection.emr.describeCluster[AWSConfig.region][cluster.Id] || + !collection.emr.describeCluster[AWSConfig.region][cluster.Id].data || + !collection.emr.describeCluster[AWSConfig.region][cluster.Id].data.Cluster || + !collection.emr.describeCluster[AWSConfig.region][cluster.Id].data.Cluster.SecurityConfiguration) { + return cb(); + } + + var securityConfigurationName = collection.emr.describeCluster[AWSConfig.region][cluster.Id].data.Cluster.SecurityConfiguration; + + collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName] = {}; + var params = { + 'Name': securityConfigurationName + }; + + helpers.makeCustomCollectorCall(emr, 'describeSecurityConfiguration', params, retries, null, null, null, function(err, data) { + if (err) { + collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName].err = err; + } + if (data) collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/firehose/describeDeliveryStream.js b/collectors/aws/firehose/describeDeliveryStream.js index 89bbc0c55..abebc7e57 100644 --- a/collectors/aws/firehose/describeDeliveryStream.js +++ b/collectors/aws/firehose/describeDeliveryStream.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var firehose = new AWS.Firehose(AWSConfig); async.eachLimit(collection.firehose.listDeliveryStreams[AWSConfig.region].data, 15, function(deliverystream, cb){ @@ -11,11 +12,11 @@ module.exports = function(AWSConfig, collection, callback) { DeliveryStreamName: deliverystream }; - firehose.describeDeliveryStream(params, function(err, data) { + helpers.makeCustomCollectorCall(firehose, 'describeDeliveryStream', params, retries, null, null, null, function(err, data) { if (err) { collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].err = err; } - collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].data = data; + if (data) collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/describePublishingDestination.js b/collectors/aws/guardduty/describePublishingDestination.js new file mode 100644 index 000000000..897608ef1 --- /dev/null +++ b/collectors/aws/guardduty/describePublishingDestination.js @@ -0,0 +1,44 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var guardduty = new AWS.GuardDuty(AWSConfig); + + if (!collection.guardduty || + !collection.guardduty.listDetectors || + !collection.guardduty.listDetectors[AWSConfig.region] || + !collection.guardduty.listDetectors[AWSConfig.region].data) return callback(); + + async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 5, function(detectorId, cb){ + + if (!detectorId || !collection.guardduty || + !collection.guardduty.listPublishingDestinations || + !collection.guardduty.listPublishingDestinations[AWSConfig.region] || + !collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId] || + !collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data || + !collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data.Destinations || + !collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data.Destinations.length) { + return cb(); + } + + async.eachLimit(collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data.Destinations, 3, function(destination, pCb){ + collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId] = {}; + + // Make the describe destinations call + helpers.makeCustomCollectorCall(guardduty, 'describePublishingDestination', {DestinationId: destination.DestinationId, DetectorId: detectorId}, retries, null, null, null, function(err, data) { + if (err) { + collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId].err = err; + } + + if (data) collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId].data = data; + pCb(); + }); + + }, function() { + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/guardduty/getDetector.js b/collectors/aws/guardduty/getDetector.js index c07971bc2..b04b7c3d7 100644 --- a/collectors/aws/guardduty/getDetector.js +++ b/collectors/aws/guardduty/getDetector.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var guardduty = new AWS.GuardDuty(AWSConfig); async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 15, function(detectorId, cb) { collection.guardduty.getDetector[AWSConfig.region][detectorId] = {}; @@ -9,11 +10,14 @@ module.exports = function(AWSConfig, collection, callback) { 'DetectorId': detectorId }; - guardduty.getDetector(params, function(err, data) { + helpers.makeCustomCollectorCall(guardduty, 'getDetector', params, retries, null, null, null, function(err, data) { if (err) { collection.guardduty.getDetector[AWSConfig.region][detectorId].err = err; } - collection.guardduty.getDetector[AWSConfig.region][detectorId].data = data; + if (data) { + data.id = detectorId; + collection.guardduty.getDetector[AWSConfig.region][detectorId].data = data; + } cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/getFindings.js b/collectors/aws/guardduty/getFindings.js new file mode 100644 index 000000000..26132a0fa --- /dev/null +++ b/collectors/aws/guardduty/getFindings.js @@ -0,0 +1,36 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var guardduty = new AWS.GuardDuty(AWSConfig); + async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 15, function(detectorId, dcb) { + if (!collection.guardduty || + !collection.guardduty.listFindings || + !collection.guardduty.listFindings[AWSConfig.region] || + !collection.guardduty.listFindings[AWSConfig.region][detectorId] || + !collection.guardduty.listFindings[AWSConfig.region][detectorId].data || + !collection.guardduty.listFindings[AWSConfig.region][detectorId].data.FindingIds) return dcb(); + + const findingIds = collection.guardduty.listFindings[AWSConfig.region][detectorId].data.FindingIds; + + if (!findingIds || !findingIds.length) return dcb(); + + collection.guardduty.getFindings[AWSConfig.region][detectorId] = {}; + const params = { + DetectorId: detectorId, + FindingIds: findingIds + }; + + helpers.makeCustomCollectorCall(guardduty, 'getFindings', params, retries, null, null, null, function(err, data) { + if (err) { + collection.guardduty.getFindings[AWSConfig.region][detectorId].err = err; + } + if (data) collection.guardduty.getFindings[AWSConfig.region][detectorId].data = data; + dcb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/guardduty/getMasterAccount.js b/collectors/aws/guardduty/getMasterAccount.js index 25ca2f9c3..2e399f38f 100644 --- a/collectors/aws/guardduty/getMasterAccount.js +++ b/collectors/aws/guardduty/getMasterAccount.js @@ -1,18 +1,20 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var guardduty = new AWS.GuardDuty(AWSConfig); async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 15, function(detectorId, cb) { collection.guardduty.getMasterAccount[AWSConfig.region][detectorId] = {}; var params = { 'DetectorId': detectorId }; - guardduty.getMasterAccount(params, function(err, data) { + + helpers.makeCustomCollectorCall(guardduty, 'getMasterAccount', params, retries, null, null, null, function(err, data) { if (err) { collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].err = err; } - collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].data = data; + if (data) collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/listFindings.js b/collectors/aws/guardduty/listFindings.js new file mode 100644 index 000000000..7e2826f89 --- /dev/null +++ b/collectors/aws/guardduty/listFindings.js @@ -0,0 +1,30 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var guardduty = new AWS.GuardDuty(AWSConfig); + async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 15, function(detectorId, cb) { + collection.guardduty.listFindings[AWSConfig.region][detectorId] = {}; + var params = { + 'DetectorId': detectorId, + FindingCriteria: { + Criterion: { + 'service.archived': { + 'Eq': ['false'] + } + } + } + }; + + helpers.makeCustomCollectorCall(guardduty, 'listFindings', params, retries, null, null, null, function(err, data) { + if (err) { + collection.guardduty.listFindings[AWSConfig.region][detectorId].err = err; + } + if (data) collection.guardduty.listFindings[AWSConfig.region][detectorId].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/guardduty/listPublishingDestinations.js b/collectors/aws/guardduty/listPublishingDestinations.js new file mode 100644 index 000000000..e6d7f7a3b --- /dev/null +++ b/collectors/aws/guardduty/listPublishingDestinations.js @@ -0,0 +1,24 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var guardduty = new AWS.GuardDuty(AWSConfig); + async.eachLimit(collection.guardduty.listDetectors[AWSConfig.region].data, 15, function(detectorId, cb) { + collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId] = {}; + var params = { + 'DetectorId': detectorId + }; + + helpers.makeCustomCollectorCall(guardduty, 'listPublishingDestinations', params, retries, null, null, null, function(err, data) { + if (err) { + collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].err = err; + } + if (data) collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data = data; + }); + + cb(); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/iam/generateCredentialReport.js b/collectors/aws/iam/generateCredentialReport.js index 3643eba8e..4ee1fdb2f 100644 --- a/collectors/aws/iam/generateCredentialReport.js +++ b/collectors/aws/iam/generateCredentialReport.js @@ -1,7 +1,7 @@ var AWS = require('aws-sdk'); var async = require('async'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var iam = new AWS.IAM(AWSConfig); var generateCredentialReport = function(genCb) { @@ -34,7 +34,7 @@ module.exports = function(AWSConfig, collection, callback) { try { var csvContent = reportData.Content.toString(); var csvRows = csvContent.split('\n'); - } catch(e) { + } catch (e) { collection.iam.generateCredentialReport[AWSConfig.region].err = 'Error converting credential CSV to string: ' + e; return callback(); } diff --git a/collectors/aws/iam/getGroupPolicy.js b/collectors/aws/iam/getGroupPolicy.js index 5c270bac4..95b251281 100644 --- a/collectors/aws/iam/getGroupPolicy.js +++ b/collectors/aws/iam/getGroupPolicy.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var iam = new AWS.IAM(AWSConfig); if (!collection.iam || @@ -20,27 +21,45 @@ module.exports = function(AWSConfig, collection, callback) { return cb(); } + if (group.GroupName && collection.iam && + collection.iam.listAttachedGroupPolicies && + collection.iam.listAttachedGroupPolicies[AWSConfig.region] && + collection.iam.listAttachedGroupPolicies[AWSConfig.region][group.GroupName] && + collection.iam.listAttachedGroupPolicies[AWSConfig.region][group.GroupName].data && + collection.iam.listAttachedGroupPolicies[AWSConfig.region][group.GroupName].data.AttachedPolicies && + collection.iam.listAttachedGroupPolicies[AWSConfig.region][group.GroupName].data.AttachedPolicies.length) { + group.attachedPolicies = collection.iam.listAttachedGroupPolicies[AWSConfig.region][group.GroupName].data.AttachedPolicies; + } else { + group.attachedPolicies = []; + } + collection.iam.getGroupPolicy[AWSConfig.region][group.GroupName] = {}; + group.inlinePolicies = []; async.eachLimit(collection.iam.listGroupPolicies[AWSConfig.region][group.GroupName].data.PolicyNames, 5, function(policyName, pCb){ collection.iam.getGroupPolicy[AWSConfig.region][group.GroupName][policyName] = {}; - // Make the policy call - iam.getGroupPolicy({ - PolicyName: policyName, - GroupName: group.GroupName - }, function(err, data){ + helpers.makeCustomCollectorCall(iam, 'getGroupPolicy', {PolicyName: policyName, GroupName: group.GroupName}, retries, null, null, null, function(err, data) { if (err) { collection.iam.getGroupPolicy[AWSConfig.region][group.GroupName][policyName].err = err; + return pCb(); + } + + if (data['PolicyDocument']) { + data['PolicyDocument'] = helpers.normalizePolicyDocument(data['PolicyDocument']); } collection.iam.getGroupPolicy[AWSConfig.region][group.GroupName][policyName].data = data; + + delete data['ResponseMetadata']; + group.inlinePolicies.push(data); + pCb(); }); }, function(){ setTimeout(function(){ cb(); - }, 100); + }, 200); }); }, function(){ callback(); diff --git a/collectors/aws/iam/getInstanceProfile.js b/collectors/aws/iam/getInstanceProfile.js new file mode 100644 index 000000000..453e2a78f --- /dev/null +++ b/collectors/aws/iam/getInstanceProfile.js @@ -0,0 +1,50 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var iam = new AWS.IAM(AWSConfig); + + if (!collection.ec2 || + !collection.ec2.describeInstances || + !Object.keys(collection.ec2.describeInstances).length) return callback(); + async.eachOfLimit(collection.ec2.describeInstances, 5, function(regionInstances, region, rcb){ + if (!collection.ec2 || + !regionInstances.data || + !regionInstances.data.length) return rcb(); + + async.eachLimit(regionInstances.data, 5, function(parent, cb){ + if (!parent.Instances || !parent.Instances.length) return cb(); + // Loop through each policy for that role + let instance = parent.Instances[0]; + + if (!instance.IamInstanceProfile || !instance.IamInstanceProfile.Arn) { + return cb(); + } + + let iamInstanceProfileName = instance.IamInstanceProfile.Arn.split('/')[1]; + + if (collection.iam.getInstanceProfile[AWSConfig.region][instance.IamInstanceProfile.Arn]) return cb(); + + collection.iam.getInstanceProfile[AWSConfig.region][instance.IamInstanceProfile.Arn] = {}; + + helpers.makeCustomCollectorCall(iam, 'getInstanceProfile', {InstanceProfileName: iamInstanceProfileName}, retries, null, null, null, function(err, data) { + if (err) { + collection.iam.getInstanceProfile[AWSConfig.region][instance.IamInstanceProfile.Arn].err = err; + } + if (data && data.InstanceProfile) { + collection.iam.getInstanceProfile[AWSConfig.region][instance.IamInstanceProfile.Arn].data = data.InstanceProfile; + } else { + collection.iam.getInstanceProfile[AWSConfig.region][instance.IamInstanceProfile.Arn].data = data; + } + + cb(); + }); + + }, function(){ + rcb(); + }); + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/iam/getPolicyVersion.js b/collectors/aws/iam/getPolicyVersion.js new file mode 100644 index 000000000..70bb12a38 --- /dev/null +++ b/collectors/aws/iam/getPolicyVersion.js @@ -0,0 +1,38 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var iam = new AWS.IAM(AWSConfig); + + if (!collection.iam || + !collection.iam.listPolicies || + !collection.iam.listPolicies[AWSConfig.region] || + !collection.iam.listPolicies[AWSConfig.region].data) return callback(); + + async.eachLimit(collection.iam.listPolicies[AWSConfig.region].data, 10, function(policy, cb){ + if (!policy.Arn || + !collection.iam.getPolicy || + !collection.iam.getPolicy[AWSConfig.region] || + !collection.iam.getPolicy[AWSConfig.region] || + !collection.iam.getPolicy[AWSConfig.region][policy.Arn] || + !collection.iam.getPolicy[AWSConfig.region][policy.Arn].data || + !collection.iam.getPolicy[AWSConfig.region][policy.Arn].data.Policy || + !collection.iam.getPolicy[AWSConfig.region][policy.Arn].data.Policy.DefaultVersionId) { + return cb(); + } + + var versionId = collection.iam.getPolicy[AWSConfig.region][policy.Arn].data.Policy.DefaultVersionId; + collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn] = {}; + + helpers.makeCustomCollectorCall(iam, 'getPolicyVersion', {PolicyArn: policy.Arn, VersionId: versionId}, retries, null, null, null, function(err, data) { + if (err) { + collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn].err = err; + } + if (data) collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/iam/getRole.js b/collectors/aws/iam/getRole.js new file mode 100644 index 000000000..5ec172fe4 --- /dev/null +++ b/collectors/aws/iam/getRole.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var iam = new AWS.IAM(AWSConfig); + + if (!collection.iam || + !collection.iam.listRoles || + !collection.iam.listRoles[AWSConfig.region] || + !collection.iam.listRoles[AWSConfig.region].data) return callback(); + + async.eachLimit(collection.iam.listRoles[AWSConfig.region].data, 10, function(role, cb){ + if (!role.Arn || + !collection.iam.listRoles || + !collection.iam.listRoles[AWSConfig.region] || + !collection.iam.listRoles[AWSConfig.region].data) { + + return cb(); + } + + collection.iam.getRole[AWSConfig.region][role.RoleName] = {}; + + helpers.makeCustomCollectorCall(iam, 'getRole', {RoleName: role.RoleName}, retries, null, null, null, function(err, data) { + if (err) { + collection.iam.getRole[AWSConfig.region][role.RoleName].err = err; + } + if (data) { + delete data['ResponseMetadata']; + + data.Role.AssumeRolePolicyDocument = helpers.normalizePolicyDocument(data.Role.AssumeRolePolicyDocument); + collection.iam.getRole[AWSConfig.region][role.RoleName].data = data; + } + + cb(); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/iam/getRolePolicy.js b/collectors/aws/iam/getRolePolicy.js index 37ae420c2..239d1e45a 100644 --- a/collectors/aws/iam/getRolePolicy.js +++ b/collectors/aws/iam/getRolePolicy.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var iam = new AWS.IAM(AWSConfig); if (!collection.iam || @@ -20,27 +21,58 @@ module.exports = function(AWSConfig, collection, callback) { return cb(); } + if (collection.iam.listAttachedRolePolicies && + collection.iam.listAttachedRolePolicies[AWSConfig.region] && + collection.iam.listAttachedRolePolicies[AWSConfig.region][role.RoleName] && + collection.iam.listAttachedRolePolicies[AWSConfig.region][role.RoleName].data && + collection.iam.listAttachedRolePolicies[AWSConfig.region][role.RoleName].data.AttachedPolicies && + collection.iam.listAttachedRolePolicies[AWSConfig.region][role.RoleName].data.AttachedPolicies.length) { + role.attachedPolicies = collection.iam.listAttachedRolePolicies[AWSConfig.region][role.RoleName].data.AttachedPolicies; + } else { + role.attachedPolicies = []; + } + + if (collection.iam.getRole && + collection.iam.getRole[AWSConfig.region] && + collection.iam.getRole[AWSConfig.region][role.RoleName] && + collection.iam.getRole[AWSConfig.region][role.RoleName].data && + collection.iam.getRole[AWSConfig.region][role.RoleName].data.Role && + Object.keys(collection.iam.getRole[AWSConfig.region][role.RoleName].data.Role).length) { + role.tags = collection.iam.getRole[AWSConfig.region][role.RoleName].data.Role.Tags; + role.lastUsed = collection.iam.getRole[AWSConfig.region][role.RoleName].data.Role.RoleLastUsed; + } else { + role.tags = []; + role.lastUsed = []; + } + collection.iam.getRolePolicy[AWSConfig.region][role.RoleName] = {}; + role.inlinePolicies = []; async.eachLimit(collection.iam.listRolePolicies[AWSConfig.region][role.RoleName].data.PolicyNames, 5, function(policyName, pCb){ collection.iam.getRolePolicy[AWSConfig.region][role.RoleName][policyName] = {}; - // Make the policy call - iam.getRolePolicy({ - PolicyName: policyName, - RoleName: role.RoleName - }, function(err, data){ + helpers.makeCustomCollectorCall(iam, 'getRolePolicy', {PolicyName: policyName,RoleName: role.RoleName}, retries, null, null, null, function(err, data) { if (err) { collection.iam.getRolePolicy[AWSConfig.region][role.RoleName][policyName].err = err; + return pCb(); + } + + if (data['PolicyDocument']) { + data['PolicyDocument'] = helpers.normalizePolicyDocument(data['PolicyDocument']); } collection.iam.getRolePolicy[AWSConfig.region][role.RoleName][policyName].data = data; + + delete data['ResponseMetadata']; + + role.inlinePolicies.push(data); + pCb(); }); }, function(){ setTimeout(function(){ cb(); - }, 100); + }, 200); }); }, function(){ callback(); diff --git a/collectors/aws/iam/getUserPolicy.js b/collectors/aws/iam/getUserPolicy.js index 490a150c3..d40a3a20d 100644 --- a/collectors/aws/iam/getUserPolicy.js +++ b/collectors/aws/iam/getUserPolicy.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var iam = new AWS.IAM(AWSConfig); if (!collection.iam || @@ -20,29 +21,57 @@ module.exports = function(AWSConfig, collection, callback) { return cb(); } + if (collection.iam.listAttachedUserPolicies && + collection.iam.listAttachedUserPolicies[AWSConfig.region] && + collection.iam.listAttachedUserPolicies[AWSConfig.region][user.UserName] && + collection.iam.listAttachedUserPolicies[AWSConfig.region][user.UserName].data && + collection.iam.listAttachedUserPolicies[AWSConfig.region][user.UserName].data.AttachedPolicies && + collection.iam.listAttachedUserPolicies[AWSConfig.region][user.UserName].data.AttachedPolicies.length) { + user.attachedPolicies = collection.iam.listAttachedUserPolicies[AWSConfig.region][user.UserName].data.AttachedPolicies; + } else { + user.attachedPolicies = []; + } + + if (collection.iam.getUser && + collection.iam.getUser[AWSConfig.region] && + collection.iam.getUser[AWSConfig.region][user.UserName] && + collection.iam.getUser[AWSConfig.region][user.UserName].data && + collection.iam.getUser[AWSConfig.region][user.UserName].data.User && + Object.keys(collection.iam.getUser[AWSConfig.region][user.UserName].data.User).length) { + user.tags = collection.iam.getUser[AWSConfig.region][user.UserName].data.User.Tags; + } else { + user.tags = []; + } + collection.iam.getUserPolicy[AWSConfig.region][user.UserName] = {}; + user.inlinePolicies = []; async.each(collection.iam.listUserPolicies[AWSConfig.region][user.UserName].data.PolicyNames, function(policyName, pCb){ collection.iam.getUserPolicy[AWSConfig.region][user.UserName][policyName] = {}; - // Make the policy call - iam.getUserPolicy({ - PolicyName: policyName, - UserName: user.UserName - }, function(err, data){ + helpers.makeCustomCollectorCall(iam, 'getUserPolicy', {PolicyName: policyName,UserName: user.UserName}, retries, null, null, null, function(err, data) { if (err) { collection.iam.getUserPolicy[AWSConfig.region][user.UserName][policyName].err = err; + return pCb(); + } + + if (data['PolicyDocument']) { + data['PolicyDocument'] = helpers.normalizePolicyDocument(data['PolicyDocument']); } collection.iam.getUserPolicy[AWSConfig.region][user.UserName][policyName].data = data; + + delete data['ResponseMetadata']; + user.inlinePolicies.push(data); + pCb(); }); }, function(){ setTimeout(function(){ cb(); - }, 100); + }, 200); }); }, function(){ callback(); }); -}; \ No newline at end of file +}; diff --git a/collectors/aws/iam/listRoles.js b/collectors/aws/iam/listRoles.js new file mode 100644 index 000000000..c16f2a5c4 --- /dev/null +++ b/collectors/aws/iam/listRoles.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var iam = new AWS.IAM(AWSConfig); + collection.iam.listRoles[AWSConfig.region] = {}; + var params = {}; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) { + collection.iam.listRoles[AWSConfig.region].err = err; + } else if (data) { + data.Roles.map(role => + role['AssumeRolePolicyDocument'] = helpers.normalizePolicyDocument(role['AssumeRolePolicyDocument'])); + if (paginating && data.Roles && data.Roles.length && + collection.iam.listRoles[AWSConfig.region].data && + collection.iam.listRoles[AWSConfig.region].data.length) { + collection.iam.listRoles[AWSConfig.region].data = collection.iam.listRoles[AWSConfig.region].data.concat(data.Roles); + } else { + collection.iam.listRoles[AWSConfig.region].data = data.Roles; + } + if (data.Marker && data.Marker.length) { + paginating = true; + return execute(data.Marker); + } + } + + + callback(); + }; + + function execute(marker) { + var localParams = JSON.parse(JSON.stringify(params || {})); + if (marker) localParams['Marker'] = marker; + helpers.makeCustomCollectorCall(iam, 'listRoles', localParams, retries, null, null, null, paginateCb); + } + + execute(); +}; diff --git a/collectors/aws/kinesis/describeStream.js b/collectors/aws/kinesis/describeStream.js index 1f0398533..31bcb0b1a 100644 --- a/collectors/aws/kinesis/describeStream.js +++ b/collectors/aws/kinesis/describeStream.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var kinesis = new AWS.Kinesis(AWSConfig); async.eachLimit(collection.kinesis.listStreams[AWSConfig.region].data, 15, function(stream, cb){ @@ -11,11 +12,11 @@ module.exports = function(AWSConfig, collection, callback) { StreamName: stream }; - kinesis.describeStream(params, function(err, data) { + helpers.makeCustomCollectorCall(kinesis, 'describeStream', params, retries, null, null, null, function(err, data) { if (err) { collection.kinesis.describeStream[AWSConfig.region][stream].err = err; } - collection.kinesis.describeStream[AWSConfig.region][stream].data = data; + if (data) collection.kinesis.describeStream[AWSConfig.region][stream].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/kms/getKeyPolicy.js b/collectors/aws/kms/getKeyPolicy.js index d3136be94..4e562042f 100644 --- a/collectors/aws/kms/getKeyPolicy.js +++ b/collectors/aws/kms/getKeyPolicy.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var kms = new AWS.KMS(AWSConfig); async.eachLimit(collection.kms.listKeys[AWSConfig.region].data, 15, function(key, cb){ @@ -15,7 +16,7 @@ module.exports = function(AWSConfig, collection, callback) { PolicyName: 'default' }; - kms.getKeyPolicy(params, function(err, data) { + helpers.makeCustomCollectorCall(kms, 'getKeyPolicy', params, retries, null, null, null, function(err, data) { if (err) { collection.kms.getKeyPolicy[AWSConfig.region][key.KeyId].err = err; } @@ -23,7 +24,7 @@ module.exports = function(AWSConfig, collection, callback) { var policyData; try { policyData = JSON.parse(data.Policy); - } catch(e) { + } catch (e) { policyData = null; } diff --git a/collectors/aws/kms/listGrants.js b/collectors/aws/kms/listGrants.js new file mode 100644 index 000000000..4272e478b --- /dev/null +++ b/collectors/aws/kms/listGrants.js @@ -0,0 +1,50 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var kms = new AWS.KMS(AWSConfig); + async.eachLimit(collection.kms.listKeys[AWSConfig.region].data, 15, function(key, cb) { + collection.kms.listGrants[AWSConfig.region][key.KeyId] = {}; + var params = { + KeyId: key.KeyId + }; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) collection.kms.listGrants[AWSConfig.region][key.KeyId].err = err; + + if (!data) return cb(); + + if (paginating && data.Grants && data.Grants.length && + collection.kms.listGrants[AWSConfig.region][key.KeyId].data.Grants && + collection.kms.listGrants[AWSConfig.region][key.KeyId].data.Grants.length) { + collection.kms.listGrants[AWSConfig.region][key.KeyId].data.Grants = collection.kms.listGrants[AWSConfig.region][key.KeyId].data.Grants.concat(data.Grants); + } else { + collection.kms.listGrants[AWSConfig.region][key.KeyId].data = data; + } + + if (data.NextMarker && data.NextMarker.length) { + paginating = true; + return execute(data.NextMarker); + } + + cb(); + }; + + function execute(marker) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (marker) localParams['Marker'] = marker; + if (marker) { + helpers.makeCustomCollectorCall(kms, 'listGrants', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(kms, 'listGrants', params, retries, null, null, null, paginateCb); + } + } + + execute(); + }, function(){ + callback(); + }); +}; + diff --git a/collectors/aws/lexmodelsv2/describeBotAlias.js b/collectors/aws/lexmodelsv2/describeBotAlias.js new file mode 100644 index 000000000..fb6c951af --- /dev/null +++ b/collectors/aws/lexmodelsv2/describeBotAlias.js @@ -0,0 +1,42 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var lexmodelsv2 = new AWS.LexModelsV2(AWSConfig); + + if (!collection.lexmodelsv2 || + !collection.lexmodelsv2.listBots || + !collection.lexmodelsv2.listBots[AWSConfig.region] || + !collection.lexmodelsv2.listBots[AWSConfig.region].data) return callback(); + async.eachLimit(collection.lexmodelsv2.listBots[AWSConfig.region].data, 5, function(bot, cb){ + + if (!bot.botId || !collection.lexmodelsv2 || + !collection.lexmodelsv2.listBotAliases || + !collection.lexmodelsv2.listBotAliases[AWSConfig.region] || + !collection.lexmodelsv2.listBotAliases[AWSConfig.region][bot.botId] || + !collection.lexmodelsv2.listBotAliases[AWSConfig.region][bot.botId].data || + !collection.lexmodelsv2.listBotAliases[AWSConfig.region][bot.botId].data.botAliasSummaries || + !collection.lexmodelsv2.listBotAliases[AWSConfig.region][bot.botId].data.botAliasSummaries.length) { + return cb(); + } + + async.eachLimit(collection.lexmodelsv2.listBotAliases[AWSConfig.region][bot.botId].data.botAliasSummaries, 3, function(alias, pCb){ + collection.lexmodelsv2.describeBotAlias[AWSConfig.region][alias.botAliasId] = {}; + + helpers.makeCustomCollectorCall(lexmodelsv2, 'describeBotAlias', {botAliasId: alias.botAliasId,botId: bot.botId}, retries, null, null, null, function(err, data) { + if (err) { + collection.lexmodelsv2.describeBotAlias[AWSConfig.region][alias.botAliasId].err = err; + } + data.id = `${bot.botId}/${alias.botAliasId}`; + collection.lexmodelsv2.describeBotAlias[AWSConfig.region][alias.botAliasId].data = data; + pCb(); + }); + + }, function() { + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/lookoutvision/describeModel.js b/collectors/aws/lookoutvision/describeModel.js new file mode 100644 index 000000000..a3216bf2c --- /dev/null +++ b/collectors/aws/lookoutvision/describeModel.js @@ -0,0 +1,43 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var lookoutvision = new AWS.LookoutVision(AWSConfig); + + if (!collection.lookoutvision || + !collection.lookoutvision.listProjects || + !collection.lookoutvision.listProjects[AWSConfig.region] || + !collection.lookoutvision.listProjects[AWSConfig.region].data) return callback(); + + async.eachLimit(collection.lookoutvision.listProjects[AWSConfig.region].data, 5, function(project, cb){ + + if (!project.ProjectName || !collection.lookoutvision || + !collection.lookoutvision.listModels || + !collection.lookoutvision.listModels[AWSConfig.region] || + !collection.lookoutvision.listModels[AWSConfig.region][project.ProjectName] || + !collection.lookoutvision.listModels[AWSConfig.region][project.ProjectName].data || + !collection.lookoutvision.listModels[AWSConfig.region][project.ProjectName].data.Models || + !collection.lookoutvision.listModels[AWSConfig.region][project.ProjectName].data.Models.length) { + return cb(); + } + + async.eachLimit(collection.lookoutvision.listModels[AWSConfig.region][project.ProjectName].data.Models, 3, function(model, pCb){ + collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn] = {}; + + // Make the describe Models call + helpers.makeCustomCollectorCall(lookoutvision, 'describeModel', {ModelVersion: model.ModelVersion,ProjectName: project.ProjectName}, retries, null, null, null, function(err, data) { + if (err) { + collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn].err = err; + } + + if (data) collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn].data = data; + pCb(); + }); + }, function() { + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/managedblockchain/getMember.js b/collectors/aws/managedblockchain/getMember.js new file mode 100644 index 000000000..17b99ffe6 --- /dev/null +++ b/collectors/aws/managedblockchain/getMember.js @@ -0,0 +1,42 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var managedblockchain = new AWS.ManagedBlockchain(AWSConfig); + + if (!collection.managedblockchain || + !collection.managedblockchain.listNetworks || + !collection.managedblockchain.listNetworks[AWSConfig.region] || + !collection.managedblockchain.listNetworks[AWSConfig.region].data) return callback(); + + async.eachLimit(collection.managedblockchain.listNetworks[AWSConfig.region].data, 3, function(network, cb){ + if (!network.Id || !collection.managedblockchain || + !collection.managedblockchain.listMembers || + !collection.managedblockchain.listMembers[AWSConfig.region] || + !collection.managedblockchain.listMembers[AWSConfig.region][network.Id] || + !collection.managedblockchain.listMembers[AWSConfig.region][network.Id].data || + !collection.managedblockchain.listMembers[AWSConfig.region][network.Id].data.Members) { + return cb(); + } + + async.eachLimit(collection.managedblockchain.listMembers[AWSConfig.region][network.Id].data.Members, 5, function(member, mcb){ + collection.managedblockchain.getMember[AWSConfig.region][member.Id] = {}; + + helpers.makeCustomCollectorCall(managedblockchain, 'getMember', {MemberId: member.Id,NetworkId: network.Id}, retries, null, null, null, function(err, data) { + if (err) { + collection.managedblockchain.getMember[AWSConfig.region][member.Id].err = err; + } + + if (data) collection.managedblockchain.getMember[AWSConfig.region][member.Id].data = data; + mcb(); + }); + }, function(){ + setTimeout(function(){ + cb(); + }, 100); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/mwaa/getEnvironment.js b/collectors/aws/mwaa/getEnvironment.js new file mode 100644 index 000000000..9d992a124 --- /dev/null +++ b/collectors/aws/mwaa/getEnvironment.js @@ -0,0 +1,25 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var mwaa = new AWS.MWAA(AWSConfig); + + async.eachLimit(collection.mwaa.listEnvironments[AWSConfig.region].data, 15, function(env, cb){ + collection.mwaa.getEnvironment[AWSConfig.region][env] = {}; + + var params = { + Name: env + }; + + helpers.makeCustomCollectorCall(mwaa, 'getEnvironment', params, retries, null, null, null, function(err, data) { + if (err) { + collection.mwaa.getEnvironment[AWSConfig.region][env].err = err; + } + if (data) collection.mwaa.getEnvironment[AWSConfig.region][env].data = data; + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js b/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js new file mode 100644 index 000000000..8944a652c --- /dev/null +++ b/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js @@ -0,0 +1,26 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.OpenSearchServerless(AWSConfig); + + async.eachLimit(collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region].data, 10, function(policy, cb){ + collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name] = {}; + var params = { + name: policy.name, + type: 'encryption' + }; + + helpers.makeCustomCollectorCall(ecs, 'getSecurityPolicy', params, retries, null, null, null, function(err, data) { + if (err) { + collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name].err = err; + } + if (data) collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name].data = data; + + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js b/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js new file mode 100644 index 000000000..cc87011fc --- /dev/null +++ b/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js @@ -0,0 +1,26 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ecs = new AWS.OpenSearchServerless(AWSConfig); + async.eachLimit(collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region].data, 10, function(policy, cb){ + collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name] = {}; + var params = { + name: policy.name, + type: 'network' + }; + + helpers.makeCustomCollectorCall(ecs, 'getSecurityPolicy', params, retries, null, null, null, function(err, data) { + if (err) { + collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name].err = err; + } + + if (data) collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name].data = data; + + cb(); + }); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js b/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js new file mode 100644 index 000000000..590dbc71d --- /dev/null +++ b/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js @@ -0,0 +1,18 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var opensearch = new AWS.OpenSearchServerless(AWSConfig); + collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region] = {}; + let params = { + type: 'encryption' + }; + helpers.makeCustomCollectorCall(opensearch, 'listSecurityPolicies', params, retries, null, null, null, function(err, data) { + if (err) { + collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region].err = err; + } else if (data && data.securityPolicySummaries){ + collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region].data = data.securityPolicySummaries; + } + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js b/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js new file mode 100644 index 000000000..f1f55ca91 --- /dev/null +++ b/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js @@ -0,0 +1,18 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var opensearch = new AWS.OpenSearchServerless(AWSConfig); + collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region] = {}; + let params = { + type: 'network' + }; + helpers.makeCustomCollectorCall(opensearch, 'listSecurityPolicies', params, retries, null, null, null, function(err, data) { + if (err) { + collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region].err = err; + } else if (data && data.securityPolicySummaries) { + collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region].data = data.securityPolicySummaries; + } + callback(); + }); +}; diff --git a/collectors/aws/rds/describeDBParameters.js b/collectors/aws/rds/describeDBParameters.js new file mode 100644 index 000000000..5cf608fa1 --- /dev/null +++ b/collectors/aws/rds/describeDBParameters.js @@ -0,0 +1,49 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var rds = new AWS.RDS(AWSConfig); + async.eachLimit(collection.rds.describeDBParameterGroups[AWSConfig.region].data, 15, function(group, cb) { + collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName] = {}; + var params = { + DBParameterGroupName: group.DBParameterGroupName + }; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].err = err; + + if (!data) return cb(); + + if (paginating && data.Parameters && data.Parameters.length && + collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].data.Parameters && + collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].data.Parameters.length) { + collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].data.Parameters = collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].data.Parameters.concat(data.Parameters); + } else { + collection.rds.describeDBParameters[AWSConfig.region][group.DBParameterGroupName].data = data; + } + + if (data.Marker && data.Marker.length) { + paginating = true; + return execute(data.Marker); + } + + cb(); + }; + + function execute(marker) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (marker) localParams['Marker'] = marker; + if (marker) { + helpers.makeCustomCollectorCall(rds, 'describeDBParameters', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(rds, 'describeDBParameters', params, retries, null, null, null, paginateCb); + } + } + + execute(); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/s3/getBucketAcl.js b/collectors/aws/s3/getBucketAcl.js index 5bb2bf445..e0dc545e4 100644 --- a/collectors/aws/s3/getBucketAcl.js +++ b/collectors/aws/s3/getBucketAcl.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketAcl', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketAcl', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketEncryption.js b/collectors/aws/s3/getBucketEncryption.js index 142ec125c..07eb28a2a 100644 --- a/collectors/aws/s3/getBucketEncryption.js +++ b/collectors/aws/s3/getBucketEncryption.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketEncryption', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketEncryption', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketLifecycleConfiguration.js b/collectors/aws/s3/getBucketLifecycleConfiguration.js new file mode 100644 index 000000000..2ccd97274 --- /dev/null +++ b/collectors/aws/s3/getBucketLifecycleConfiguration.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketLifecycleConfiguration', false, AWSConfig, collection, retries, callback); +}; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketLocation.js b/collectors/aws/s3/getBucketLocation.js index 5490ac9f6..1f765eebf 100644 --- a/collectors/aws/s3/getBucketLocation.js +++ b/collectors/aws/s3/getBucketLocation.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketLocation', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketLocation', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketLogging.js b/collectors/aws/s3/getBucketLogging.js index bcb30d179..f9eb082c3 100644 --- a/collectors/aws/s3/getBucketLogging.js +++ b/collectors/aws/s3/getBucketLogging.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketLogging', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketLogging', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketPolicy.js b/collectors/aws/s3/getBucketPolicy.js index 38f9761f6..0d02b83d4 100644 --- a/collectors/aws/s3/getBucketPolicy.js +++ b/collectors/aws/s3/getBucketPolicy.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketPolicy', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketPolicy', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketPolicyStatus.js b/collectors/aws/s3/getBucketPolicyStatus.js new file mode 100644 index 000000000..7f91143fa --- /dev/null +++ b/collectors/aws/s3/getBucketPolicyStatus.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketPolicyStatus', false, AWSConfig, collection, retries, callback); +}; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketTagging.js b/collectors/aws/s3/getBucketTagging.js index e13bd68be..b6197f9f1 100644 --- a/collectors/aws/s3/getBucketTagging.js +++ b/collectors/aws/s3/getBucketTagging.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketTagging', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketTagging', false, AWSConfig, collection, retries, callback); }; diff --git a/collectors/aws/s3/getBucketVersioning.js b/collectors/aws/s3/getBucketVersioning.js index 543d5fb27..8a114cfe5 100644 --- a/collectors/aws/s3/getBucketVersioning.js +++ b/collectors/aws/s3/getBucketVersioning.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketVersioning', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketVersioning', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getBucketWebsite.js b/collectors/aws/s3/getBucketWebsite.js index 985826ab3..0357b60df 100644 --- a/collectors/aws/s3/getBucketWebsite.js +++ b/collectors/aws/s3/getBucketWebsite.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getBucketWebsite', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getBucketWebsite', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/getObjectLockConfiguration.js b/collectors/aws/s3/getObjectLockConfiguration.js new file mode 100644 index 000000000..fb280059d --- /dev/null +++ b/collectors/aws/s3/getObjectLockConfiguration.js @@ -0,0 +1,5 @@ +var index = require(__dirname + '/index.js'); + +module.exports = function(AWSConfig, collection, retries, callback) { + index('getObjectLockConfiguration', false, AWSConfig, collection, retries, callback); +}; \ No newline at end of file diff --git a/collectors/aws/s3/getPublicAccessBlock.js b/collectors/aws/s3/getPublicAccessBlock.js index 6a0aaed98..dddf5be6a 100644 --- a/collectors/aws/s3/getPublicAccessBlock.js +++ b/collectors/aws/s3/getPublicAccessBlock.js @@ -1,5 +1,5 @@ var index = require(__dirname + '/index.js'); -module.exports = function(AWSConfig, collection, callback) { - index('getPublicAccessBlock', false, AWSConfig, collection, callback); +module.exports = function(AWSConfig, collection, retries, callback) { + index('getPublicAccessBlock', false, AWSConfig, collection, retries, callback); }; \ No newline at end of file diff --git a/collectors/aws/s3/index.js b/collectors/aws/s3/index.js index 6fe95937d..bbcac26df 100644 --- a/collectors/aws/s3/index.js +++ b/collectors/aws/s3/index.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(callKey, forceCloudTrail, AWSConfig, collection, callback) { +module.exports = function(callKey, forceCloudTrail, AWSConfig, collection, retries, callback) { var s3 = new AWS.S3(AWSConfig); var knownBuckets = []; @@ -38,12 +39,12 @@ module.exports = function(callKey, forceCloudTrail, AWSConfig, collection, callb async.eachLimit(knownBuckets, 10, function(bucket, bcb){ collection['s3'][callKey][AWSConfig.region][bucket] = {}; - s3[callKey]({Bucket:bucket}, function(bErr, bData){ + helpers.makeCustomCollectorCall(s3, callKey, {Bucket:bucket}, retries, null, null, null, function(bErr, bData) { if (bErr) { collection['s3'][callKey][AWSConfig.region][bucket].err = bErr; if (bErr.statusCode && bErr.statusCode == 301) { - s3.getBucketLocation({Bucket: bucket}, function(locErr, locData){ + helpers.makeCustomCollectorCall(s3, 'getBucketLocation', {Bucket:bucket}, retries, null, null, null, function(locErr, locData) { if (locErr || !locData || !locData.LocationConstraint) return bcb(); // Special case where location constraint is EU - rewrite as eu-west-1 if (locData.LocationConstraint == 'EU') locData.LocationConstraint = 'eu-west-1'; diff --git a/collectors/aws/s3control/getPublicAccessBlock.js b/collectors/aws/s3control/getPublicAccessBlock.js new file mode 100644 index 000000000..0ea179f5f --- /dev/null +++ b/collectors/aws/s3control/getPublicAccessBlock.js @@ -0,0 +1,21 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var s3control = new AWS.S3Control(AWSConfig); + + var accountId = collection.sts.getCallerIdentity[AWSConfig.region].data; + collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId] = {}; + + var params = { + AccountId: accountId + }; + + helpers.makeCustomCollectorCall(s3control, 'getPublicAccessBlock', params, retries, null, null, null, function(err, data) { + if (err) { + collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId].err = err; + } + if (data) collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId].data = data; + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/securityhub/getFindings.js b/collectors/aws/securityhub/getFindings.js new file mode 100644 index 000000000..52e94a459 --- /dev/null +++ b/collectors/aws/securityhub/getFindings.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var securityhub = new AWS.SecurityHub(AWSConfig); + collection.securityhub.getFindings[AWSConfig.region] = {}; + + const params = { + MaxResults: 100, + Filters: { + RecordState: [ + { + Comparison: 'EQUALS', + Value: 'ACTIVE' + } + ], + WorkflowStatus: [ + { + Comparison: 'EQUALS', + Value: 'NEW' + } + ] + } + }; + + var paginateCb = function(err, data) { + if (err) { + collection.securityhub.getFindings[AWSConfig.region].err = err; + } else if (data && data.Findings && data.Findings.length) { + collection.securityhub.getFindings[AWSConfig.region].data = [data.Findings]; // only returning the first finding + } + + callback(); + }; + + function execute() { + helpers.makeCustomCollectorCall(securityhub, 'getFindings', params, retries, null, null, null, paginateCb); + } + execute(); +}; diff --git a/collectors/aws/ses/getIdentityDkimAttributes.js b/collectors/aws/ses/getIdentityDkimAttributes.js index 5c29d477b..3e5106497 100644 --- a/collectors/aws/ses/getIdentityDkimAttributes.js +++ b/collectors/aws/ses/getIdentityDkimAttributes.js @@ -1,15 +1,49 @@ var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var ses = new AWS.SES(AWSConfig); + collection.ses.getIdentityDkimAttributes[AWSConfig.region] = {}; - ses.getIdentityDkimAttributes({Identities: collection.ses.listIdentities[AWSConfig.region].data}, function(err, data){ - if (err) { - collection.ses.getIdentityDkimAttributes[AWSConfig.region].err = err; + var identities = collection.ses.listIdentities[AWSConfig.region].data; + var identityChunks = chunkArray(identities, 100); + var allDkimAttributes = []; + var processIdentityChunk = function(chunkIndex) { + if (chunkIndex >= identityChunks.length) { + collection.ses.getIdentityDkimAttributes[AWSConfig.region].data = { + DkimAttributes: allDkimAttributes + }; + callback(); + return; } - collection.ses.getIdentityDkimAttributes[AWSConfig.region].data = data; + var chunk = identityChunks[chunkIndex]; + var params = { + Identities: chunk, + }; - callback(); - }); -}; \ No newline at end of file + setTimeout(function() { + helpers.makeCustomCollectorCall(ses, 'getIdentityDkimAttributes', params, retries, null, null, null, function(err, data) { + if (err) { + collection.ses.getIdentityDkimAttributes[AWSConfig.region].err = err; + } else if (data && data.DkimAttributes) { + var processedIdentities = Object.keys(data.DkimAttributes).map((key) => ({ + identityName: key, + ...data.DkimAttributes[key], + })); + allDkimAttributes = allDkimAttributes.concat(processedIdentities); + } + processIdentityChunk(chunkIndex + 1); + }); + }, 1000); + }; + + processIdentityChunk(0); +}; +function chunkArray(arr, chunkSize) { + var result = []; + for (var i = 0; i < arr.length; i += chunkSize) { + result.push(arr.slice(i, i + chunkSize)); + } + return result; +} diff --git a/collectors/aws/sqs/getQueueAttributes.js b/collectors/aws/sqs/getQueueAttributes.js index b49e3ad98..b85e355c2 100644 --- a/collectors/aws/sqs/getQueueAttributes.js +++ b/collectors/aws/sqs/getQueueAttributes.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var sqs = new AWS.SQS(AWSConfig); async.eachLimit(collection.sqs.listQueues[AWSConfig.region].data, 15, function(queue, cb){ @@ -14,11 +15,11 @@ module.exports = function(AWSConfig, collection, callback) { ] }; - sqs.getQueueAttributes(params, function(err, data) { + helpers.makeCustomCollectorCall(sqs, 'getQueueAttributes', params, retries, null, null, null, function(err, data) { if (err) { collection.sqs.getQueueAttributes[AWSConfig.region][queue].err = err; } - collection.sqs.getQueueAttributes[AWSConfig.region][queue].data = data; + if (data) collection.sqs.getQueueAttributes[AWSConfig.region][queue].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/ssm/describeParameters.js b/collectors/aws/ssm/describeParameters.js new file mode 100644 index 000000000..4c7eb3932 --- /dev/null +++ b/collectors/aws/ssm/describeParameters.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var ssm = new AWS.SSM(AWSConfig); + collection.ssm.describeParameters[AWSConfig.region] = {}; + var params = {}; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) { + collection.ssm.describeParameters[AWSConfig.region].err = err; + } else if (data) { + data.Parameters.map(param => + param['Name'] = param.Name.charAt(0) === '/' ? param.Name.slice(1) : param.Name); + if (paginating && data.Parameters && data.Parameters.length && + collection.ssm.describeParameters[AWSConfig.region].data && + collection.ssm.describeParameters[AWSConfig.region].data.length) { + collection.ssm.describeParameters[AWSConfig.region].data = collection.ssm.describeParameters[AWSConfig.region].data.concat(data.Parameters); + } else { + collection.ssm.describeParameters[AWSConfig.region].data = data.Parameters; + } + if (data.NextToken && data.NextToken.length) { + paginating = true; + return execute(data.NextToken); + } + } + + + callback(); + }; + + function execute(marker) { + var localParams = JSON.parse(JSON.stringify(params || {})); + if (marker) localParams['NextToken'] = marker; + helpers.makeCustomCollectorCall(ssm, 'describeParameters', localParams, retries, null, null, null, paginateCb); + } + + execute(); +}; diff --git a/collectors/aws/support/describeTrustedAdvisorCheckResult.js b/collectors/aws/support/describeTrustedAdvisorCheckResult.js index a148da121..76ed78cd1 100644 --- a/collectors/aws/support/describeTrustedAdvisorCheckResult.js +++ b/collectors/aws/support/describeTrustedAdvisorCheckResult.js @@ -1,7 +1,8 @@ var AWS = require('aws-sdk'); var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); -module.exports = function(AWSConfig, collection, callback) { +module.exports = function(AWSConfig, collection, retries, callback) { var support = new AWS.Support(AWSConfig); async.eachLimit(collection.support.describeTrustedAdvisorChecks[AWSConfig.region].data, 15, function(check, cb) { @@ -11,11 +12,11 @@ module.exports = function(AWSConfig, collection, callback) { checkId: check, }; - support.describeTrustedAdvisorCheckResult(params, function(err, data) { + helpers.makeCustomCollectorCall(support, 'describeTrustedAdvisorCheckResult', params, retries, null, null, null, function(err, data) { if (err) { collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].err = err; } - collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].data = data; + if (data) collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].data = data; cb(); }); }, function() { diff --git a/collectors/aws/wafregional/listResourcesForWebACL.js b/collectors/aws/wafregional/listResourcesForWebACL.js new file mode 100644 index 000000000..c1728c507 --- /dev/null +++ b/collectors/aws/wafregional/listResourcesForWebACL.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var wafregional = new AWS.WAFRegional(AWSConfig); + async.eachLimit(collection.wafregional.listWebACLs[AWSConfig.region].data, 15, function(dep, depCb){ + async.eachLimit(['APPLICATION_LOAD_BALANCER', 'API_GATEWAY'], 1, function(thisCheck, tcCb){ + if (!collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']]) collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']] = {}; + + var filter = {}; + filter['WebACLId'] = dep['WebACLId']; + filter['ResourceType'] = thisCheck; + helpers.makeCustomCollectorCall(wafregional, 'listResourcesForWebACL', filter, retries, null, null, null, function(err, data) { + if (err) { + collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].err = err; + return tcCb(); + } else { + if (!collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data) { + collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data = data; + } else if (collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data && + collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data.ResourceWebACLIds && + collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data.ResourceWebACLIds.length && + data.ResourceWebACLIds && data.ResourceWebACLIds.length) { + collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data.ResourceWebACLIds = collection['wafregional']['listResourcesForWebACL'][AWSConfig.region][dep['WebACLId']].data.ResourceWebACLIds.concat(data.ResourceWebACLIds); + } + + return tcCb(); + } + }); + + }, function() { + setTimeout(function() { + depCb(); + }, 600); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/wafv2/getWebACL.js b/collectors/aws/wafv2/getWebACL.js new file mode 100644 index 000000000..e2ed82a7c --- /dev/null +++ b/collectors/aws/wafv2/getWebACL.js @@ -0,0 +1,29 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var wafv2 = new AWS.WAFV2(AWSConfig); + + async.eachLimit(collection.wafv2.listWebACLs[AWSConfig.region].data, 15, function(acl, cb){ + var params = { + 'Name': acl.Name, + 'Id': acl.Id, + 'Scope': 'REGIONAL' + }; + + helpers.makeCustomCollectorCall(wafv2, 'getWebACL', params, retries, null, null, null, function(err, data) { + collection.wafv2.getWebACL[AWSConfig.region][acl.ARN] = {}; + + if (err) { + collection.wafv2.getWebACL[AWSConfig.region][acl.ARN].err = err; + } else if (data) { + collection.wafv2.getWebACL[AWSConfig.region][acl.ARN].data = data; + } + cb(); + }); + + }, function(){ + callback(); + }); +}; diff --git a/collectors/aws/wafv2/getWebACLForCognitoUserPool.js b/collectors/aws/wafv2/getWebACLForCognitoUserPool.js new file mode 100644 index 000000000..756032c4d --- /dev/null +++ b/collectors/aws/wafv2/getWebACLForCognitoUserPool.js @@ -0,0 +1,34 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + + var wafv2 = new AWS.WAFV2(AWSConfig); + var region = 'us-east-1'; + var partition = 'aws'; + if (wafv2.endpoint.hostname.includes('gov')){ + region = 'us-gov-west-1'; + partition = 'aws-us-gov'; + } + + if (!collection.sts.getCallerIdentity || !collection.sts.getCallerIdentity[region].data) return callback(); + + async.eachLimit(collection.cognitoidentityserviceprovider.listUserPools[AWSConfig.region].data, 15, function(up, cb){ + collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id] = {}; + var params = { + 'ResourceArn':`arn:${partition}:cognito-idp:${AWSConfig.region}:${collection.sts.getCallerIdentity[region].data}:userpool/${up.Id}` + }; + + helpers.makeCustomCollectorCall(wafv2, 'getWebACLForResource', params, retries, null, null, null, function(err, data) { + if (err) { + collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id].err = err; + } + if (data) collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id].data = data; + cb(); + }); + + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/wafv2/listResourcesForWebACL.js b/collectors/aws/wafv2/listResourcesForWebACL.js new file mode 100644 index 000000000..711a45dbe --- /dev/null +++ b/collectors/aws/wafv2/listResourcesForWebACL.js @@ -0,0 +1,39 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var wafv2 = new AWS.WAFV2(AWSConfig); + async.eachLimit(collection.wafv2.listWebACLs[AWSConfig.region].data, 15, function(dep, depCb){ + async.eachLimit(['APPLICATION_LOAD_BALANCER', 'API_GATEWAY'], 1, function(thisCheck, tcCb){ + if (!collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']]) collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']] = {}; + + var filter = {}; + filter['WebACLArn'] = dep['ARN']; + filter['ResourceType'] = thisCheck; + helpers.makeCustomCollectorCall(wafv2, 'listResourcesForWebACL', filter, retries, null, null, null, function(err, data) { + if (err) { + collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].err = err; + return tcCb(); + } else { + if (!collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data) { + collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data = data; + } else if (collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data && + collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data.ResourceArns && + collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data.ResourceArns.length && + data.ResourceArns && data.ResourceArns.length) { + collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data.ResourceArns = collection['wafv2']['listResourcesForWebACL'][AWSConfig.region][dep['ARN']].data.ResourceArns.concat(data.ResourceArns); + } + + return tcCb(); + } + }); + }, function() { + setTimeout(function() { + depCb(); + }, 600); + }); + }, function(){ + callback(); + }); +}; diff --git a/collectors/azure/blobService/getProperties.js b/collectors/azure/blobService/getProperties.js new file mode 100644 index 000000000..32f968699 --- /dev/null +++ b/collectors/azure/blobService/getProperties.js @@ -0,0 +1,42 @@ +var async = require('async'); +var azureStorage = require('@azure/storage-blob'); + +module.exports = function(collection, reliesOn, callback) { + if (!reliesOn['storageAccounts.listKeys']) return callback(); + + if (!collection['blobService']['getProperties']) collection['blobService']['getProperties'] = {}; + + // Loop through regions and properties in reliesOn + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + collection['blobService']['getProperties'][region] = {}; + + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { + collection['blobService']['getProperties'][region][resourceId] = {}; + + if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { + + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.BlobServiceClient.fromConnectionString(connectionString); + const properties = await storageService.getProperties(); + if (properties) { + collection['blobService']['getProperties'][region][resourceId].data = properties; + } else { + collection['blobService']['getProperties'][region][resourceId].data = {}; + } + + } catch (exception) { + collection['blobService']['getProperties'][region][resourceId].err = exception.message; + } + sCb(); + } else { + sCb(); + } + }, function() { + cb(); + }); + }, function() { + callback(); + }); +}; diff --git a/collectors/azure/blobService/listContainersSegmented.js b/collectors/azure/blobService/listContainersSegmented.js index 910b009eb..ab6583178 100644 --- a/collectors/azure/blobService/listContainersSegmented.js +++ b/collectors/azure/blobService/listContainersSegmented.js @@ -1,58 +1,59 @@ +const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob'); var async = require('async'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['blobService']['listContainersSegmented']) collection['blobService']['listContainersSegmented'] = {}; if (!collection['blobService']['getContainerAcl']) collection['blobService']['getContainerAcl'] = {}; - // Loop through regions and properties in reliesOn - async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10, function(regionObj, region, cb) { collection['blobService']['listContainersSegmented'][region] = {}; collection['blobService']['getContainerAcl'][region] = {}; - async.eachOf(regionObj, function(subObj, resourceId, sCb) { + async.eachOfLimit(regionObj, 10, function(subObj, resourceId, sCb) { collection['blobService']['listContainersSegmented'][region][resourceId] = {}; - if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { - // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['BlobService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listContainersSegmented(null, function(serviceErr, serviceResults) { - if (serviceErr || !serviceResults) { - collection['blobService']['listContainersSegmented'][region][resourceId].err = (serviceErr || 'No data returned'); - sCb(); - } else { - collection['blobService']['listContainersSegmented'][region][resourceId].data = serviceResults.entries; - - // Add ACLs - async.each(serviceResults.entries, function(entryObj, entryCb) { - var entryId = `${resourceId}/blobService/${entryObj.name}`; - collection['blobService']['getContainerAcl'][region][entryId] = {}; - - storageService.getContainerAcl(entryObj.name, function(getErr, getData) { - if (getErr || !getData) { - collection['blobService']['getContainerAcl'][region][entryId].err = (getErr || 'No data returned'); - } else { - collection['blobService']['getContainerAcl'][region][entryId].data = getData; - } - entryCb(); - }); - }, function() { - sCb(); - }); + const key = subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value? subObj.data.keys[0].value : null; + if (!key) return sCb(); + + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const credential = new StorageSharedKeyCredential(storageAccountName, key); + const blobServiceClient = new BlobServiceClient( + `https://${storageAccountName}.blob.core.windows.net`, + credential + ); + + const containers = []; + + (async() => { + try { + for await (const container of blobServiceClient.listContainers()) { + containers.push(container); } - }); - } else { - sCb(); - } - }, function() { - cb(); - }); - }, function() { - callback(); - }); -}; \ No newline at end of file + + collection['blobService']['listContainersSegmented'][region][resourceId].data = containers; + + // Get ACLs for each container + async.eachLimit(containers, 10, async(entryObj, entryCb) => { + const entryId = `${resourceId}/blobService/${entryObj.name}`; + collection['blobService']['getContainerAcl'][region][entryId] = {}; + + try { + const containerClient = blobServiceClient.getContainerClient(entryObj.name); + const aclResponse = await containerClient.getAccessPolicy(); + collection['blobService']['getContainerAcl'][region][entryId].data = aclResponse; + } catch (getErr) { + collection['blobService']['getContainerAcl'][region][entryId].err = getErr.message || getErr; + } + + entryCb(); + }, sCb); + } catch (serviceErr) { + collection['blobService']['listContainersSegmented'][region][resourceId].err = serviceErr.message || serviceErr; + sCb(); + } + })(); + }, cb); + }, callback); +}; diff --git a/collectors/azure/collector.js b/collectors/azure/collector.js index 404dc7d59..a0031bde0 100644 --- a/collectors/azure/collector.js +++ b/collectors/azure/collector.js @@ -1,6 +1,6 @@ /********************* Collector - The collector will query Azure APIs for the information required - to run the CloudSploit scans. This data will be returned in the callback + to run the CloudExploit scans. This data will be returned in the callback as a JSON object. Arguments: @@ -10,359 +10,25 @@ - api_calls: (Optional) If provided, will only query these APIs. - Example: { - "skip_locations": ["eastus", "westus"], - "api_calls": ["storageAccounts:list", "resourceGroups:list"] + "skip_locations": ["eastus", "westus"], + "api_calls": ["storageAccounts:list", "resourceGroups:list"] } - callback: Function to call when the collection is complete *********************/ var async = require('async'); var collectors = require(__dirname + '/index.js'); +var collectData = require(__dirname + '/../../helpers/shared.js'); +var apiCalls = require(__dirname + '/../../helpers/azure/api.js'); // Standard calls that contain top-level operations -var calls = { - resourceGroups: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/resourcegroups?api-version=2019-10-01' - } - }, - activityLogAlerts: { - listBySubscriptionId: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/activityLogAlerts?api-version=2017-04-01' - } - }, - storageAccounts: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Storage/storageAccounts?api-version=2019-06-01' - } - }, - virtualNetworks: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/virtualNetworks?api-version=2020-03-01' - } - }, - virtualMachines: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachines?api-version=2019-12-01' - } - }, - disks: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/disks?api-version=2019-07-01' - } - }, - networkSecurityGroups: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkSecurityGroups?api-version=2020-03-01' - } - }, - vaults: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.KeyVault/vaults?api-version=2019-09-01' - } - }, - resources: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/resources?api-version=2019-10-01' - } - }, - managedClusters: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ContainerService/managedClusters?api-version=2020-03-01' - } - }, - networkWatchers: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkWatchers?api-version=2020-03-01' - } - }, - policyAssignments: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyAssignments?api-version=2019-09-01', - } - }, - policyDefinitions: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions?api-version=2019-09-01' - } - }, - webApps: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Web/sites?api-version=2019-08-01' - } - }, - logProfiles: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/logprofiles?api-version=2016-03-01' - } - }, - profiles: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Cdn/profiles?api-version=2019-04-15' - } - }, - autoProvisioningSettings: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/autoProvisioningSettings?api-version=2017-08-01-preview' - } - }, - securityContacts: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/securityContacts?api-version=2017-08-01-preview', - } - }, - subscriptions: { - listLocations: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/locations?api-version=2020-01-01' - } - }, - roleDefinitions: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions?api-version=2015-07-01' - } - }, - managementLocks: { - listAtSubscriptionLevel: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/locks?api-version=2016-09-01' - } - }, - loadBalancers: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/loadBalancers?api-version=2020-03-01' - } - }, - users: { - list: { - url: 'https://graph.windows.net/myorganization/users?api-version=1.6', - graph: true - } - }, - registries: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ContainerRegistry/registries?api-version=2019-05-01' - } - }, - pricings: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/pricings?api-version=2018-06-01' - } - }, - availabilitySets: { - listBySubscription: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/availabilitySets?api-version=2019-12-01' - } - }, - virtualMachineScaleSets: { - listAll: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachineScaleSets?api-version=2019-12-01' - } - }, - autoscaleSettings: { - listBySubscription: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/autoscalesettings?api-version=2015-04-01' - } - }, - diagnosticSettingsOperations: { - list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' - } - }, - servers: { - listSql: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Sql/servers?api-version=2019-06-01-preview' - }, - listMysql: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/servers?api-version=2017-12-01' - }, - listPostgres: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforPostgreSQL/servers?api-version=2017-12-01' - } - } -}; +var calls = apiCalls.calls; -var postcalls = { - serverBlobAuditingPolicies: { - get: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/auditingSettings?api-version=2017-03-01-preview' - } - }, - serverSecurityAlertPolicies: { - listByServer: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/securityAlertPolicies?api-version=2017-03-01-preview' - } - }, - configurations: { - listByServer: { - reliesOnPath: 'servers.listPostgres', - properties: ['id'], - url: 'https://management.azure.com/{id}/configurations?api-version=2017-12-01' - } - }, - virtualMachineExtensions: { - list: { - reliesOnPath: 'virtualMachines.listAll', - properties: ['id'], - url: 'https://management.azure.com/{id}/extensions?api-version=2019-12-01' - } - }, - blobContainers: { - list: { - reliesOnPath: 'storageAccounts.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/blobServices/default/containers?api-version=2019-06-01' - } - }, - fileShares: { - list: { - reliesOnPath: 'storageAccounts.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/fileServices/default/shares?api-version=2019-06-01' - } - }, - storageAccounts: { - listKeys: { - reliesOnPath: 'storageAccounts.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/listKeys?api-version=2019-06-01', - post: true - } - }, - encryptionProtectors: { - listByServer: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/encryptionProtector?api-version=2015-05-01-preview' - }, - }, - webApps: { - getAuthSettings: { - reliesOnPath: 'webApps.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/config/authsettings/list?api-version=2019-08-01', - post: true - }, - listConfigurations: { - reliesOnPath: 'webApps.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/config?api-version=2019-08-01' - } - }, - endpoints: { - listByProfile: { - reliesOnPath: 'profiles.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/endpoints?api-version=2019-04-15' - }, - }, - vaults: { - getKeys: { - reliesOnPath: 'vaults.list', - properties: ['vaultUri'], - url: '{vaultUri}keys?api-version=7.0', - vault: true - }, - getSecrets: { - reliesOnPath: 'vaults.list', - properties: ['vaultUri'], - url: '{vaultUri}secrets?api-version=7.0', - vault: true - } - }, - databases: { - listByServer: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/databases?api-version=2017-10-01-preview' - }, - }, - serverAzureADAdministrators: { - listByServer: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/administrators?api-version=2014-04-01' - } - }, - usages: { - list: { - reliesOnPath: 'subscriptions.listLocations', - properties: ['name'], - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/locations/{name}/usages?api-version=2020-03-01' - } - }, - firewallRules: { - listByServer: { - reliesOnPath: 'servers.listSql', - properties: ['id'], - url: 'https://management.azure.com/{id}/firewallRules?api-version=2014-04-01' - } - }, - managedClusters: { - getUpgradeProfile: { - reliesOnPath: 'managedClusters.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/upgradeProfiles/default?api-version=2020-03-01' - } - } -}; +var postcalls = apiCalls.postcalls; -var tertiarycalls = { - databaseBlobAuditingPolicies: { - get: { - reliesOnPath: 'databases.listByServer', - properties: ['id'], - url: 'https://management.azure.com/{id}/auditingSettings?api-version=2017-03-01-preview' - } - }, - diagnosticSettings: { - listByEndpoint: { - reliesOnPath: 'endpoints.listByProfile', - properties: ['id'], - url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' - }, - listByKeyVault: { - reliesOnPath: 'vaults.list', - properties: ['id'], - url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' - }, - listByLoadBalancer: { - reliesOnPath: 'loadBalancers.listAll', - properties: ['id'], - url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' - }, - listByNetworkSecurityGroup: { - reliesOnPath: 'networkSecurityGroups.listAll', - properties: ['id'], - url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' - } - } -}; +var tertiarycalls = apiCalls.tertiarycalls; -var specialcalls = { - tableService: { - listTablesSegmented: { - reliesOnPath: ['storageAccounts.listKeys'], - } - }, - fileService: { - listSharesSegmented: { - reliesOnPath: ['storageAccounts.listKeys'], - } - }, - blobService: { - listContainersSegmented: { - reliesOnPath: ['storageAccounts.listKeys'], - } - }, - queueService: { - listQueuesSegmented: { - reliesOnPath: ['storageAccounts.listKeys'], - } - } -}; +var specialcalls = apiCalls.specialcalls; function parseCollection(path, obj) { if (typeof path == 'string') path = path.split('.'); @@ -378,27 +44,207 @@ function parseCollection(path, obj) { } } -var collect = function(AzureConfig, settings, callback) { +let collect = function(AzureConfig, settings, callback, fargateFlag) { // Used to gather info only if (settings.gather) { return callback(null, calls, postcalls, tertiarycalls, specialcalls); } - + var helpers = require(__dirname + '/../../helpers/azure/auth.js'); - + + let services = []; + let skip_locations= settings.skip_regions || []; + // Login using the Azure config helpers.login(AzureConfig, function(loginErr, loginData) { if (loginErr) return callback(loginErr); var collection = {}; - var processCall = function(obj, cb) { - var localUrl = obj.url.replace(/\{subscriptionId\}/g, AzureConfig.SubscriptionID); - helpers.call({ - url: localUrl, - post: obj.post, - token: obj.graph ? loginData.graphToken : (obj.vault ? loginData.vaultToken : loginData.token) - }, cb); + let makeCall = function(localUrl, obj, cb, localData) { + const makeApiCall = async(retryAttempt = 0) => { + try { + const response = await new Promise((resolve, reject) => { + helpers.call({ + url: localUrl, + post: obj.post, + token: obj.graph ? loginData.graphToken : (obj.vault ? loginData.vaultToken : loginData.token), + govcloud: AzureConfig.Govcloud + }, (err, data, apiResponse) => { + if (err) { + reject({ error: err, response: apiResponse }); + } else { + resolve({ data, response: apiResponse }); + } + }); + }); + + handleResponse(response.data); + } catch (error) { + if (error.error && error.error.includes('ECONNRESET') && retryAttempt < 3) { + // Refresh token using callback patterns + helpers.login(AzureConfig, function(refreshErr, refreshedLoginData) { + if (refreshErr) { + return cb(`Failed to refresh token: ${refreshErr}`, null, error.response); + } + + // Update loginData with refreshed tokens + loginData = refreshedLoginData; + // Retry with new token + makeApiCall(retryAttempt + 1) + .then(() => cb(null, null, null)) + .catch(err => cb(err, null, null)); + }); + } else { + return cb(error.error, null, error.response); + } + } + }; + + // Start the async process + makeApiCall().catch(error => { + console.log(`Unexpected error in makeApiCall: ${error}`); + cb(error, null, null); + }); + + function handleResponse(data) { + // If a new nextLink is provided, this will be updated. There shouldn't + // be a need to hold on to the previous value + if (data && obj.hasListResponse && data.length) data.value = data; + + if (data && obj.getCompleteResponse) { + data = {value: [data]}; + } + + obj.nextUrl = null; + if (data && data.value && Array.isArray(data.value) && data.value.length && localData && localData.value) { + localData.value = localData.value.concat(data.value); + } else if (localData && localData.value && localData.value.length && (!data || !((obj.paginate && data[obj.paginate]) || data['nextLink']))) { + return cb(null, localData); + } + + const resData = localData || data; + if (data && ((obj.paginate && data[obj.paginate]) || data['nextLink']) && (!obj.limit || (obj.limit && resData && resData.value && resData.value.length < obj.limit))) { + obj.nextUrl = data['nextLink'] || data[obj.paginate]; + processCall(obj, cb, localData || data); + } else { + return cb(null, localData || data || []); + } + } + }; + + let processCall = function(obj, cb, localData) { + let callbackCalled = false; + const wrappedCallback = (err, data, response) => { + if (callbackCalled) return; + callbackCalled = true; + cb(err, data, response); + }; + + if (fargateFlag) { + const maxApiRetryAttempts = 15; + let initialResponse = null; + async.retry({ + times: maxApiRetryAttempts, + interval: function() { + let retryAfter = initialResponse && initialResponse.headers['retry-after'] ? parseInt(initialResponse.headers['retry-after']) * 1000 : 5000; + let localUrl = obj.nextUrl || obj.url.replace(/\{subscriptionId\}/g, AzureConfig.SubscriptionID); + console.log(`Retrying in ${retryAfter / 1000} seconds for api call : ${localUrl}`); + return retryAfter; + }, + errorFilter: function(err) { + const errorMessage = typeof err === 'string' ? err : err.message || err.toString(); + + // Azure throttling patterns + const throttlingPatterns = [ + 'TooManyRequests', + 'RateLimitExceeded', + 'Throttling', + 'Throttled', + 'RequestThrottled', + 'RequestLimitExceeded', + 'ServerBusy', + 'ServiceBusy', + 'toomanyrequests', + 'ratelimitexceeded', + 'throttling', + 'throttled', + 'requestthrottled', + 'requestlimitexceeded', + 'serverbusy', + 'servicebusy', + 'too many requests', + 'rate limit', + 'retry after', + 'the request is being throttled', + 'request rate is large', + 'rate exceeded' + ]; + + const errorMatch = throttlingPatterns.some(pattern => errorMessage.includes(pattern)); + return errorMatch; + } + }, function(retryCallback) { + let localUrl = obj.nextUrl || obj.url.replace(/\{subscriptionId\}/g, AzureConfig.SubscriptionID); + var rateLimit = obj.rateLimit && obj.rateLimit == 3000? 6000: obj.rateLimit; + if (rateLimit) { + setTimeout(function() { + console.log(`Fargate collector rate limited: url: ${localUrl}`); + makeCall(localUrl, obj, function(err, data, response) { + initialResponse = response; + retryCallback(err, data, response); + }, localData); + }, rateLimit); + } else { + makeCall(localUrl, obj, function(err, data, response) { + initialResponse = response; + retryCallback(err, data, response); + }, localData); + } + }, wrappedCallback); + } else { + let localUrl = obj.nextUrl || obj.url.replace(/\{subscriptionId\}/g, AzureConfig.SubscriptionID); + if (obj.rateLimit) { + setTimeout(function() { + console.log(`url: ${localUrl}`); + makeCall(localUrl, obj, wrappedCallback, localData); + }, obj.rateLimit); + } else { + makeCall(localUrl, obj, wrappedCallback, localData); + } + } + }; + + let integrationCall = function(collection, settings, service, calls, postcalls, cback) { + let collect = JSON.parse(JSON.stringify(collection)); + collect = Object.keys(collect).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = collect[key]; + return accumulator; + }, {}); + + if (settings.previousCollection) { + settings.previousCollection = Object.keys(settings.previousCollection).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = settings.previousCollection[key]; + return accumulator; + }, {}); + } + + if (collect[service.toLowerCase()] && + Object.keys(collect[service.toLowerCase()]) && + Object.keys(collect[service.toLowerCase()]).length && + collectData.callsCollected(service, collect, calls, postcalls) + ) { + try { + collectData.processIntegration(service, settings, collect, calls, postcalls, false,function() { + cback(); + }); + } catch (e) { + console.log(`Error in storing ${service} service data: ${JSON.stringify(e)}`); + cback(); + } + } else { + cback(); + } }; async.series([ @@ -406,11 +252,12 @@ var collect = function(AzureConfig, settings, callback) { function(cb) { function processTopCall(collectionObj, service, subCallObj, subCallCb) { processCall(subCallObj, function(processCallErr, processCallData) { - helpers.addLocations(subCallObj, service, collectionObj, processCallErr, processCallData); + if (AzureConfig.Govcloud) helpers.addGovLocations(subCallObj, service, collectionObj, processCallErr, processCallData , skip_locations); + else helpers.addLocations(subCallObj, service, collectionObj, processCallErr, processCallData , skip_locations); subCallCb(); }); } - + async.eachOfLimit(calls, 10, function(callObj, service, callCb) { if (!collection[service]) collection[service] = {}; // Loop through sub-calls @@ -419,14 +266,37 @@ var collect = function(AzureConfig, settings, callback) { if (settings && settings.api_calls && settings.api_calls.indexOf([service, one].join(':')) === -1) return subCallCb(); - + if (!collection[service][one]) collection[service][one] = {}; processTopCall(collection[service][one], service, subCallObj, subCallCb); }, function() { - callCb(); + if (settings.identifier && calls[service].sendIntegration && calls[service].sendIntegration.enabled) { + if (!calls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, calls, [], function() { + callCb(); + }); + } else { + services.push(service); + callCb(); + } + } else { + callCb(); + } }); }, function() { - cb(); + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, calls, [], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } }); }, @@ -437,20 +307,25 @@ var collect = function(AzureConfig, settings, callback) { var regionsToLoop = parseCollection(subCallObj.reliesOnPath, collection); if (regionsToLoop && Object.keys(regionsToLoop).length) { // Loop through regions - async.eachOf(regionsToLoop, function(regionObj, region, regionCb) { + async.eachOfLimit(regionsToLoop, 5, function(regionObj, region, regionCb) { if (regionObj && regionObj.data && regionObj.data.length) { if (!collectionObj[region]) collectionObj[region] = {}; - async.each(regionObj.data, function(regionData, regionDataCb) { + async.eachLimit(regionObj.data, 10, function(regionData, regionDataCb) { var localReq = { url: subCallObj.url, post: subCallObj.post, token: subCallObj.token, graph: subCallObj.graph, - vault: subCallObj.vault + vault: subCallObj.vault, + rateLimit: subCallObj.rateLimit, + limit: subCallObj.limit }; // Check and replace properties if (subCallObj.properties && subCallObj.properties.length) { subCallObj.properties.forEach(function(propToReplace) { + if (propToReplace.includes('.')) { + regionData[propToReplace] = parseCollection(propToReplace, regionData); + } if (regionData[propToReplace]) { var re = new RegExp(`{${propToReplace}}`, 'g'); localReq.url = subCallObj.url.replace(re, regionData[propToReplace]); @@ -487,7 +362,7 @@ var collect = function(AzureConfig, settings, callback) { subCallCb(); } } - + async.eachOfLimit(postcalls, 10, function(callObj, service, callCb) { if (!collection[service]) collection[service] = {}; // Loop through sub-calls @@ -495,14 +370,37 @@ var collect = function(AzureConfig, settings, callback) { if (settings && settings.api_calls && settings.api_calls.indexOf([service, one].join(':')) === -1) return subCallCb(); - + if (!collection[service][one]) collection[service][one] = {}; processTopCall(collection[service][one], service, subCallObj, subCallCb); }, function() { - callCb(); + if (settings.identifier && postcalls[service].sendIntegration && postcalls[service].sendIntegration.enabled) { + if (!postcalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [postcalls], function() { + callCb(); + }); + } else { + services.push(service); + callCb(); + } + } else { + callCb(); + } }); }, function() { - cb(); + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [postcalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } }); }, @@ -516,14 +414,16 @@ var collect = function(AzureConfig, settings, callback) { async.eachOf(regionsToLoop, function(regionObj, region, regionCb) { if (!collectionObj[region]) collectionObj[region] = {}; // Loop through the resources - async.eachOf(regionObj, function(resourceObj, resourceId, resourceCb){ + async.eachOfLimit(regionObj, 5, function(resourceObj, resourceId, resourceCb){ function processResource(resourceData, resourceDataCb) { var localReq = { url: subCallObj.url, post: subCallObj.post, token: subCallObj.token, graph: subCallObj.graph, - vault: subCallObj.vault + vault: subCallObj.vault, + rateLimit: subCallObj.rateLimit, + limit: subCallObj.limit }; // Check and replace properties if (subCallObj.properties && subCallObj.properties.length) { @@ -552,16 +452,16 @@ var collect = function(AzureConfig, settings, callback) { resourceDataCb(); }); } - + if (Array.isArray(resourceObj)) { - async.each(resourceObj, function(resourceData, resourceDataCb) { + async.eachLimit(resourceObj, 10, function(resourceData, resourceDataCb) { processResource(resourceData, resourceDataCb); }, function(){ resourceCb(); }); } else { if (resourceObj && resourceObj.data && resourceObj.data.length) { - async.each(resourceObj.data, function(resourceData, resourceDataCb) { + async.eachLimit(resourceObj.data, 10, function(resourceData, resourceDataCb) { processResource(resourceData, resourceDataCb); }, function() { resourceCb(); @@ -588,7 +488,7 @@ var collect = function(AzureConfig, settings, callback) { if (settings && settings.api_calls && settings.api_calls.indexOf([service, one].join(':')) === -1) return subCallCb(); - + if (!collection[service][one]) collection[service][one] = {}; if (subCallObj.url) { processTopCall(collection[service][one], service, subCallObj, subCallCb); @@ -598,7 +498,7 @@ var collect = function(AzureConfig, settings, callback) { if (settings && settings.api_calls && settings.api_calls.indexOf([service, one, two].join(':')) === -1) return subCallCb(); - + if (!collection[service][one][two]) collection[service][one][two] = {}; processTopCall(collection[service][one][two], service, innerCallObj, innerCb); }, function() { @@ -606,10 +506,33 @@ var collect = function(AzureConfig, settings, callback) { }); } }, function() { - callCb(); + if (settings.identifier && tertiarycalls[service].sendIntegration && tertiarycalls[service].sendIntegration.enabled) { + if (!tertiarycalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [tertiarycalls], function() { + callCb(); + }); + } else { + services.push(service); + callCb(); + } + } else { + callCb(); + } }); }, function() { - cb(); + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [tertiarycalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } }); }, @@ -631,13 +554,42 @@ var collect = function(AzureConfig, settings, callback) { }); } collectors[service][one](collection, reliesOn, function(){ - subCallCb(); + if (subCallObj.rateLimit) { + setTimeout(function() { + subCallCb(); + }, subCallObj.rateLimit); + } else { + subCallCb(); + } }); }, function(){ - callCb(); + if (settings.identifier && specialcalls[service].sendIntegration && specialcalls[service].sendIntegration.enabled) { + if (!specialcalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [specialcalls], function() { + callCb(); + }); + } else { + services.push(service); + callCb(); + } + } else { + callCb(); + } }); }, function() { - cb(); + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [specialcalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } }); }, @@ -650,4 +602,4 @@ var collect = function(AzureConfig, settings, callback) { }); }; -module.exports = collect; +module.exports = collect; \ No newline at end of file diff --git a/collectors/azure/fileService/listSharesSegmented.js b/collectors/azure/fileService/listSharesSegmented.js index ff3f7c4eb..58a5a81a0 100644 --- a/collectors/azure/fileService/listSharesSegmented.js +++ b/collectors/azure/fileService/listSharesSegmented.js @@ -1,51 +1,53 @@ var async = require('async'); +var azureStorage = require('@azure/storage-file-share'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['fileService']['listSharesSegmented']) collection['fileService']['listSharesSegmented'] = {}; if (!collection['fileService']['getShareAcl']) collection['fileService']['getShareAcl'] = {}; // Loop through regions and properties in reliesOn - async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10,function(regionObj, region, cb) { collection['fileService']['listSharesSegmented'][region] = {}; collection['fileService']['getShareAcl'][region] = {}; - async.eachOf(regionObj, function(subObj, resourceId, sCb) { + async.eachOfLimit(regionObj, 10, async function(subObj, resourceId, sCb) { collection['fileService']['listSharesSegmented'][region][resourceId] = {}; if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['FileService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listSharesSegmented(null, function(serviceErr, serviceResults) { - if (serviceErr || !serviceResults) { - collection['fileService']['listSharesSegmented'][region][resourceId].err = (serviceErr || 'No data returned'); - sCb(); - } else { - collection['fileService']['listSharesSegmented'][region][resourceId].data = serviceResults.entries; - - // Add ACLs - async.each(serviceResults.entries, function(entryObj, entryCb) { - var entryId = `${resourceId}/fileService/${entryObj.name}`; - collection['fileService']['getShareAcl'][region][entryId] = {}; - - storageService.getShareAcl(entryObj.name, function(getErr, getData) { - if (getErr || !getData) { - collection['fileService']['getShareAcl'][region][entryId].err = (getErr || 'No data returned'); - } else { - collection['fileService']['getShareAcl'][region][entryId].data = getData; - } - entryCb(); + const shareItemList = []; + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.ShareServiceClient.fromConnectionString(connectionString); + const iterator = storageService.listShares(); + let item = await iterator.next(); + + while (!item.done) { + let fileShare = item.value.name; + var entryId = `${resourceId}/fileService/${fileShare}`; + shareItemList.push({ name: fileShare, id: entryId}); + collection['fileService']['getShareAcl'][region][entryId] = {}; + const shareClient = storageService.getShareClient(fileShare); + shareClient.getAccessPolicy() + .then(result => { + collection['fileService']['getShareAcl'][region][entryId].data = result; + }) + .catch(err => { + collection['fileService']['getShareAcl'][region][entryId].err = err; }); - }, function() { - sCb(); - }); + item = await iterator.next(); } - }); + } catch (exception) { + collection['fileService']['listSharesSegmented'][region][resourceId].err = exception.message; + } + if (shareItemList.length) { + collection['fileService']['listSharesSegmented'][region][resourceId].data = shareItemList; + } else { + collection['fileService']['listSharesSegmented'][region][resourceId].data = []; + } } else { sCb(); } @@ -55,4 +57,4 @@ module.exports = function(collection, reliesOn, callback) { }, function() { callback(); }); -}; \ No newline at end of file +}; diff --git a/collectors/azure/queueService/getProperties.js b/collectors/azure/queueService/getProperties.js new file mode 100644 index 000000000..050599948 --- /dev/null +++ b/collectors/azure/queueService/getProperties.js @@ -0,0 +1,42 @@ +var async = require('async'); +var azureStorage = require('@azure/storage-queue'); + +module.exports = function(collection, reliesOn, callback) { + if (!reliesOn['storageAccounts.listKeys']) return callback(); + + if (!collection['queueService']['getProperties']) collection['queueService']['getProperties'] = {}; + + // Loop through regions and properties in reliesOn + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + collection['queueService']['getProperties'][region] = {}; + + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { + collection['queueService']['getProperties'][region][resourceId] = {}; + + if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { + + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.QueueServiceClient.fromConnectionString(connectionString); + const properties = await storageService.getProperties(); + if (properties) { + collection['queueService']['getProperties'][region][resourceId].data = properties; + } else { + collection['queueService']['getProperties'][region][resourceId].data = {}; + } + + } catch (exception) { + collection['queueService']['getProperties'][region][resourceId].err = exception.message; + } + sCb(); + } else { + sCb(); + } + }, function() { + cb(); + }); + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/azure/queueService/listQueuesSegmented.js b/collectors/azure/queueService/listQueuesSegmented.js index 8b3937285..e8bf68055 100644 --- a/collectors/azure/queueService/listQueuesSegmented.js +++ b/collectors/azure/queueService/listQueuesSegmented.js @@ -1,51 +1,53 @@ var async = require('async'); +var azureStorage = require('@azure/storage-queue'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['queueService']['listQueuesSegmented']) collection['queueService']['listQueuesSegmented'] = {}; if (!collection['queueService']['getQueueAcl']) collection['queueService']['getQueueAcl'] = {}; // Loop through regions and properties in reliesOn - async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { collection['queueService']['listQueuesSegmented'][region] = {}; collection['queueService']['getQueueAcl'][region] = {}; - async.eachOf(regionObj, function(subObj, resourceId, sCb) { + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { collection['queueService']['listQueuesSegmented'][region][resourceId] = {}; if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['QueueService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listQueuesSegmented(null, function(serviceErr, serviceResults) { - if (serviceErr || !serviceResults) { - collection['queueService']['listQueuesSegmented'][region][resourceId].err = (serviceErr || 'No data returned'); - sCb(); - } else { - collection['queueService']['listQueuesSegmented'][region][resourceId].data = serviceResults.entries; - - // Add ACLs - async.each(serviceResults.entries, function(entryObj, entryCb) { - var entryId = `${resourceId}/queueService/${entryObj.name}`; - collection['queueService']['getQueueAcl'][region][entryId] = {}; - - storageService.getQueueAcl(entryObj.name, function(getErr, getData) { - if (getErr || !getData) { - collection['queueService']['getQueueAcl'][region][entryId].err = (getErr || 'No data returned'); - } else { - collection['queueService']['getQueueAcl'][region][entryId].data = getData; - } - entryCb(); + const queueItemList = []; + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.QueueServiceClient.fromConnectionString(connectionString); + const iterator = storageService.listQueues(); + let item = await iterator.next(); + while (!item.done) { + let queueName = item.value.name; + queueItemList.push({ name: queueName}); + let entryId = `${resourceId}/queueService/${queueName}`; + collection['queueService']['getQueueAcl'][region][entryId] = {}; + const queueClient = storageService.getQueueClient(queueName); + queueClient.getAccessPolicy() + .then(result => { + collection['queueService']['getQueueAcl'][region][entryId].data = result; + }) + .catch(err => { + collection['queueService']['getQueueAcl'][region][entryId].err = err; }); - }, function() { - sCb(); - }); + item = await iterator.next(); } - }); + } catch (exception) { + collection['queueService']['listQueuesSegmented'][region][resourceId].err = exception.message; + } + if (queueItemList.length) { + collection['queueService']['listQueuesSegmented'][region][resourceId].data = queueItemList; + } else { + collection['queueService']['listQueuesSegmented'][region][resourceId].data = []; + } + } else { sCb(); } @@ -55,4 +57,4 @@ module.exports = function(collection, reliesOn, callback) { }, function() { callback(); }); -}; \ No newline at end of file +}; diff --git a/collectors/azure/queueService/listQueuesSegmentedNew.js b/collectors/azure/queueService/listQueuesSegmentedNew.js new file mode 100644 index 000000000..937d3807d --- /dev/null +++ b/collectors/azure/queueService/listQueuesSegmentedNew.js @@ -0,0 +1,60 @@ +var async = require('async'); +var azureStorage = require('@azure/storage-queue'); + +module.exports = function(collection, reliesOn, callback) { + if (!reliesOn['storageAccounts.listKeys']) return callback(); + + if (!collection['queueService']['listQueuesSegmented']) collection['queueService']['listQueuesSegmented'] = {}; + if (!collection['queueService']['getQueueAcl']) collection['queueService']['getQueueAcl'] = {}; + + // Loop through regions and properties in reliesOn + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + collection['queueService']['listQueuesSegmented'][region] = {}; + collection['queueService']['getQueueAcl'][region] = {}; + + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { + collection['queueService']['listQueuesSegmented'][region][resourceId] = {}; + + if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { + // Extract storage account name from resourceId + const queueItemList = []; + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.QueueServiceClient.fromConnectionString(connectionString); + const iterator = storageService.listQueues(); + let item = await iterator.next(); + while (!item.done) { + let queueName = item.value.name; + queueItemList.push({ name: queueName}); + let entryId = `${resourceId}/queueService/${queueName}`; + collection['queueService']['getQueueAcl'][region][entryId] = {}; + const queueClient = storageService.getQueueClient(queueName); + queueClient.getAccessPolicy() + .then(result => { + collection['queueService']['getQueueAcl'][region][entryId].data = result; + }) + .catch(err => { + collection['queueService']['getQueueAcl'][region][entryId].err = err; + }); + item = await iterator.next(); + } + } catch (exception) { + collection['queueService']['listQueuesSegmented'][region][resourceId].err = exception.message; + } + if (queueItemList.length) { + collection['queueService']['listQueuesSegmented'][region][resourceId].data = queueItemList; + } else { + collection['queueService']['listQueuesSegmented'][region][resourceId].data = []; + } + + } else { + sCb(); + } + }, function() { + cb(); + }); + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/azure/tableService/getProperties.js b/collectors/azure/tableService/getProperties.js new file mode 100644 index 000000000..0fdcbc521 --- /dev/null +++ b/collectors/azure/tableService/getProperties.js @@ -0,0 +1,41 @@ +var async = require('async'); +var azureStorage = require('@azure/data-tables'); + +module.exports = function(collection, reliesOn, callback) { + if (!reliesOn['storageAccounts.listKeys']) return callback(); + + if (!collection['tableService']['getProperties']) collection['tableService']['getProperties'] = {}; + // Loop through regions and properties in reliesOn + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + collection['tableService']['getProperties'][region] = {}; + + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { + collection['tableService']['getProperties'][region][resourceId] = {}; + + if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { + + try { + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + const storageService = azureStorage.TableServiceClient.fromConnectionString(connectionString); + const properties = await storageService.getProperties(); + if (properties) { + collection['tableService']['getProperties'][region][resourceId].data = properties; + } else { + collection['tableService']['getProperties'][region][resourceId].data = {}; + } + + } catch (exception) { + collection['tableService']['getProperties'][region][resourceId].err = exception.message; + } + sCb(); + } else { + sCb(); + } + }, function() { + cb(); + }); + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/azure/tableService/listTablesSegmented.js b/collectors/azure/tableService/listTablesSegmented.js index dc86d1736..787986b01 100644 --- a/collectors/azure/tableService/listTablesSegmented.js +++ b/collectors/azure/tableService/listTablesSegmented.js @@ -1,58 +1,57 @@ +const { TableServiceClient, AzureNamedKeyCredential } = require('@azure/data-tables'); var async = require('async'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['tableService']['listTablesSegmented']) collection['tableService']['listTablesSegmented'] = {}; if (!collection['tableService']['getTableAcl']) collection['tableService']['getTableAcl'] = {}; - // Loop through regions and properties in reliesOn - async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10, function(regionObj, region, cb) { collection['tableService']['listTablesSegmented'][region] = {}; collection['tableService']['getTableAcl'][region] = {}; - async.eachOf(regionObj, function(subObj, resourceId, sCb) { + async.eachOfLimit(regionObj, 10, function(subObj, resourceId, sCb) { collection['tableService']['listTablesSegmented'][region][resourceId] = {}; - if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { - // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['TableService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listTablesSegmented(null, function(tableErr, tableResults) { - if (tableErr || !tableResults) { - collection['tableService']['listTablesSegmented'][region][resourceId].err = (tableErr || 'No data returned'); - sCb(); - } else { - collection['tableService']['listTablesSegmented'][region][resourceId].data = tableResults.entries; - - // Add table ACLs - async.each(tableResults.entries, function(tableName, tableCb){ - var tableId = `${resourceId}/tableService/${tableName}`; - collection['tableService']['getTableAcl'][region][tableId] = {}; - - storageService.getTableAcl(tableName, function(getErr, getData){ - if (getErr || !getData) { - collection['tableService']['getTableAcl'][region][tableId].err = (getErr || 'No data returned'); - } else { - collection['tableService']['getTableAcl'][region][tableId].data = getData; - } - tableCb(); - }); - }, function(){ - sCb(); - }); + const key = subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value? subObj.data.keys[0].value:null; + if (!key) return sCb(); + + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const credential = new AzureNamedKeyCredential(storageAccountName, key); + const serviceClient = new TableServiceClient( + `https://${storageAccountName}.table.core.windows.net`, + credential + ); + + const tables = []; + + (async() => { + try { + for await (const table of serviceClient.listTables()) { + tables.push(table.name); } - }); - } else { - sCb(); - } - }, function() { - cb(); - }); - }, function() { - callback(); - }); -}; \ No newline at end of file + + collection['tableService']['listTablesSegmented'][region][resourceId].data = tables; + + async.eachLimit(tables, 10, async(tableName, tableCb) => { + const tableId = `${resourceId}/tableService/${tableName}`; + collection['tableService']['getTableAcl'][region][tableId] = {}; + + try { + const aclResponse = await serviceClient.getAccessPolicy(tableName); + collection['tableService']['getTableAcl'][region][tableId].data = aclResponse; + } catch (getErr) { + collection['tableService']['getTableAcl'][region][tableId].err = getErr.message || getErr; + } + + tableCb(); + }, sCb); + } catch (tableErr) { + collection['tableService']['listTablesSegmented'][region][resourceId].err = tableErr.message || tableErr; + sCb(); + } + })(); + }, cb); + }, callback); +}; diff --git a/collectors/azure/tableService/listTablesSegmentedNew.js b/collectors/azure/tableService/listTablesSegmentedNew.js new file mode 100644 index 000000000..ec6bba62c --- /dev/null +++ b/collectors/azure/tableService/listTablesSegmentedNew.js @@ -0,0 +1,67 @@ +var async = require('async'); +var azureStorage = require('@azure/data-tables'); + +module.exports = function(collection, reliesOn, callback) { + if (!reliesOn['storageAccounts.listKeys']) return callback(); + + if (!collection['tableService']['listTablesSegmented']) collection['tableService']['listTablesSegmented'] = {}; + if (!collection['tableService']['getTableAcl']) collection['tableService']['getTableAcl'] = {}; + + // Loop through regions and properties in reliesOn + async.eachOf(reliesOn['storageAccounts.listKeys'], function(regionObj, region, cb) { + collection['tableService']['listTablesSegmented'][region] = {}; + collection['tableService']['getTableAcl'][region] = {}; + + async.eachOfLimit(regionObj, 5, async function(subObj, resourceId, sCb) { + collection['tableService']['listTablesSegmented'][region][resourceId] = {}; + + if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { + // Extract storage account name from resourceId + let storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + let connectionString = `DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${subObj.data.keys[0].value};EndpointSuffix=core.windows.net`; + let tableList = []; + try { + let storageService = azureStorage.TableServiceClient.fromConnectionString(connectionString); + let iterator = storageService.listTables(); + let item = await iterator.next(); + + while (!item.done) { + collection['tableService']['listTablesSegmented'][region][resourceId].data = []; + let tableName = item.value.name; + tableList.push({ name: tableName}); + + let tableId = `${resourceId}/tableService/${tableName}`; + collection['tableService']['getTableAcl'][region][tableId] = {}; + const credential = new azureStorage.AzureNamedKeyCredential(storageAccountName, subObj.data.keys[0].value); + const tableClient = new azureStorage.TableClient(`https://${storageAccountName}.table.core.windows.net`, tableName, credential); + + tableClient.getAccessPolicy() + .then(result => { + collection['tableService']['getTableAcl'][region][tableId].data = {signedIdentifiers: result}; + }) + .catch(err => { + + collection['tableService']['getTableAcl'][region][tableId].err = err; + }); + item = await iterator.next(); + } + } catch (exception) { + collection['tableService']['listTablesSegmented'][region][resourceId].err = exception.message; + } + + if (tableList.length) { + collection['tableService']['listTablesSegmented'][region][resourceId].data = tableList; + } else { + collection['tableService']['listTablesSegmented'][region][resourceId].data = []; + } + + } else { + sCb(); + } + }, function() { + cb(); + }); + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/github/collector.js b/collectors/github/collector.js index d63b6bb8d..6877a6a1a 100644 --- a/collectors/github/collector.js +++ b/collectors/github/collector.js @@ -2,13 +2,14 @@ /********************* Collector - The collector will query GitHub APIs for the information required - to run the CloudSploit scans. This data will be returned in the callback + to run the CloudExploit scans. This data will be returned in the callback as a JSON object. *********************/ var Octokit = require('@octokit/rest'); -var Octoapp = require('@octokit/app'); -var Octoreq = require('@octokit/request'); +const { createAppAuth } = require('@octokit/auth-app'); +const fetch = require('node-fetch'); +const { request } = require('@octokit/request'); var async = require('async'); var collectors = require(__dirname + '/../../collectors/github'); @@ -104,196 +105,147 @@ var postcalls = [ // Loop through all of the top-level collectors for each service var collect = function(GitHubConfig, settings, callback) { var collection = {}; - var appConfig = { id: GitHubConfig.application_id, privateKey: GitHubConfig.private_key }; + var appConfig = { appId: GitHubConfig.application_id, privateKey: GitHubConfig.private_key }; if (GitHubConfig.url) appConfig.baseUrl = GitHubConfig.url; - const app = new Octoapp(appConfig); - const jwt = app.getSignedJsonWebToken(); + const auth = createAppAuth(appConfig); + auth({ type: 'app' }).then((authentication)=> { + const jwt = authentication.token; - var reqObj = { - headers: { - authorization: `Bearer ${jwt}`, - accept: 'application/vnd.github.machine-man-preview+json' - } - }; + var reqObj = { + headers: { + authorization: `Bearer ${jwt}`, + accept: 'application/vnd.github.machine-man-preview+json' + }, + request: { + fetch: fetch + } + }; - var path = GitHubConfig.organization ? '/orgs/:org/installation' : '/users/:username/installation'; - var param = GitHubConfig.organization ? 'org' : 'username'; - reqObj[param] = GitHubConfig.login; + var path = GitHubConfig.organization ? '/orgs/:org/installation' : '/users/:username/installation'; + var param = GitHubConfig.organization ? 'org' : 'username'; + reqObj[param] = GitHubConfig.login; - Octoreq('GET ' + path, reqObj).then(function(data){ - if (!data || !data.data || !data.data.id) return callback('No installation ID found. Please ensure the GitHub app is installed.'); + request('GET ' + path, reqObj).then(function(data) { + if (!data || !data.data || !data.data.id) return callback('No installation ID found. Please ensure the GitHub app is installed.'); - var installationId = data.data.id; + var installationId = data.data.id; - if (GitHubConfig.installation_id && GitHubConfig.installation_id !== installationId) { - return callback('Installation ID misconfigured. Please reinstall the GitHub app.'); - } + if (GitHubConfig.installation_id && GitHubConfig.installation_id !== installationId) { + return callback('Installation ID misconfigured. Please reinstall the GitHub app.'); + } - app.getInstallationAccessToken({ installationId }).then(function(installationToken){ - if (!installationToken) return callback('Installation token could not be obtained. Please ensure the GitHub app is installed.'); - - var octokit = { - server: new Octokit({ - baseUrl: GitHubConfig.url, - auth: 'token ' + installationToken, - previews: [ - 'hellcat-preview', - 'machine-man-preview' - ] - }), - user: new Octokit({ - baseUrl: GitHubConfig.url, - auth: 'token ' + GitHubConfig.access_token, - previews: [ - 'hellcat-preview', - 'machine-man-preview' - ] - }) - }; - - var processPagination = function(callObj, results) { - if (callObj.paginate && results) { - if (callObj.paginate !== 'self') { - var masterList = []; - for (var r in results) { - if (results[r][callObj.paginate]) masterList = masterList.concat(results[r][callObj.paginate]); + auth({ type: 'installation' }).then(authentication => { + const installationToken = authentication.token; + if (!installationToken) return callback('Installation token could not be obtained. Please ensure the GitHub app is installed.'); + + var octokit = { + server: new Octokit({ + baseUrl: GitHubConfig.url, + auth: 'token ' + installationToken, + previews: [ + 'hellcat-preview', + 'machine-man-preview' + ] + }), + user: new Octokit({ + baseUrl: GitHubConfig.url, + auth: 'token ' + GitHubConfig.access_token, + previews: [ + 'hellcat-preview', + 'machine-man-preview' + ] + }) + }; + + var processPagination = function(callObj, results) { + if (callObj.paginate && results) { + if (callObj.paginate !== 'self') { + var masterList = []; + for (var r in results) { + if (results[r][callObj.paginate]) masterList = masterList.concat(results[r][callObj.paginate]); + } + results = masterList; } - results = masterList; + + return results; + } else if (results && results.data) { + return results.data; + } else { + return null; } + }; - return results; - } else if (results && results.data) { - return results.data; - } else { - return null; - } - }; - - async.eachOfLimit(calls, 10, function(call, service, serviceCb){ - if (!collection[service]) collection[service] = {}; - - // Loop through each of the service's functions - async.eachOfLimit(call, 10, function(callObj, callKey, callCb) { - if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); - if (!collection[service][callKey]) collection[service][callKey] = {}; - - var params = callObj.params || {}; - if (callObj.inject_org) params.org = GitHubConfig.login; - var type = callObj.type; - - var finish = function() { - if (callObj.rateLimit) { - setTimeout(function() { - callCb(); - }, callObj.rateLimit); - } else { - callCb(); - } - }; + async.eachOfLimit(calls, 10, function(call, service, serviceCb) { + if (!collection[service]) collection[service] = {}; - if (callObj.override) { - collectors[service][callKey](GitHubConfig, octokit[type], collection, function() { - finish(); - }); - } else { - var processResults = function(results){ - collection[service][callKey].data = processPagination(callObj, results); - finish(); - }; + // Loop through each of the service's functions + async.eachOfLimit(call, 10, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); + if (!collection[service][callKey]) collection[service][callKey] = {}; - var processErr = function(err){ - if (err) collection[service][callKey].err = err; - finish(); + var params = callObj.params || {}; + if (callObj.inject_org) params.org = GitHubConfig.login; + var type = callObj.type; + + var finish = function() { + if (callObj.rateLimit) { + setTimeout(function() { + callCb(); + }, callObj.rateLimit); + } else { + callCb(); + } }; - if (callObj.paginate) { - var options = octokit[type][service][callKey].endpoint.merge(params); - octokit[type].paginate(options).then(processResults, processErr); + if (callObj.override) { + collectors[service][callKey](GitHubConfig, octokit[type], collection, function() { + finish(); + }); } else { - octokit[type][service][callKey](params).then(processResults, processErr); - } - } - }, function(){ - serviceCb(); - }); - }, function(){ - // Now loop through the follow up calls - async.eachSeries(postcalls, function(postcallObj, postcallCb) { - async.eachOfLimit(postcallObj, 10, function(serviceObj, service, serviceCb) { - if (!collection[service]) collection[service] = {}; - - async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { - if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); - if (!collection[service][callKey]) collection[service][callKey] = {}; - - // Ensure pre-requisites are met - if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return callCb(); - - if (callObj.reliesOnCall && - (!collection[callObj.reliesOnService] || - !collection[callObj.reliesOnService][callObj.reliesOnCall] || - !collection[callObj.reliesOnService][callObj.reliesOnCall].data || - !collection[callObj.reliesOnService][callObj.reliesOnCall].data.length)) return callCb(); - - var type = callObj.type; - - if (callObj.override) { - collectors[service][callKey](GitHubConfig, octokit[type], collection, function() { - if (callObj.rateLimit) { - setTimeout(function() { - callCb(); - }, callObj.rateLimit); - } else { - callCb(); - } - }); + var processResults = function(results) { + collection[service][callKey].data = processPagination(callObj, results); + finish(); + }; + + var processErr = function(err) { + if (err) collection[service][callKey].err = err; + finish(); + }; + + if (callObj.paginate) { + var options = octokit[type][service][callKey].endpoint.merge(params); + octokit[type].paginate(options).then(processResults, processErr); } else { - if (!callObj.reliesOnService && !callObj.reliesOnCall) { - var params = callObj.params || {}; - if (callObj.inject_org) params.org = GitHubConfig.login; - - var processResults = function(results){ - collection[service][callKey].data = processPagination(callObj, results); - callCb(); - }; - - var processErr = function(err){ - collection[service][callKey].err = err; - callCb(); - }; - - if (callObj.paginate) { - var options = octokit[type][service][callKey].endpoint.merge(params); - octokit[type].paginate(options).then(processResults, processErr); - } else { - octokit[type][service][callKey](params).then(processResults, processErr); - } - } else { - async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall].data, 10, function(dep, depCb) { - collection[service][callKey][dep[callObj.filterValue]] = {}; + octokit[type][service][callKey](params).then(processResults, processErr); + } + } + }, function() { + serviceCb(); + }); + }, function() { + // Now loop through the follow up calls + async.eachSeries(postcalls, function(postcallObj, postcallCb) { + async.eachOfLimit(postcallObj, 10, function(serviceObj, service, serviceCb) { + if (!collection[service]) collection[service] = {}; - var filter = {}; - if (callObj.inject_org) filter.org = GitHubConfig.login; - filter[callObj.filterKey] = dep[callObj.filterValue]; + async.eachOfLimit(serviceObj, 1, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); + if (!collection[service][callKey]) collection[service][callKey] = {}; - var processResults = function(results){ - collection[service][callKey][dep[callObj.filterValue]].data = processPagination(callObj, results); - depCb(); - }; + // Ensure pre-requisites are met + if (callObj.reliesOnService && !collection[callObj.reliesOnService]) return callCb(); - var processErr = function(err){ - collection[service][callKey][dep[callObj.filterValue]].err = err; - depCb(); - }; + if (callObj.reliesOnCall && + (!collection[callObj.reliesOnService] || + !collection[callObj.reliesOnService][callObj.reliesOnCall] || + !collection[callObj.reliesOnService][callObj.reliesOnCall].data || + !collection[callObj.reliesOnService][callObj.reliesOnCall].data.length)) return callCb(); - if (callObj.paginate) { - var options = octokit[type][service][callKey].endpoint.merge(filter); - octokit[type].paginate(options).then(processResults, processErr); - } else { - octokit[type][service][callKey](filter).then(processResults, processErr); - } - }, function() { + var type = callObj.type; + + if (callObj.override) { + collectors[service][callKey](GitHubConfig, octokit[type], collection, function() { if (callObj.rateLimit) { setTimeout(function() { callCb(); @@ -302,24 +254,79 @@ var collect = function(GitHubConfig, settings, callback) { callCb(); } }); + } else { + if (!callObj.reliesOnService && !callObj.reliesOnCall) { + var params = callObj.params || {}; + if (callObj.inject_org) params.org = GitHubConfig.login; + + var processResults = function(results) { + collection[service][callKey].data = processPagination(callObj, results); + callCb(); + }; + + var processErr = function(err) { + collection[service][callKey].err = err; + callCb(); + }; + + if (callObj.paginate) { + var options = octokit[type][service][callKey].endpoint.merge(params); + octokit[type].paginate(options).then(processResults, processErr); + } else { + octokit[type][service][callKey](params).then(processResults, processErr); + } + } else { + async.eachLimit(collection[callObj.reliesOnService][callObj.reliesOnCall].data, 10, function(dep, depCb) { + collection[service][callKey][dep[callObj.filterValue]] = {}; + + var filter = {}; + if (callObj.inject_org) filter.org = GitHubConfig.login; + filter[callObj.filterKey] = dep[callObj.filterValue]; + + var processResults = function(results) { + collection[service][callKey][dep[callObj.filterValue]].data = processPagination(callObj, results); + depCb(); + }; + + var processErr = function(err) { + collection[service][callKey][dep[callObj.filterValue]].err = err; + depCb(); + }; + + if (callObj.paginate) { + var options = octokit[type][service][callKey].endpoint.merge(filter); + octokit[type].paginate(options).then(processResults, processErr); + } else { + octokit[type][service][callKey](filter).then(processResults, processErr); + } + }, function() { + if (callObj.rateLimit) { + setTimeout(function() { + callCb(); + }, callObj.rateLimit); + } else { + callCb(); + } + }); + } } - } + }, function() { + serviceCb(); + }); }, function() { - serviceCb(); + postcallCb(); }); }, function() { - postcallCb(); + //console.log(JSON.stringify(collection, null, 2)); + helpers.cleanCollection(collection); + callback(null, collection); }); - }, function() { - //console.log(JSON.stringify(collection, null, 2)); - helpers.cleanCollection(collection); - callback(null, collection); }); }); + }).catch(function(err) { + callback(err); }); - }).catch(function(err){ - callback(err); }); }; -module.exports = collect; \ No newline at end of file +module.exports = collect; diff --git a/collectors/google/collector.js b/collectors/google/collector.js index cb6e05588..d416d7124 100644 --- a/collectors/google/collector.js +++ b/collectors/google/collector.js @@ -1,6 +1,6 @@ /********************* Collector - The collector will query Google APIs for the information required - to run the CloudSploit scans. This data will be returned in the callback + to run the CloudExploit scans. This data will be returned in the callback as a JSON object. Arguments: @@ -17,265 +17,241 @@ *********************/ var async = require('async'); -var helpers = require(__dirname + '/../../helpers/google'); +var helpers = require(__dirname + '/../../helpers/google'); +var collectData = require(__dirname + '/../../helpers/shared'); +var collectors = require(__dirname + '/index.js'); +var apiCalls = require(__dirname + '/../../helpers/google/api.js'); -var calls = { - disks: { - list: { - api: 'compute', - version: 'v1', - location: 'zone' - } - }, - securityPolicies: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - firewalls: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - instances: { - compute: { - list: { - api: 'compute', - version: 'v1', - location: 'zone' - }, - aggregatedList: { - api: 'compute', - version: 'v1', - location: null - } - }, - sql: { - list: { - api: 'sqladmin', - version: 'v1beta4', - location: null - } - }, - manyApi: true, - }, - instanceGroups: { - aggregatedList: { - api: 'compute', - version: 'v1', - location: null, - } - }, - keyRings: { - list: { - api: 'cloudkms', - version: 'v1', - location: 'region', - parent: true, - nested: true, - } - }, - networks: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - backendServices: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - healthChecks: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - buckets: { - list: { - api: 'storage', - version: 'v1', - location: null, - } - }, - targetHttpProxies: { - list: { - api: 'compute', - version: 'v1', - location: 'global' - } - }, - autoscalers: { - aggregatedList: { - api: 'compute', - version: 'v1', - location: 'null' - } - }, - subnetworks: { - list: { - api: 'compute', - version: 'v1', - location: 'region', - } - }, - projects: { - get: { - api: 'compute', - version: 'v1', - location: null - }, - getIamPolicy: { - api: 'cloudresourcemanager', - version: 'v1', - resource: true - } - }, - clusters: { - list: { - api: 'container', - version: 'v1beta1', - location: 'global', - parent: true, - nested: true, - } - }, - managedZones: { - list: { - api: 'dns', - version: 'v1', - location: null - } - }, - metrics: { - list: { - api: 'logging', - version: 'v2', - parent: true - } - }, - alertPolicies: { - list: { - api: 'monitoring', - version: 'v3', - parent: 'name' - } - }, - serviceAccounts: { - list: { - api: 'iam', - version: 'v1', - parent: 'name' - } - }, - sinks: { - list: { - api: 'logging', - version: 'v2', - parent: true - } - }, -}; +var calls = apiCalls.calls; -var postcalls = { - instances: { - getIamPolicy: { - api: 'compute', - version: 'v1', - location: 'zone', - reliesOnService: ['instances'], - reliesOnCall: ['list'], - filterKey: ['resource_'], - filterValue: ['id'], - } - }, - cryptoKeys: { - list: { - api: 'cloudkms', - version: 'v1', - location: 'region', - reliesOnService: ['keyRings'], - reliesOnCall: ['list'], - filterKey: ['parent'], - filterValue: ['name'], - nested: true - } - }, - buckets: { - getIamPolicy: { - api: 'storage', - version: 'v1', - location: null, - reliesOnService: ['buckets'], - reliesOnCall: ['list'], - filterKey: ['bucket'], - filterValue: ['name'], - } - }, - keys: { - list: { - api: 'iam', - version: 'v1', - parent: 'name', - serviceAccount: true, - reliesOnService: ['serviceAccounts'], - reliesOnCall: ['list'], - filterKey: ['id'], - filterValue: ['uniqueId'] - } - }, - users: { - list: { - api: 'sqladmin', - version: 'v1beta4', - location: null, - reliesOnService: ['instances'], - reliesOnSubService: ['sql'], - reliesOnCall: ['list'], - filterKey: ['instance'], - filterValue: ['name'], - } - } +var postcalls = apiCalls.postcalls; +var tertiarycalls = apiCalls.tertiarycalls; + +var specialcalls = apiCalls.specialcalls; + +var additionalCalls = apiCalls.additionalCalls; -}; var collect = function(GoogleConfig, settings, callback) { var collection = {}; - GoogleConfig.maxRetries = 5; + GoogleConfig.mRetries = 5; GoogleConfig.retryDelayOptions = {base: 300}; + let services = []; + + let integrationCall = function(collection, settings, service, calls, postcalls, cback) { + let collect = JSON.parse(JSON.stringify(collection)); + collect = Object.keys(collect).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = collect[key]; + return accumulator; + }, {}); + + if (settings.previousCollection) { + settings.previousCollection = Object.keys(settings.previousCollection).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = settings.previousCollection[key]; + return accumulator; + }, {}); + } + + if (collect[service.toLowerCase()] && + Object.keys(collect[service.toLowerCase()]) && + Object.keys(collect[service.toLowerCase()]).length && + collectData.callsCollected(service, collect, calls, postcalls) + ) { + try { + collectData.processIntegration(service, settings, collect, calls, postcalls, false,function() { + cback(); + }); + } catch (e) { + console.log(`Error in storing ${service} service data: ${JSON.stringify(e)}`); + cback(); + } + } else { + cback(); + } + }; + var regions = helpers.regions(); + if (settings.gather) { + return callback(null, calls, postcalls); + } + helpers.authenticate(GoogleConfig) .then(client => { - - async.eachOfLimit(calls, 10, function(call, service, serviceCb) { - if (!collection[service]) collection[service] = {}; - helpers.processCall(GoogleConfig, collection, settings, regions, call, service, client, function() { - serviceCb(); - }); - }, function() { - async.eachOfLimit(postcalls, 10, function(postcallObj, service, postcallCb) { - helpers.processCall(GoogleConfig, collection, settings, regions, postcallObj, service, client, function() { - postcallCb(); + async.series([ + function(cb) { + async.eachOfLimit(calls, 10, function(call, service, serviceCb) { + if (!collection[service]) collection[service] = {}; + helpers.processCall(GoogleConfig, collection, settings, regions, call, service, client, function() { + if (settings.identifier && calls[service].sendIntegration && calls[service].sendIntegration.enabled) { + if (!calls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, calls, [], function() { + serviceCb(); + }); + } else { + services.push(service); + serviceCb(); + } + } else { + serviceCb(); + } + }); + }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, calls, [], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } }); - }, function() { - callback(null, collection); - }); + }, + function(cb) { + async.eachOfLimit(postcalls, 10, function(postcallObj, service, postcallCb) { + helpers.processCall(GoogleConfig, collection, settings, regions, postcallObj, service, client, function() { + if (settings.identifier && postcalls[service].sendIntegration && postcalls[service].sendIntegration.enabled) { + if (!postcalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [postcalls], function() { + postcallCb(); + }); + } else { + services.push(service); + postcallCb(); + } + } else { + postcallCb(); + } + }); + }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [postcalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } + }); + }, + function(cb) { + async.eachOfLimit(tertiarycalls, 10, function(tertiaryCallObj, service, tertiaryCallCb) { + helpers.processCall(GoogleConfig, collection, settings, regions, tertiaryCallObj, service, client, function() { + if (settings.identifier && tertiarycalls[service].sendIntegration && tertiarycalls[service].sendIntegration.enabled) { + if (!tertiarycalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [tertiarycalls], function() { + tertiaryCallCb(); + }); + } else { + services.push(service); + tertiaryCallCb(); + } + } else { + tertiaryCallCb(); + } + }); + }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [tertiarycalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } + }); + }, + function(cb) { + async.eachOfLimit(additionalCalls, 10, function(additionalCallObj, service, additionalCallCb) { + helpers.processCall(GoogleConfig, collection, settings, regions, additionalCallObj, service, client, function() { + if (settings.identifier && additionalCalls[service].sendIntegration && additionalCalls[service].sendIntegration.enabled) { + if (!additionalCalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [additionalCalls], function() { + additionalCallCb(); + }); + } else { + services.push(service); + additionalCallCb(); + } + } else { + additionalCallCb(); + } + }); + }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [additionalCalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + cb(); + }); + } else { + cb(); + } + }); + }, + function(cb) { + async.eachOfLimit(specialcalls, 10, function(specialCallObj, service, specialCallCb) { + async.eachOfLimit(specialCallObj, 10, function(subCallObj, one, subCallCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + one) === -1) return subCallCb(); + collectors[service][one](GoogleConfig, collection, settings, regions, specialCallObj, service, client, function() { + if (settings.identifier && specialcalls[service].sendIntegration && specialcalls[service].sendIntegration.enabled) { + integrationCall(collection, settings, service, [], [specialcalls], function() { + subCallCb(); + }); + } else { + subCallCb(); + } + }); + }, function() { + specialCallCb(); + }); + }, function() { + cb(); + }); + }, + + ], function() { + if (collection && (!collection.projects || !collection.projects.get || (collection.projects && collection.projects.get && !Object.keys(collection.projects.get).length))) { + collection.projects = { + ...collection.projects, + get: { + global: { + data: [ + { + kind: 'compute#project', + name: GoogleConfig.project + } + ] + } + } + }; + } + callback(null, collection); }); }); }; -module.exports = collect; \ No newline at end of file +module.exports = collect; diff --git a/collectors/google/iam/list.js b/collectors/google/iam/list.js new file mode 100644 index 000000000..af55f50d6 --- /dev/null +++ b/collectors/google/iam/list.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + + + +module.exports = function(GoogleConfig, collection, settings, regions, call, service, dummy, callback) { + let project; + if (settings && settings.identifier && settings.identifier.cloud_account) { + project = settings.identifier.cloud_account; + } else { + return callback(); + } + + async.eachOfLimit(call, 1, function(callObj, callKey, callCb) { + if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); + if (!collection[service]) collection[service] = {}; + if (!collection[service][callKey]) collection[service][callKey] = {}; + if (!collection[service][callKey]['global']) collection[service][callKey]['global'] = {}; + if (!collection[service][callKey]['global']['data']) collection[service][callKey]['global']['data'] = []; + + let memberObj = {}; + + let groups = {}; + + if (collection['memberships'] && + collection['memberships']['list'] && + collection['memberships']['list']['global'] && + collection['memberships']['list']['global']['data'] && + collection['memberships']['list']['global']['data'].length) { + collection['memberships']['list']['global']['data'].forEach(membership => { + let user_email = membership.preferredMemberKey ? membership.preferredMemberKey.id: ''; + let group_name = membership.parent ? membership.parent.displayName : ''; + if (!groups[group_name]) groups[group_name] = []; + + groups[group_name].push({ + email: user_email, + roles: membership.roles + }); + }); + } + if (collection['projects'] && + collection['projects']['getIamPolicy'] && + collection['projects']['getIamPolicy']['global'] && + collection['projects']['getIamPolicy']['global']['data'] && + collection['projects']['getIamPolicy']['global']['data'].length) { + + collection['projects']['getIamPolicy']['global']['data'].forEach(data => { + if (data.bindings && data.bindings.length) { + data.bindings.forEach(binding => { + let role = binding.role; + let condition = binding.condition; + binding.members.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let memberType = member.startsWith('serviceAccount') ? 'serviceAccounts' : (member.startsWith('user') ? 'users' : (member.startsWith('group') ? 'groups' : 'domains')); + let resource = helpers.createResourceName(memberType, accountName, project); + if (!memberObj[resource]) memberObj[resource] = { + roles: [], + Id: resource, + email: accountName, + type: memberType + }; + + if (memberType === 'groups') { + let groupName = accountName.split('@')[0]; + if (groups[groupName]) { + memberObj[resource].users = groups[groupName]; + } else { + memberObj[resource].users = []; + } + } + + let roleObj = {role: role}; + if (condition) roleObj.condition = condition; + memberObj[resource].roles.push(roleObj); + }); + }); + } + }); + + if (memberObj && Object.values(memberObj).length) collection[service][callKey]['global']['data'] = Object.values(memberObj); + callCb(); + } else { + collection[service][callKey]['global']['data'] = []; + callCb(); + } + + }, function() { + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/oracle/collector.js b/collectors/oracle/collector.js index 8996ba827..d251f0901 100644 --- a/collectors/oracle/collector.js +++ b/collectors/oracle/collector.js @@ -1,6 +1,6 @@ /********************* Collector - The collector will query Oracle's APIs for the information required - to run the CloudSploit scans. This data will be returned in the callback + to run the CloudExploit scans. This data will be returned in the callback as a JSON object. Arguments: @@ -19,375 +19,20 @@ var async = require('async'); var helpers = require(__dirname + '/../../helpers/oracle'); +var collectData = require(__dirname + '/../../helpers/shared'); +var apiCalls = require(__dirname + '/../../helpers/oracle/api.js'); -const regionSubscriptionService = {name: 'regionSubscription', call: 'list', region: helpers.regions(false).default}; +var calls = apiCalls.calls; -var globalServices = [ - 'core' -]; +var postcalls = apiCalls.postcalls; -var calls = { - // Do not use regionSubscription in Plugins - // It will be loaded automatically by the - // Oracle Collector - regionSubscription: { - list: { - api: 'iam', - filterKey: ['tenancyId'], - filterValue: ['tenancyId'], - } - }, - vcn: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - publicIp: { - list: { - api: 'core', - filterKey: ['compartmentId', 'scope'], - filterValue: ['compartmentId', 'REGION'], - filterLiteral: [false, true], - } - }, - instance: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - loadBalancer: { - list: { - api: 'loadBalance', - restVersion: '/20170115', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'] - } - }, - user: { - list: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - authenticationPolicy: { - get: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['tenancyId'], - filterConfig: [true] - } - }, - namespace: { - get: { - api: 'objectStore', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - restVersion: '', - filterConfig: [true] - } - }, - group: { - list: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - exportSummary: { - list: { - api: 'fileStorage', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - restVersion: '/20171215', - } - }, - mountTarget: { - list: { - api: 'fileStorage', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - restVersion: '/20171215', - } - }, - // Do not use compartment:get in Plugins - // It will be loaded automatically by the - // Oracle Collector - compartment: { - get: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - waasPolicy: { - list: { - api: 'waas', - restVersion: '/20181116', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - policy: { - list: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - dbHome: { - list: { - api: 'database', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - instancePool: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - autoscaleConfiguration: { - list: { - api: 'autoscale', - restVersion: '/20181001', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - bootVolume: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - volume: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - availabilityDomain: { - list: { - api: 'iam', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - bootVolumeBackup: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - volumeBackup: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - bootVolumeAttachment: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - }, - }, - volumeBackupPolicy: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - volumeGroup: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - volumeGroupBackup: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - configuration: { - get: { - api: 'audit', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - filterConfig: [true] - } - }, - networkSecurityGroup: { - list: { - api: 'core', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - }, - dbSystem: { - list: { - api: 'database', - filterKey: ['compartmentId'], - filterValue: ['compartmentId'], - } - } -}; +var finalcalls = apiCalls.finalcalls; -// Important Note: All relies must be passed in an array format [] -var postcalls = { - vcn: { - get: { - api: 'core', - reliesOnService: ['vcn'], - reliesOnCall: ['list'], - filterKey: ['vcnId'], - filterValue: ['id'], - } - }, - subnet: { - list: { - api: 'core', - reliesOnService: ['vcn'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'vcnId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - securityList: { - list: { - api: 'core', - reliesOnService: ['vcn'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'vcnId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - userGroupMembership: { - list: { - api: 'iam', - reliesOnService: ['group'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'groupId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - bucket: { - list: { - api: 'objectStore', - reliesOnService: ['namespace'], - reliesOnCall: ['get'], - filterKey: ['compartmentId','namespaceName'], - filterValue: ['compartmentId','namespaceName'], - filterConfig: [true, false], - restVersion: '', - limit: 900 - } - }, - waasPolicy: { - get: { - api: 'waas', - restVersion: '/20181116', - reliesOnService: ['waasPolicy'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'waasPolicyId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - database: { - list: { - api: 'database', - restVersion: '/20160918', - reliesOnService: ['dbHome'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'dbHomeId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - securityRule: { - list: { - api: 'core', - reliesOnService: ['networkSecurityGroup'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'networkSecurityGroupId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, - volumeBackupPolicyAssignment: { - volume: { - api: 'core', - reliesOnService: ['volume'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'assetId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - }, - bootVolume: { - api: 'core', - reliesOnService: ['bootVolume'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'assetId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - } - }, -}; - -// Important Note: All relies must be passed in an array format [] -var finalcalls = { - bucket: { - get: { - api: 'objectStore', - reliesOnService: ['bucket','namespace'], - reliesOnCall: ['list', 'get'], - filterKey: ['bucketName', 'namespaceName'], - filterValue: ['name','namespace'], - restVersion: '', - } - }, - exprt: { - get: { - api: 'fileStorage', - reliesOnService: ['exportSummary'], - reliesOnCall: ['list'], - filterKey: ['compartmentId', 'exportId'], - filterValue: ['compartmentId', 'id'], - filterConfig: [true, false], - restVersion: '/20171215', - } - }, - preAuthenticatedRequest: { - list: { - api: 'objectStore', - reliesOnService: ['bucket','namespace'], - reliesOnCall: ['list', 'get'], - filterKey: ['bucketName', 'namespaceName'], - filterValue: ['name','namespace'], - restVersion: '' - } - }, -}; +var regionSubscriptionService; +var globalServices = [ + 'core' +]; var processCall = function(OracleConfig, collection, settings, regions, call, service, serviceCb) { // Loop through each of the service's functions @@ -396,6 +41,10 @@ var processCall = function(OracleConfig, collection, settings, regions, call, se if (!collection[service][callKey]) collection[service][callKey] = {}; async.eachLimit(regions[service], helpers.MAX_REGIONS_AT_A_TIME, function(region, regionCb) { + if (region === 'default') { + region = OracleConfig.region ? OracleConfig.region : 'us-ashburn-1'; + } + if (settings.skip_regions && settings.skip_regions.indexOf(region) > -1 && globalServices.indexOf(service) === -1) return regionCb(); @@ -455,10 +104,40 @@ var processCall = function(OracleConfig, collection, settings, regions, call, se }); }; +let integrationCall = function(collection, settings, service, calls, postcalls, cback) { + let collect = JSON.parse(JSON.stringify(collection)); + collect = Object.keys(collect).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = collect[key]; + return accumulator; + }, {}); + + settings.previousCollection = Object.keys(settings.previousCollection).reduce((accumulator, key) => { + accumulator[key.toLowerCase()] = settings.previousCollection[key]; + return accumulator; + }, {}); + + if (collect[service.toLowerCase()] && + Object.keys(collect[service.toLowerCase()]) && + Object.keys(collect[service.toLowerCase()]).length && + collectData.callsCollected(service, collect, calls, postcalls) + ) { + try { + collectData.processIntegration(service, settings, collect, calls, postcalls, false,function() { + cback(); + }); + } catch (e) { + console.log(`Error in storing ${service} service data: ${JSON.stringify(e)}`); + cback(); + } + } else { + cback(); + } +}; + + var getRegionSubscription = function(OracleConfig, collection, settings, calls, service, callKey, region, serviceCb) { var LocalOracleConfig = JSON.parse(JSON.stringify(OracleConfig)); - LocalOracleConfig.region = region; LocalOracleConfig.service = service; if (!collection[service]) collection[service] = {}; @@ -482,36 +161,108 @@ var getRegionSubscription = function(OracleConfig, collection, settings, calls, // Loop through all of the top-level collectors for each service var collect = function(OracleConfig, settings, callback) { var collection = {}; - + OracleConfig.region = OracleConfig.region ? OracleConfig.region : 'us-ashburn-1'; OracleConfig.maxRetries = 5; OracleConfig.retryDelayOptions = {base: 300}; + regionSubscriptionService = {name: 'regionSubscription', call: 'list', region: OracleConfig.region}; + + if (settings.gather) { + return callback(null, calls, postcalls, finalcalls); + } var regions = helpers.regions(settings.govcloud); + let services = []; getRegionSubscription(OracleConfig, collection, settings, calls, regionSubscriptionService.name, regionSubscriptionService.call, regionSubscriptionService.region, function() { async.eachOfLimit(calls, 10, function(call, service, serviceCb) { if (!collection[service]) collection[service] = {}; processCall(OracleConfig, collection, settings, regions, call, service, function() { - serviceCb(); + if (settings.identifier && calls[service].sendIntegration && calls[service].sendIntegration.enabled) { + if (!calls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, calls, [], function() { + serviceCb(); + }); + } else { + services.push(service); + serviceCb(); + } + } else { + serviceCb(); + } + // serviceCb(); }); }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, calls, [], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + }); + } // Now loop through the follow up calls async.eachOfLimit(postcalls, 10, function(postCall, service, serviceCb) { if (!collection[service]) collection[service] = {}; processCall(OracleConfig, collection, settings, regions, postCall, service, function() { - serviceCb(); + if (settings.identifier && postcalls[service].sendIntegration && postcalls[service].sendIntegration.enabled) { + if (!postcalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [postcalls], function() { + serviceCb(); + }); + } else { + services.push(service); + serviceCb(); + } + } else { + serviceCb(); + } + // serviceCb(); }); }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [postcalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + }); + } // Now loop through the follow up calls async.eachOfLimit(finalcalls, 10, function(finalCall, service, serviceCb) { if (!collection[service]) collection[service] = {}; processCall(OracleConfig, collection, settings, regions, finalCall, service, function() { - serviceCb(); + if (settings.identifier && finalcalls[service].sendIntegration && finalcalls[service].sendIntegration.enabled) { + if (!finalcalls[service].sendIntegration.integrationReliesOn) { + integrationCall(collection, settings, service, [], [finalcalls], function() { + serviceCb(); + }); + } else { + services.push(service); + serviceCb(); + } + } else { + serviceCb(); + } + // serviceCb(); }); }, function() { + if (settings.identifier) { + async.each(services, function(serv, callB) { + integrationCall(collection, settings, serv, [], [finalcalls], callB); + }, function(err) { + if (err) { + console.log(err); + } + services = []; + }); + } //console.log(JSON.stringify(collection, null, 2)); callback(null, collection); }); diff --git a/config_example.js b/config_example.js index 3bf02f442..0d5c8589e 100644 --- a/config_example.js +++ b/config_example.js @@ -1,7 +1,14 @@ -// CloudSploit config file +// CloudExploit config file module.exports = { credentials: { + alibaba: { + // OPTION 1: If using a credential JSON file, enter the path below + // credential_file: '/path/to/file.json', + // OPTION 2: If using hard-coded credentials, enter them below + // access_key: process.env.ALIBABA_ACCESS_KEY_ID || '', + // access_key_secret: process.env.ALIBABA_ACCESS_KEY_SECRET || '', + }, aws: { // OPTION 1: If using a credential JSON file, enter the path below // credential_file: '/path/to/file.json', @@ -9,8 +16,29 @@ module.exports = { // access_key: process.env.AWS_ACCESS_KEY_ID || '', // secret_access_key: process.env.AWS_SECRET_ACCESS_KEY || '', // session_token: process.env.AWS_SESSION_TOKEN || '', + // plugins_remediate: ['bucketEncryptionInTransit'] + }, + aws_remediate: { + // OPTION 1: If using a credential JSON file, enter the path below + // credential_file: '/path/to/file.json', + // OPTION 2: If using hard-coded credentials, enter them below + // access_key: process.env.AWS_ACCESS_KEY_ID || '', + // secret_access_key: process.env.AWS_SECRET_ACCESS_KEY || '', + // session_token: process.env.AWS_SESSION_TOKEN || '', }, azure: { + // OPTION 1: If using a credential JSON file, enter the path below + // credential_file: '/path/to/file.json', + // OPTION 2: If using hard-coded credentials, enter them below + // application_id: process.env.AZURE_APPLICATION_ID || '', + // key_value: process.env.AZURE_KEY_VALUE || '', + // directory_id: process.env.AZURE_DIRECTORY_ID || '', + // subscription_id: process.env.AZURE_SUBSCRIPTION_ID || '', + // storage_connection: process.env.AZURE_STORAGE_CONNECTION || '', + // blob_container: process.env.AZURE_BLOB_CONTAINER || '', + // govcloud: process.env.AZURE_GOV_CLOUD || '' + }, + azure_remediate: { // OPTION 1: If using a credential JSON file, enter the path below // credential_file: '/path/to/file.json', // OPTION 2: If using hard-coded credentials, enter them below @@ -19,12 +47,20 @@ module.exports = { // directory_id: process.env.AZURE_DIRECTORY_ID || '', // subscription_id: process.env.AZURE_SUBSCRIPTION_ID || '' }, + google_remediate: { + // OPTION 1: If using a credential JSON file, enter the path below + // credential_file: process.env.GOOGLE_APPLICATION_CREDENTIALS || '/path/to/file.json', + // OPTION 2: If using hard-coded credentials, enter them below + // project: process.env.GOOGLE_PROJECT_ID || 'my-project', + // client_email: process.env.GOOGLE_CLIENT_EMAIL || 'cloudexploit@your-project-name.iam.gserviceaccount.com', + // private_key: process.env.GOOGLE_PRIVATE_KEY || '-----BEGIN PRIVATE KEY-----\nYOUR-PRIVATE-KEY-GOES-HERE\n-----END PRIVATE KEY-----\n' + }, google: { // OPTION 1: If using a credential JSON file, enter the path below // credential_file: process.env.GOOGLE_APPLICATION_CREDENTIALS || '/path/to/file.json', // OPTION 2: If using hard-coded credentials, enter them below // project: process.env.GOOGLE_PROJECT_ID || 'my-project', - // client_email: process.env.GOOGLE_CLIENT_EMAIL || 'cloudsploit@your-project-name.iam.gserviceaccount.com', + // client_email: process.env.GOOGLE_CLIENT_EMAIL || 'cloudexploit@your-project-name.iam.gserviceaccount.com', // private_key: process.env.GOOGLE_PRIVATE_KEY || '-----BEGIN PRIVATE KEY-----\nYOUR-PRIVATE-KEY-GOES-HERE\n-----END PRIVATE KEY-----\n' }, oracle: { diff --git a/docs/aws.md b/docs/aws.md index cb44efa46..40360a02b 100644 --- a/docs/aws.md +++ b/docs/aws.md @@ -1,13 +1,13 @@ -# CloudSploit For Amazon Web Services (AWS) +# CloudExploit For Amazon Web Services (AWS) ## Cloud Provider Configuration -Create a "cloudsploit" user, with the `SecurityAudit` policy. +Create a "cloudexploit" user, with the `SecurityAudit` policy. 1. Log into your AWS account as an admin or with permission to create IAM resources. 1. Navigate to the [IAM console](https://console.aws.amazon.com/iam/home). 1. Click on [Users](https://console.aws.amazon.com/iam/home?region=us-east-1#/users) 1. [Create a new user (Add user)](https://console.aws.amazon.com/iam/home?region=us-east-1#/users$new?step=details) -1. Set the username to `cloudsploit` +1. Set the username to `cloudexploit` 1. Set the access type to "Programmatic access", click Next. 1. Select "Attach existing policies directly" and select the SecurityAudit policy. 1. Click "Create policy" to create a supplemental policy (some permissions are not included in SecurityAudit). @@ -33,7 +33,7 @@ Create a "cloudsploit" user, with the `SecurityAudit` policy. } ``` 1. Click "Review policy." -1. Provide a name (`CloudSploitSupplemental`) and click "Create policy." +1. Provide a name (`CloudExploitSupplemental`) and click "Create policy." 1. Return to the "Create user" page and attach the newly-created policy. Click "Next: tags." 1. Set tags as needed and then click on "Create user". 1. Make sure you safely store the Access key ID and Secret access key. diff --git a/docs/azure.md b/docs/azure.md index 5fe899709..956666cd3 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -1,16 +1,16 @@ -# CloudSploit For Microsoft Azure +# CloudExploit For Microsoft Azure ## Cloud Provider Configuration 1. Log into your Azure Portal and navigate to the Azure Active Directory service. 1. Select App registrations and then click on New registration. -1. Enter "CloudSploit" and/or a descriptive name in the Name field, take note of it, it will be used again in step 3. +1. Enter "CloudExploit" and/or a descriptive name in the Name field, take note of it, it will be used again in step 3. 1. Leave the "Supported account types" default: "Accounts in this organizational directory only (YOURDIRECTORYNAME)". 1. Click on Register. 1. Copy the Application ID and Paste it below. 1. Copy the Directory ID and Paste it below. 1. Click on Certificates & secrets. 1. Under Client secrets, click on New client secret. -1. Enter a Description (i.e. Cloudsploit-2019) and select Expires "In 1 year". +1. Enter a Description (i.e. Cloudexploit-2019) and select Expires "In 1 year". 1. Click on Add. 1. The Client secret value appears only once, make sure you store it safely. 1. Navigate to Subscriptions. @@ -20,6 +20,6 @@ 1. Click on "Add", then "Add role assignment". 1. In the "Role" drop-down, select "Security Reader". 1. Leave the "Assign access to" default value. -1. In the "Select" drop-down, type the name of the app registration (e.g. "CloudSploit") you created and select it. +1. In the "Select" drop-down, type the name of the app registration (e.g. "CloudExploit") you created and select it. 1. Click "Save". 1. Repeat the process for the role "Log Analytics Reader" diff --git a/docs/gcp.md b/docs/gcp.md index 6d77271c5..d5242f780 100644 --- a/docs/gcp.md +++ b/docs/gcp.md @@ -1,19 +1,76 @@ -# CloudSploit For Google Cloud Platform (GCP) +# CloudExploit For Google Cloud Platform (GCP) -## Cloud Provider Configuration +## Create Security Audit Role + +1. Log into your Google Cloud console and "Activate" your Cloud Shell. +1. Create a new file called khulnasoft-security-audit-role.yaml. You can use: ``nano khulnasoft-security-audit-role.yaml``. +1. Copy and paste the following yaml code in the file on your Cloud Shell, press Ctrl + X and type "Y" to save the file. *Note!* Exclude all rows starting with 'resourcemanager' if you do not use Organization. +``` +name: roles/KhulnasoftCSPMSecurityAudit +title: Khulnasoft CSPM Security Audit +includedPermissions: + - cloudasset.assets.listResource + - cloudkms.cryptoKeys.list + - cloudkms.keyRings.list + - cloudsql.instances.list + - cloudsql.users.list + - compute.autoscalers.list + - compute.backendServices.list + - compute.disks.list + - compute.firewalls.list + - compute.healthChecks.list + - compute.instanceGroups.list + - compute.instances.getIamPolicy + - compute.instances.list + - compute.networks.list + - compute.projects.get + - compute.securityPolicies.list + - compute.subnetworks.list + - compute.targetHttpProxies.list + - container.clusters.list + - dns.managedZones.list + - iam.serviceAccountKeys.list + - iam.serviceAccounts.list + - logging.logMetrics.list + - logging.sinks.list + - monitoring.alertPolicies.list + - resourcemanager.folders.get + - resourcemanager.folders.getIamPolicy + - resourcemanager.folders.list + - resourcemanager.hierarchyNodes.listTagBindings + - resourcemanager.organizations.get + - resourcemanager.organizations.getIamPolicy + - resourcemanager.projects.get + - resourcemanager.projects.getIamPolicy + - resourcemanager.projects.list + - resourcemanager.resourceTagBindings.list + - resourcemanager.tagKeys.get + - resourcemanager.tagKeys.getIamPolicy + - resourcemanager.tagKeys.list + - resourcemanager.tagValues.get + - resourcemanager.tagValues.getIamPolicy + - resourcemanager.tagValues.list + - storage.buckets.getIamPolicy + - storage.buckets.list + - deploymentmanager.deployments.list + - dataproc.clusters.list + - artifactregistry.repositories.list + - composer.environments.list +stage: GA +``` +4. Run the following command to create the role, use your Organization Id to create the Role at the Org Level: ``gcloud iam roles create KhulnasoftCSPMSecurityAudit --organization=YOUR_ORGANIZATION_ID --file=khulnasoft-security-audit-role.yaml`` + * *You can use ``--project=YOUR_PROJECT_ID`` instead of ``--organization=``* + +## Create Service Account 1. Log into your Google Cloud console and navigate to IAM Admin > Service Accounts. 1. Click on "Create Service Account". -1. Enter "CloudSploit" in the "Service account name", then enter "CloudSploit API Access" in the description. +1. Enter "CloudExploit" in the "Service account name", then enter "CloudExploit API Access" in the description. 1. Click on Continue. -1. Select the role: Project > Viewer. +1. Select the role: Custom > Khulnasoft CSPM Security Audit. 1. Click on Continue. 1. Click on "Create Key". 1. Leave the default JSON selected. 1. Click on "Create". 1. The key will be downloaded to your machine. -1. Open the JSON key file, in a text editor and copy the Project Id, Client Email and Private Key values into the `index.js` file. -1. Enter the APIs & Services category. -1. Select Enable APIS & SERVICES at the top of the page -1. Search for DNS, then Select the option that appears and Enable it. -1. Enable all the APIs used to run scans, they are as follows: Stackdriver Monitoring, Stackdriver Logging, Compute, Cloud Key Management, Cloud SQL Admin, Kubernetes, Service Management, and Service Networking. \ No newline at end of file +1. Open the JSON key file, in a text editor and copy the Project Id, Client Email and Private Key values into the `index.js` file or move the JSON key file to a safe location; you can reference it in your `config.js` file later. diff --git a/docs/github.md b/docs/github.md index 11b04c1ec..c196c1e44 100644 --- a/docs/github.md +++ b/docs/github.md @@ -1,15 +1,15 @@ -# GitHub CloudSploit Scans +# GitHub CloudExploit Scans ## Background -CloudSploit provides GitHub account security auditing capabilities. CloudSploit uses the GitHub APIs to obtain metadata about the GitHub account (number of repositories, configuration, security settings, etc.) which is then used to evaluate alignment with security best practices. +CloudExploit provides GitHub account security auditing capabilities. CloudExploit uses the GitHub APIs to obtain metadata about the GitHub account (number of repositories, configuration, security settings, etc.) which is then used to evaluate alignment with security best practices. ## Getting Started To use the GitHub scans, you need a GitHub personal access token for an organization owner with read-only access. You can read more about the permission model below. Follow these steps: 1. Log into your GitHub organization account as an owner -2. Create a new machine (generic) user for the CloudSploit service (depending on your organization's configuration, you may need to impersonate this user to get access to its settings page). **NOTE**: You can optionally use an existing organization owner for this token, but we strongly recommend creating a new user. +2. Create a new machine (generic) user for the CloudExploit service (depending on your organization's configuration, you may need to impersonate this user to get access to its settings page). **NOTE**: You can optionally use an existing organization owner for this token, but we strongly recommend creating a new user. 3. Ensure the user is added as an owner of the Git organization. 4. Log into GitHub as this user. 5. Navigate to "Settings" > "Developer Settings" > "Personal Access Tokens" @@ -47,7 +47,7 @@ To use the GitHub scans, you need a GitHub personal access token for an organiza - [ ] write:gpg_key - [x] read:gpg_key -8. Save the permissions to obtain a token. Copy this token for use with CloudSploit. +8. Save the permissions to obtain a token. Copy this token for use with CloudExploit. ``` GITHUB_ORG= GITHUB_TOKEN= node index.js @@ -57,20 +57,20 @@ GITHUB_ORG= GITHUB_TOKEN= node index.js GitHub has a number of ways to provide access to its APIs, each with different levels of access. These include: third-party OAuth applications, GitHub applications, and personal access tokens. -CloudSploit requires personal access tokens because many of the APIs it invokes are not exposed to OAuth and GitHub applications. These applications were designed to provide functionality around creating repositories, issues, checks, pull requests, etc., and were not designed for use as auditing tools. +CloudExploit requires personal access tokens because many of the APIs it invokes are not exposed to OAuth and GitHub applications. These applications were designed to provide functionality around creating repositories, issues, checks, pull requests, etc., and were not designed for use as auditing tools. -CloudSploit recommends creating a machine user (also called a generic user in some organizations) for the auditing service. This user must be added as an organization owner (required to have visibility into all repositories and settings). However, a read-only access key can be created for it to limit the scope in which it operates. +CloudExploit recommends creating a machine user (also called a generic user in some organizations) for the auditing service. This user must be added as an organization owner (required to have visibility into all repositories and settings). However, a read-only access key can be created for it to limit the scope in which it operates. ## Developing New Plugins -CloudSploit GitHub scans contain two main pieces: 1) a collector that queries the GitHub APIs for information and 2) an executor which uses that information in "plugins" to evaluate security best practice adherence. To add new plugins follow the below steps. +CloudExploit GitHub scans contain two main pieces: 1) a collector that queries the GitHub APIs for information and 2) an executor which uses that information in "plugins" to evaluate security best practice adherence. To add new plugins follow the below steps. All code changes can be found in `collectors/github/collector.js` or as a plugin inside `plugins/github`. ### Using Octokit -CloudSploit uses [Octokit](https://octokit.github.io/rest.js), which is a Node.js module for making GitHub API calls. +CloudExploit uses [Octokit](https://octokit.github.io/rest.js), which is a Node.js module for making GitHub API calls. ### Determine the API Calls Needed for Your Plugin @@ -78,7 +78,7 @@ The source data required for the plugin will be different depending on the infor ### Evaluate API Call Order -CloudSploit supports both `calls` and `postcalls` in the collector. `calls` defined API calls that can be made at any time; in other words, the order does not matter. `postcalls` are API calls that must be made after a previous call is made because it relies on some information within that dependent call. +CloudExploit supports both `calls` and `postcalls` in the collector. `calls` defined API calls that can be made at any time; in other words, the order does not matter. `postcalls` are API calls that must be made after a previous call is made because it relies on some information within that dependent call. In our org admins example, the `orgs:listMembers` API call returns a list of all members of an organization: diff --git a/docs/notes.md b/docs/notes.md index c658a7f1a..5e337f0fb 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -25,8 +25,8 @@ To create a cross-account role: 10. Then click on the role name and copy the role ARN for use in the next step. ``` -## CloudSploit Supplemental Policy -Allows read only accesss to services not included in the SecurityAudit AWS Managed policy but that are also tested by the CSPM scans. +## CloudExploit Supplemental Policy +Allows read only access to services not included in the SecurityAudit AWS Managed policy but that are also tested by the CSPM scans. ```$xslt { diff --git a/docs/oracle.md b/docs/oracle.md index 178e5156b..f9089666f 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -1,4 +1,4 @@ -# CloudSploit For Oracle Cloud Infrastructure (OCI) +# CloudExploit For Oracle Cloud Infrastructure (OCI) ## Cloud Provider Configuration @@ -6,7 +6,7 @@ 1. Copy your Tenancy OCID and paste it in the index file. 1. Navigate to Identity > Users. 1. Click on Create User. -1. Enter "CloudSploit", then enter "CloudSploit API Access" in the description. +1. Enter "CloudExploit", then enter "CloudExploit API Access" in the description. 1. Click on Create. 1. Copy the User OCID and paste it in the index file. 1. Follow the steps to Generate an API Signing Key listed on Oracle's Cloud Doc(https://docs.cloud.oracle.com/iaas/Content/API/Concepts/apisigningkey.htm#How). @@ -15,12 +15,12 @@ 1. Open the private key (oci_api_key.pem) in your preferred text editor and paste it in the index file. 1. Navigate to Identity > Groups. 1. Click on Create Group. -1. Enter "SecurityAudit" in the Name field, then enter "CloudSploit Security Audit Access" in the description. +1. Enter "SecurityAudit" in the Name field, then enter "CloudExploit Security Audit Access" in the description. 1. Click on Submit. -1. Click on the SecurityAudit group in the Groups List and Add the CloudSploit API User to the group. +1. Click on the SecurityAudit group in the Groups List and Add the CloudExploit API User to the group. 1. Navigate to Identity > Policies. 1. Click on Create Policy. -1. Enter "SecurityAudit" in the Name field, then enter "CloudSploit Security Audit Policy" in the description. +1. Enter "SecurityAudit" in the Name field, then enter "CloudExploit Security Audit Policy" in the description. 1. Copy and paste the following statement: 1. ALLOW GROUP SecurityAudit to READ all-resources in tenancy 1. Click on Create. @@ -33,8 +33,8 @@ In your Oracle Cloud Infrastructure Console, under Identity > Users: * Click on "Create User" -* Set the Name to "CloudSploitAPI" -* Set the Description to "CloudSploit API Read Only Access" +* Set the Name to "CloudExploitAPI" +* Set the Description to "CloudExploit API Read Only Access" * Click on "Create" ## Generate an API Signing Key diff --git a/docs/upgrading.md b/docs/upgrading.md index 2ff6fd1b0..fcee7b9f5 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -1,30 +1,30 @@ -# Upgrading CloudSploit -CloudSploit version 2.0.0 introduced a number of changes from the original CloudSploit release, designed to make running CloudSploit easier in multiple environment types, including command line and CI/CD systems. +# Upgrading CloudExploit +CloudExploit version 2.0.0 introduced a number of changes from the original CloudExploit release, designed to make running CloudExploit easier in multiple environment types, including command line and CI/CD systems. ## Notable Changes * The addition of the `argparse` library to enhance CLI option support * Formalizing several previously-hidden settings and options (e.g. saving the JSON collection, multiple output formats, suppressions, etc.) * The addition of the `tty-table` library for pretty-print CLI output of results. This is now the default output, but it can be changed to text-only via the `--console=text` flag. * Improved documentation across the AWS, Azure, GCP, and OCI providers. -* The use of a `config.js` file for storing cloud provider configuration options, making it easier to run CloudSploit against multiple accounts by passing the `--config` flag. -* Fallback to the AWS credential chain, allowing users to get started running CloudSploit more quickly. -* Addition of an .eslint file for developers of CloudSploit and CloudSploit plugins. +* The use of a `config.js` file for storing cloud provider configuration options, making it easier to run CloudExploit against multiple accounts by passing the `--config` flag. +* Fallback to the AWS credential chain, allowing users to get started running CloudExploit more quickly. +* Addition of an .eslint file for developers of CloudExploit and CloudExploit plugins. * Formalizing CIS Benchmark options in the plugins using the `compliance` property. * Added the ability to run a single plugin directly from the CLI, without editing the `exports.js` file by passing the flag `--plugin pluginName`. ## Preparing Your Environment -If you previously used CloudSploit, you may need to make some changes as part of 2.0. Consider the following steps: +If you previously used CloudExploit, you may need to make some changes as part of 2.0. Consider the following steps: 1. If you previously edited the `index.js` file, copy your cloud provider credentials to a new `config.js` file instead. You can do this by: ``` $ cp config_example.js config.js // Edit your config.js file and pass either a path to a cloud credential file or the credentials themselves. $ ./index.js --config=./config.js ``` -1. If you are using AWS, you may now use the default credential handler by simply running CloudSploit with no config flag: +1. If you are using AWS, you may now use the default credential handler by simply running CloudExploit with no config flag: ``` $ ./index.js ``` -1. If you were running CloudSploit as part of a CI/CD process, the following flags may be helpful: +1. If you were running CloudExploit as part of a CI/CD process, the following flags may be helpful: ``` // Ignore passing results $ ./index.js --ignore-ok @@ -41,6 +41,6 @@ If you previously used CloudSploit, you may need to make some changes as part of // Creates a JUnit XML file $ ./index.js --junit=file.xml ``` -1. If you are running CloudSploit in a place where pretty-print tables, with colors, are not usable, you can revert to raw text output with the `--console=text` flag. +1. If you are running CloudExploit in a place where pretty-print tables, with colors, are not usable, you can revert to raw text output with the `--console=text` flag. 1. The text output has changed. The previous format contained too much information and created unreadable output. The new text output puts each result on its own line, and includes the plugin name, description, and other useful information. -1. If you are using CloudSploit as source input to other systems, we strongly recommend using the JSON output option to create a standardized output file (do not try to parse the output text format). Use `--json=file.json` to create results in a JSON structure. +1. If you are using CloudExploit as source input to other systems, we strongly recommend using the JSON output option to create a standardized output file (do not try to parse the output text format). Use `--json=file.json` to create results in a JSON structure. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index b2f0a3a13..6e318ee33 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -1,4 +1,4 @@ -# Writing CloudSploit Plugins +# Writing CloudExploit Plugins ## Collection Phase To write a plugin, you want to understand which data is needed and how your cloud infrastructure provides them via their API calls. Once you have identified the API calls needed, you can add them to the collect.js file for your cloud infrastructure provider. This file determines the cloud infrastructure API calls and their run-order. @@ -11,7 +11,7 @@ To write a plugin, you want to understand which data is needed and how your clou * [Oracle Collection](#oracle-collection) #### AWS Collection -The following declaration tells the CloudSploit collection engine to query the CloudFront service using the `listDistributions` call and then save the results returned under `DistributionList.Items`. +The following declaration tells the CloudExploit collection engine to query the CloudFront service using the `listDistributions` call and then save the results returned under `DistributionList.Items`. ``` CloudFront: { @@ -22,7 +22,7 @@ CloudFront: { }, ``` -The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `CloudFront distributions`, and then loop through each one and run a more detailed call, you would add the `CloudFront:listDistributions` call in the [`calls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/aws/collector.js#L58-L64) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/aws/collector.js#L467-L473), setting it to rely on the output of `listDistributions` call. +The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `CloudFront distributions`, and then loop through each one and run a more detailed call, you would add the `CloudFront:listDistributions` call in the [`calls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/aws/collector.js#L58-L64) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/aws/collector.js#L467-L473), setting it to rely on the output of `listDistributions` call. An example: @@ -35,13 +35,13 @@ getGroup: { }, ``` -This section tells CloudSploit to wait until the `IAM:listGroups` call has been made, and then loop through the data that is returned. The `filterKey` tells CloudSploit the name of the key from the original response, while `filterValue` tells it which property to set in the `getGroup` call filter. For example: `iam.getGroup({GroupName:abc})` where `abc` is the `GroupName` from the returned list. CloudSploit will loop through each response, re-invoking `getGroup` for each element. +This section tells CloudExploit to wait until the `IAM:listGroups` call has been made, and then loop through the data that is returned. The `filterKey` tells CloudExploit the name of the key from the original response, while `filterValue` tells it which property to set in the `getGroup` call filter. For example: `iam.getGroup({GroupName:abc})` where `abc` is the `GroupName` from the returned list. CloudExploit will loop through each response, re-invoking `getGroup` for each element. -You can find the [AWS Collector here.](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/aws/collector.js) +You can find the [AWS Collector here.](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/aws/collector.js) #### Azure Collection -The following declaration tells the Cloudsploit collection engine to query the Compute Management Service using the virtualMachines:listAll call. +The following declaration tells the Cloudexploit collection engine to query the Compute Management Service using the virtualMachines:listAll call. ``` virtualMachines: { @@ -52,7 +52,7 @@ virtualMachines: { }, ``` -The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `Virtual Machine instances`, and then loop through each one and run a more detailed call, you would add the `virtualMachines:listAll` call in the [`calls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/azure/collector.js#L50-L55) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/azure/collector.js#L293-L302), setting it to rely on the output of `listDistributions` call. +The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `Virtual Machine instances`, and then loop through each one and run a more detailed call, you would add the `virtualMachines:listAll` call in the [`calls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/azure/collector.js#L50-L55) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/azure/collector.js#L293-L302), setting it to rely on the output of `listDistributions` call. ``` virtualMachineExtensions: { @@ -67,11 +67,11 @@ virtualMachineExtensions: { }, ``` -You can find the [Azure Collector here.](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/azure/collector.js) +You can find the [Azure Collector here.](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/azure/collector.js) #### GCP Collection -The following declaration tells the Cloudsploit collection engine to query the Compute Management Service using the buckets:list call. +The following declaration tells the Cloudexploit collection engine to query the Compute Management Service using the buckets:list call. ``` buckets: { @@ -83,7 +83,7 @@ buckets: { }, ``` -The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `Storage Buckets`, and then loop through each one and run a more detailed call, you would add the `buckets:list` call in the [`calls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/google/collector.js#L103-L109) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/google/collector.js#L213-L223), setting it to rely on the output of `getIamPolicy` call. +The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `Storage Buckets`, and then loop through each one and run a more detailed call, you would add the `buckets:list` call in the [`calls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/google/collector.js#L103-L109) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/google/collector.js#L213-L223), setting it to rely on the output of `getIamPolicy` call. ``` buckets: { @@ -99,11 +99,11 @@ buckets: { }, ``` -You can find the [GCP Collector here.](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/google/collector.js) +You can find the [GCP Collector here.](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/google/collector.js) #### Oracle Collection -The following declaration tells the Cloudsploit collection engine to query the Compute Management Service using the vcn:list call. +The following declaration tells the Cloudexploit collection engine to query the Compute Management Service using the vcn:list call. ``` vcn: { @@ -115,7 +115,7 @@ vcn: { }, ``` -The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `VCNs`, and then loop through each one and run a more detailed call, you would add the `vcn:list` call in the [`calls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/oracle/collector.js#L41-L47) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/oracle/collector.js#L243-L251), setting it to rely on the output of `get` call. +The second section in `collect.js` is `postcalls`, which is an array of objects defining API calls that rely on other calls first returned. For example, if you need to query for all `VCNs`, and then loop through each one and run a more detailed call, you would add the `vcn:list` call in the [`calls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/oracle/collector.js#L41-L47) section and then the more detailed call in [`postcalls`](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/oracle/collector.js#L243-L251), setting it to rely on the output of `get` call. ``` vcn: { @@ -129,7 +129,7 @@ vcn: { }, ``` -You can find the [Oracle Collector here.](https://github.com/khulnasoft/cloudsploit/blob/master/collectors/oracle/collector.js) +You can find the [Oracle Collector here.](https://github.com/khulnasoft/cloudexploit/blob/master/collectors/oracle/collector.js) ## Scanning Phase @@ -171,7 +171,7 @@ IAM: { } }, ``` -The `property` tells CloudSploit which property to read in the response from AWS. +The `property` tells CloudExploit which property to read in the response from AWS. Then, under `postCalls`, add: ``` @@ -184,7 +184,7 @@ IAM: { } }, ``` -CloudSploit will first get the list of groups, then, it will loop through each one, using the group name to get more detailed info via `getGroup`. +CloudExploit will first get the list of groups, then, it will loop through each one, using the group name to get more detailed info via `getGroup`. Next, we'll write the plugin. Create a new file in the `plugins/iam` folder called `emptyGroups.js` (this plugin already exists, but you can create a similar one for the purposes of this example). @@ -244,7 +244,7 @@ virtualMachineExtensions: { } }, ``` -CloudSploit will first get the list of virtual machines, then, it will loop through each one, using the virtual machine name to get more detailed info via `virtualMachineExtensions`. +CloudExploit will first get the list of virtual machines, then, it will loop through each one, using the virtual machine name to get more detailed info via `virtualMachineExtensions`. Next, we'll write the plugin. Create a new file in the `plugins/virtualmachines` folder called `vmEndpointProtection.js` (this plugin already exists, but you can create a similar one for the purposes of this example). @@ -309,7 +309,7 @@ buckets: { } }, ``` -CloudSploit will first get the list of buckets, then, it will loop through each one, using the bucket name to get more detailed info via `getIamPolicy`. +CloudExploit will first get the list of buckets, then, it will loop through each one, using the bucket name to get more detailed info via `getIamPolicy`. Next, we'll write the plugin. Create a new file in the `plugins/google/storage` folder called `bucketAllUsersPolicy.js` (this plugin already exists, but you can create a similar one for the purposes of this example). @@ -370,7 +370,7 @@ subnet: { } }, ``` -CloudSploit will first get the list of vcns, then, it will loop through each one, using the vcn id to get more detailed info via `subnet:list`. +CloudExploit will first get the list of vcns, then, it will loop through each one, using the vcn id to get more detailed info via `subnet:list`. Next, we'll write the plugin. Create a new file in the `plugins/oracle/networking` folder called `subnetMultiAd.js` (this plugin already exists, but you can create a similar one for the purposes of this example). diff --git a/docs/writing-remediation.md b/docs/writing-remediation.md new file mode 100644 index 000000000..9b6218e1d --- /dev/null +++ b/docs/writing-remediation.md @@ -0,0 +1,125 @@ +# Writing CloudExploit Remediation +To write remediation for a plugin, you need to understand what action needs to be performed to remediate the plugin, what permissions are needed from the cloud provider, and what is the api call you need to make to perform that action. You need to understand what all data are needed to perform this. Those api calls to collect the data should be added in the collect.js for the particluar cloud provider, if those are not there already. For more information on collectors please check [complete guide](docs/writing-plugins.md). +### Remediations + +* [AWS Remediation](#aws-remediation) +* [Azure Remediation](#azure-remediation) +* [GCP Remediation](#gcp-remediation) +* [Oracle Remediation](#oracle-remediation) + +### Prerequisites +Please go through the collector and scanning doc [here](docs/writing-plugins.md) before developing remediation to understand how data is being collected from csp and results are stored. + +#### AWS Remediation +To remediate a plugin we need to specify following things in exports section: +``` +remediation_description: 'The impacted bucket will be configured to be have Versioning enabled.', +remediation_min_version: '202010160030', +apis_remediate: ['S3:listBuckets', 'S3:getBucketVersioning', 'S3:getBucketLocation'], +actions: { + remediate: ['S3:putBucketVersioning'], + rollback: ['S3:putBucketVersioning'], +}, +permissions: { + remediate: ['s3:PutBucketVersioning'], + rollback: ['s3:PutBucketVersioning'] +}, +realtime_triggers: ['s3:CreateBucket'], +``` +##### Description of the fields mentioned above: +* ```remediation_description```(string): A short description on what action will be performed to remediate the vulnerability. +* ```remediation_min_version```(string): Timestamp when this remediation is available in `YYYYMMDDhhmm` format. +* ```apis_remediate```(string): These are the api calls to collect the data needed for remediation. +* ```actions```(dictionary): Here we need to mention the following + * ```remediate```(list): The actual api calls to perform the remediate action. + * ```rollback```(list): The api calls to perform rollback to undo the remediation. +* ```permissions```(dictionary): Permission needed from cloud provider. + * ```remediate```(list): The permissions to perform the remediate action. + * ```rollback```(list): The permissions to perform rollback to undo the remediation. +* ```realtime_triggers```(list): The action which will trigger this remediation automatically. +* ```remediate: function(config, cache, settings, resource, callback)```: This is the function which will have the main logic to remediate. We will discuss this in more detail in [Remediate Function](#remediate-function) section. +* ```rollback: function(config, cache, settings, resource, callback)```: This is to rollback the remediate action + +Above is the example from s3/bucketVersioning plugin +##### Remediate Function +In ```remediate``` function we will receive the following inputs as parameter. +* ```config```: This will have the needed info to call aws sdk. +* ```cache``` : This will have the collection data. +* ```settings```: This object will have user given inputs. This also will store the action data for logging purpose. +* ```callback```: call back function. + +The inputs will be passed from engine.js. In enjine.js after the scan is complete we have all the data and result. +Now based on the result and the plugin names passed with ```--remediate``` in cli remediaion will be called. + +``` +if (settings.remediate && settings.remediate.length) { + if (settings.remediate.indexOf(key) > -1) { + if (results[r].status === 2) { + var resource = results[r].resource; + var event = {}; + event['remediation_file'] = {}; + event['remediation_file'] = initializeFile(event['remediation_file'], 'execute', key, resource); + plugin.remediate(cloudConfig, collection, event, resource, (err, result) => { + if (err) return console.log(err); + return console.log(result); + }); + } + } + } +``` +If the remediation action takes an input we need to pass it in ```event['input']```. Like for bucketEncrption we can have keys from user as + +``` +"input": { + "kmsKeyId": "110f0e35-eabc-466c-b884-e45356daa12d" + } +``` +So we need to do +``` +event['input'] = { + "kmsKeyId": "110f0e35-eabc-466c-b884-e45356daa12d" + } +``` +This event will be passed as ```settings``` to the remediate call. **Document the regex and a short description of the custom inputs**. +And then in remediate function we need to pass this to the parameter that actual sdk call expects. + +``` +if (settings.input && + settings.input.kmsKeyId) { + params = { + 'Bucket': bucketName, + 'ServerSideEncryptionConfiguration': { + 'Rules': [{ + 'ApplyServerSideEncryptionByDefault': { + 'SSEAlgorithm': 'aws:kms', + 'KMSMasterKeyID': config.kmsKeyId + } + }] + } + }; +} else { + params = { + 'Bucket': bucketName, + 'ServerSideEncryptionConfiguration': { + 'Rules': [{ + 'ApplyServerSideEncryptionByDefault': { + 'SSEAlgorithm': 'AES256', + } + }] + } + }; +} +``` +For more details please check the [bucketEncryption plugin here](https://github.com/khulnasoft/cloudexploit/blob/master/plugins/aws/s3/bucketEncryption.js) +#### Azure Remediation + +TBD + +#### GCP Collection + +TBD + +#### Oracle Collection + +TBD + \ No newline at end of file diff --git a/engine.js b/engine.js index 46e5c69c7..01c5f912f 100644 --- a/engine.js +++ b/engine.js @@ -2,9 +2,49 @@ var async = require('async'); var exports = require('./exports.js'); var suppress = require('./postprocess/suppress.js'); var output = require('./postprocess/output.js'); +var azureHelper = require('./helpers/azure/auth.js'); + +function runAuth(settings, remediateConfig, callback) { + if (settings.cloud && settings.cloud == 'azure') { + azureHelper.login(remediateConfig, function(err, loginData) { + if (err) return (callback(err)); + remediateConfig.token = loginData.token; + return callback(); + }); + } else callback(); +} + +async function uploadResultsToBlob(resultsObject, storageConnection, blobContainerName ) { + var azureStorage = require('@azure/storage-blob'); + + try { + const blobServiceClient = azureStorage.BlobServiceClient.fromConnectionString(storageConnection); + const containerClient = blobServiceClient.getContainerClient(blobContainerName); + + // Check if the container exists, if not, create it + const exists = await containerClient.exists(); + if (!exists) { + await containerClient.create(); + console.log(`Container ${blobContainerName} created successfully.`); + } + + const blobName = `results-${Date.now()}.json`; + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + + const data = JSON.stringify(resultsObject, null, 2); + const uploadBlobResponse = await blockBlobClient.upload(data, data.length); + console.log(`Blob ${blobName} uploaded successfully. Request ID: ${uploadBlobResponse.requestId}`); + } catch (error) { + if (error.message && error.message == 'Invalid DefaultEndpointsProtocol') { + console.log(`Invalid Storage Account connection string ${error.message}`); + } else { + console.log(`Failed to upload results to blob: ${error.message}`); + } + } +} /** - * The main function to execute CloudSploit scans. + * The main function to execute CloudExploit scans. * @param cloudConfig The configuration for the cloud provider. * @param settings General purpose settings. */ @@ -20,6 +60,14 @@ var engine = function(cloudConfig, settings) { var plugins = exports[settings.cloud]; var apiCalls = []; + // Load resource mappings + var resourceMap; + try { + resourceMap = require(`./helpers/${settings.cloud}/resources.js`); + } catch (e) { + resourceMap = {}; + } + // Print customization options if (settings.compliance) console.log(`INFO: Using compliance modes: ${settings.compliance.join(', ')}`); if (settings.govcloud) console.log('INFO: Using AWS GovCloud mode'); @@ -27,6 +75,7 @@ var engine = function(cloudConfig, settings) { if (settings.ignore_ok) console.log('INFO: Ignoring passing results'); if (settings.skip_paginate) console.log('INFO: Skipping AWS pagination mode'); if (settings.suppress && settings.suppress.length) console.log('INFO: Suppressing results based on suppress flags'); + if (settings.remediate && settings.remediate.length) console.log('INFO: Remediate the plugins mentioned here'); if (settings.plugin) { if (!plugins[settings.plugin]) return console.log(`ERROR: Invalid plugin: ${settings.plugin}`); console.log(`INFO: Testing plugin: ${plugins[settings.plugin].title}`); @@ -83,6 +132,12 @@ var engine = function(cloudConfig, settings) { plugin.apis.forEach(function(api) { if (apiCalls.indexOf(api) === -1) apiCalls.push(api); }); + // add the remediation api calls also for data to be collected + if (settings.remediate && settings.remediate.includes(pluginId)){ + plugin.apis_remediate.forEach(function(api) { + if (apiCalls.indexOf(api) === -1) apiCalls.push(api); + }); + } } }); @@ -91,6 +146,24 @@ var engine = function(cloudConfig, settings) { console.log(`INFO: Found ${apiCalls.length} API calls to make for ${settings.cloud} plugins`); console.log('INFO: Collecting metadata. This may take several minutes...'); + const initializeFile = function(file, type, testQuery, resource) { + if (!file['access']) file['access'] = {}; + if (!file['pre_remediate']) file['pre_remediate'] = {}; + if (!file['pre_remediate']['actions']) file['pre_remediate']['actions'] = {}; + if (!file['pre_remediate']['actions'][testQuery]) file['pre_remediate']['actions'][testQuery] = {}; + if (!file['pre_remediate']['actions'][testQuery][resource]) file['pre_remediate']['actions'][testQuery][resource] = {}; + if (!file['post_remediate']) file['post_remediate'] = {}; + if (!file['post_remediate']['actions']) file['post_remediate']['actions'] = {}; + if (!file['post_remediate']['actions'][testQuery]) file['post_remediate']['actions'][testQuery] = {}; + if (!file['post_remediate']['actions'][testQuery][resource]) file['post_remediate']['actions'][testQuery][resource] = {}; + if (!file['remediate']) file['remediate'] = {}; + if (!file['remediate']['actions']) file['remediate']['actions'] = {}; + if (!file['remediate']['actions'][testQuery]) file['remediate']['actions'][testQuery] = {}; + if (!file['remediate']['actions'][testQuery][resource]) file['remediate']['actions'][testQuery][resource] = {}; + + return file; + }; + // STEP 2 - Collect API Metadata from Service Providers collector(cloudConfig, { api_calls: apiCalls, @@ -100,57 +173,115 @@ 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; + var resultsObject = {}; // Initialize resultsObject for azure gov cloud - async.mapValuesLimit(plugins, 10, function(plugin, key, pluginDone) { - if (skippedPlugins.indexOf(key) > -1) return pluginDone(null, 0); + function executePlugins(cloudRemediateConfig) { + async.mapValuesLimit(plugins, 10, function(plugin, key, pluginDone) { + if (skippedPlugins.indexOf(key) > -1) return pluginDone(null, 0); + var postRun = function(err, results) { + if (err) return console.log(`ERROR: ${err}`); + if (!results || !results.length) { + console.log(`Plugin ${plugin.title} returned no results. There may be a problem with this plugin.`); + } else { + if (!resultsObject[plugin.title]) { + resultsObject[plugin.title] = []; + } + 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; + } + + resultsObject[plugin.title].push(results[r]); - 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]}`); + 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 for the worst status to calculate + // the exit code + maximumStatus = Math.max(maximumStatus, results[r].status); + // Remediation + if (settings.remediate && settings.remediate.length) { + if (settings.remediate.indexOf(key) > -1) { + if (results[r].status === 2) { + var resource = results[r].resource; + var event = {}; + event.region = results[r].region; + event['remediation_file'] = {}; + event['remediation_file'] = initializeFile(event['remediation_file'], 'execute', key, resource); + plugin.remediate(cloudRemediateConfig, collection, event, resource, (err, result) => { + if (err) return console.log(err); + return console.log(result); + }); + } + } } - }); + } + } - 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); + }; + + if (plugin.asl && settings['run-asl']) { + console.log(`INFO: Using custom ASL for plugin: ${plugin.title}`); + // Inject APIs and resource maps + plugin.asl.apis = plugin.apis; + var aslConfig = require('./helpers/asl/config.json'); + var aslVersion = plugin.asl.version ? plugin.asl.version : aslConfig.current_version; + let aslRunner; + try { + aslRunner = require(`./helpers/asl/asl-${aslVersion}.js`); + + } catch (e) { + postRun('Error: ASL: Wrong ASL Version: ', e); + } + + aslRunner(collection, plugin.asl, resourceMap, postRun); + } else { + plugin.run(collection, settings, postRun); } + }, function(err) { + if (err) return console.log(err); - setTimeout(function() { pluginDone(err, maximumStatus); }, 0); + if (cloudConfig.StorageConnection && cloudConfig.BlobContainer) uploadResultsToBlob(resultsObject, cloudConfig.StorageConnection, cloudConfig.BlobContainer); + // console.log(JSON.stringify(collection, null, 2)); + outputHandler.close(); + if (settings.exit_code) { + // The original cloudexploit 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) + console.log(`INFO: Exiting with exit code: ${maximumStatus}`); + process.exitCode = maximumStatus; + } + console.log('INFO: Scan complete'); }); - }, function(err) { - if (err) return console.log(err); - // console.log(JSON.stringify(collection, null, 2)); - outputHandler.close(); - if (settings.exit_code) { - // 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) - console.log(`INFO: Exiting with exit code: ${maximumStatus}`); - process.exitCode = maximumStatus; - } - console.log('INFO: Scan complete'); - }); + } + + if (settings.remediate && settings.remediate.length && cloudConfig.remediate) { + runAuth(settings, cloudConfig.remediate, function(err) { + if (err) return console.log(err); + executePlugins(cloudConfig.remediate); + }); + } else { + executePlugins(cloudConfig); + } }); }; -module.exports = engine; \ No newline at end of file +module.exports = engine; diff --git a/exports.js b/exports.js index 5a338cfa5..261e4485f 100644 --- a/exports.js +++ b/exports.js @@ -2,50 +2,155 @@ module.exports = { aws : { + 'accessAnalyzerEnabled' : require(__dirname + '/plugins/aws/accessanalyzer/accessAnalyzerEnabled.js'), + 'accessAnalyzerActiveFindings' : require(__dirname + '/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js'), + 'acmValidation' : require(__dirname + '/plugins/aws/acm/acmValidation.js'), 'acmCertificateExpiry' : require(__dirname + '/plugins/aws/acm/acmCertificateExpiry.js'), + 'acmSingleDomainNameCertificate': require(__dirname + '/plugins/aws/acm/acmSingleDomainNameCertificate.js'), + 'acmCertificateHasTags' : require(__dirname + '/plugins/aws/acm/acmCertificateHasTags.js'), + + 'appmeshVGAccessLogging' : require(__dirname + '/plugins/aws/appmesh/appmeshVGAccessLogging.js'), + + 'apigatewayCertificateRotation' : require(__dirname + '/plugins/aws/apigateway/apigatewayCertificateRotation.js'), + 'apigatewayCloudwatchLogs' : require(__dirname + '/plugins/aws/apigateway/apigatewayCloudwatchLogs.js'), + 'apigatewayPrivateEndpoints' : require(__dirname + '/plugins/aws/apigateway/apigatewayPrivateEndpoints.js'), + 'apigatewayContentEncoding' : require(__dirname + '/plugins/aws/apigateway/apigatewayContentEncoding.js'), + 'apigatewayTracingEnabled' : require(__dirname + '/plugins/aws/apigateway/apigatewayTracingEnabled.js'), + 'apigatewayWafEnabled' : require(__dirname + '/plugins/aws/apigateway/apigatewayWafEnabled.js'), + 'detailedCloudWatchMetrics' : require(__dirname + '/plugins/aws/apigateway/detailedCloudWatchMetrics.js'), + 'apigatewayClientCertificate' : require(__dirname + '/plugins/aws/apigateway/apigatewayClientCertificate.js'), + 'apigatewayResponseCaching' : require(__dirname + '/plugins/aws/apigateway/apigatewayResponseCaching.js'), + 'apiStageLevelCacheEncryption' : require(__dirname + '/plugins/aws/apigateway/apiStageLevelCacheEncryption.js'), + 'customDomainTlsVersion' : require(__dirname + '/plugins/aws/apigateway/customDomainTlsVersion.js'), + 'apigatewayDefaultEndpointDisabled' : require(__dirname + '/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.js'), + 'apigatewayAuthorization' : require(__dirname + '/plugins/aws/apigateway/apigatewayAuthorization.js'), + 'apigatewayV2Authorization' : require(__dirname + '/plugins/aws/apigateway/apigatewayV2Authorization.js'), + 'apigatewayV2AccessLogging' : require(__dirname + '/plugins/aws/apigateway/apigatewayV2AccessLogging.js'), + 'apigatewayRequestValidation' : require(__dirname + '/plugins/aws/apigateway/apigatewayRequestValidation.js'), + + 'restrictExternalTraffic' : require(__dirname + '/plugins/aws/appmesh/restrictExternalTraffic.js'), + 'appmeshTLSRequired' : require(__dirname + '/plugins/aws/appmesh/appmeshTLSRequired.js'), + 'appmeshVGHealthChecks' : require(__dirname + '/plugins/aws/appmesh/appmeshVGHealthChecks.js'), + + 'asgMultiAz' : require(__dirname + '/plugins/aws/autoscaling/asgMultiAz.js'), + 'asgActiveNotifications' : require(__dirname + '/plugins/aws/autoscaling/asgActiveNotifications.js'), + 'asgCooldownPeriod' : require(__dirname + '/plugins/aws/autoscaling/asgCooldownPeriod.js'), + 'emptyASG' : require(__dirname + '/plugins/aws/autoscaling/emptyASG.js'), + 'sameAzElb' : require(__dirname + '/plugins/aws/autoscaling/sameAzElb.js'), + 'asgMissingELB' : require(__dirname + '/plugins/aws/autoscaling/asgMissingELB.js'), + 'webTierAsgAssociatedElb' : require(__dirname + '/plugins/aws/autoscaling/webTierAsgAssociatedElb.js'), + 'elbHealthCheckActive' : require(__dirname + '/plugins/aws/autoscaling/elbHealthCheckActive.js'), + 'asgSuspendedProcesses' : require(__dirname + '/plugins/aws/autoscaling/asgSuspendedProcesses.js'), + 'appTierAsgApprovedAmi' : require(__dirname + '/plugins/aws/autoscaling/appTierAsgApprovedAmi.js'), + 'webTierAsgApprovedAmi' : require(__dirname + '/plugins/aws/autoscaling/webTierAsgApprovedAmi.js'), + 'appTierAsgCloudwatchLogs' : require(__dirname + '/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.js'), + 'webTierAsgCloudwatchLogs' : require(__dirname + '/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.js'), + 'asgMissingSecurityGroups' : require(__dirname + '/plugins/aws/autoscaling/asgMissingSecurityGroups.js'), + 'webTierIamRole' : require(__dirname + '/plugins/aws/autoscaling/webTierIamRole.js'), + 'appTierIamRole' : require(__dirname + '/plugins/aws/autoscaling/appTierIamRole.js'), + 'asgUnusedLaunchConfiguration' : require(__dirname + '/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.js'), + 'asgTagPropagation' : require(__dirname + '/plugins/aws/autoscaling/asgTagPropagation.js'), + 'workgroupEncrypted' : require(__dirname + '/plugins/aws/athena/workgroupEncrypted.js'), 'workgroupEnforceConfiguration' : require(__dirname + '/plugins/aws/athena/workgroupEnforceConfiguration.js'), + + 'customModelInVpc' :require(__dirname + '/plugins/aws/bedrock/customModelInVpc.js'), + 'bedrockInUse' :require(__dirname + '/plugins/aws/bedrock/bedrockInUse.js'), + 'privateCustomModel' :require(__dirname + '/plugins/aws/bedrock/privateCustomModel.js'), + 'customModelHasTags' :require(__dirname + '/plugins/aws/bedrock/customModelHasTags.js'), + 'modelInvocationLoggingEnabled' :require(__dirname + '/plugins/aws/bedrock/modelInvocationLoggingEnabled.js'), + 'customModelEncrypted' :require(__dirname + '/plugins/aws/bedrock/customModelEncryptionEnabled.js'), + + 'infraConfigNotificationEnabled': require(__dirname + '/plugins/aws/imagebuilder/infraConfigNotificationEnabled.js'), 'publicS3Origin' : require(__dirname + '/plugins/aws/cloudfront/publicS3Origin.js'), 'secureOrigin' : require(__dirname + '/plugins/aws/cloudfront/secureOrigin.js'), 'insecureProtocols' : require(__dirname + '/plugins/aws/cloudfront/insecureProtocols.js'), 'cloudfrontHttpsOnly' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontHttpsOnly.js'), 'cloudfrontLoggingEnabled' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js'), 'cloudfrontWafEnabled' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontWafEnabled.js'), - - 'plainTextParameters' : require(__dirname + '/plugins/aws/cloudformation/plainTextParameters.js'), + 'cloudfrontFieldLevelEncryption': require(__dirname + '/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.js'), + 'cloudfrontInUse' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontInUse.js'), + 'enableOriginFailOver' : require(__dirname + '/plugins/aws/cloudfront/enableOriginFailOver.js'), + 'cloudfrontGeoRestriction' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontGeoRestriction.js'), + 'compressObjectsAutomatically' : require(__dirname + '/plugins/aws/cloudfront/compressObjectsAutomatically.js'), + 'cloudfrontOriginTlsVersion' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontOriginTlsVersion'), + 'cloudfrontCustomOriginHttpsOnly' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.js'), + 'cloudfrontTlsInsecureCipher' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.js'), + 'cloudfrontTlsDeprecatedProtocols' : require(__dirname + '/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.js'), + + 'volumeEncryption' : require(__dirname + '/plugins/aws/comprehend/volumeEncryption.js'), + 'flywheelInVpc' : require(__dirname + '/plugins/aws/comprehend/flywheelInVpc.js'), + 'outputResultEncryption' : require(__dirname + '/plugins/aws/comprehend/outputResultEncryption.js'), + + 'plainTextParameters' : require(__dirname + '/plugins/aws/cloudformation/plainTextParameters.js'), + 'stackFailedStatus' : require(__dirname + '/plugins/aws/cloudformation/stackFailedStatus.js'), + 'cloudformationDeletionPolicy' : require(__dirname + '/plugins/aws/cloudformation/cloudformationDeletionPolicy.js'), + 'driftDetection' : require(__dirname + '/plugins/aws/cloudformation/driftDetection.js'), + 'stackTerminationProtection' : require(__dirname + '/plugins/aws/cloudformation/stackTerminationProtection.js'), + 'stackNotifications' : require(__dirname + '/plugins/aws/cloudformation/stackNotifications.js'), + 'cloudformationAdminPriviliges' : require(__dirname + '/plugins/aws/cloudformation/cloudformationAdminPriviliges.js'), + 'cloudformationInUse' : require(__dirname + '/plugins/aws/cloudformation/cloudformationInUse.js'), 'cloudtrailBucketAccessLogging' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js'), 'cloudtrailBucketDelete' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailBucketDelete.js'), + 'cloudtrailDataEvents' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailDataEvents.js'), 'cloudtrailEnabled' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailEnabled.js'), 'cloudtrailEncryption' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailEncryption.js'), 'cloudtrailFileValidation' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailFileValidation.js'), + 'cloudtrailObjectLock' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailObjectLock.js'), 'cloudtrailToCloudwatch' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailToCloudwatch.js'), 'cloudtrailBucketPrivate' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailBucketPrivate.js'), + 'cloudtrailDeliveryFailing' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.js'), + 'cloudtrailManagementEvents' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailManagementEvents.js'), + 'cloudtrailS3Bucket' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailS3Bucket.js'), + 'globalLoggingDuplicated' : require(__dirname + '/plugins/aws/cloudtrail/globalLoggingDuplicated.js'), + 'cloudtrailNotificationsEnabled': require(__dirname + '/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.js'), + 'cloudtrailHasTags' : require(__dirname + '/plugins/aws/cloudtrail/cloudtrailHasTags.js'), + + 'ec2InstancesOptimized' : require(__dirname + '/plugins/aws/computeoptimizer/ec2InstancesOptimized.js'), + 'lambdaFunctionsOptimized' : require(__dirname + '/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.js'), + 'optimizerRecommendationsEnabled': require(__dirname + '/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.js'), + 'ebsVolumesOptimized' : require(__dirname + '/plugins/aws/computeoptimizer/ebsVolumesOptimized.js'), + 'asgOptimized' : require(__dirname + '/plugins/aws/computeoptimizer/asgOptimized.js'), 'configServiceEnabled' : require(__dirname + '/plugins/aws/configservice/configServiceEnabled.js'), + 'configComplaintRules' : require(__dirname + '/plugins/aws/configservice/configComplaintRules.js'), + 'configDeliveryFailing' : require(__dirname + '/plugins/aws/configservice/configDeliveryFailing.js'), + 'configServiceMissingBucket' : require(__dirname + '/plugins/aws/configservice/configServiceMissingBucket.js'), + 'servicesInUse' : require(__dirname + '/plugins/aws/configservice/servicesInUse.js'), + + 'devOpsGuruNotificationEnabled' : require(__dirname + '/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.js'), 'dmsEncryptionEnabled' : require(__dirname + '/plugins/aws/dms/dmsEncryptionEnabled.js'), + 'dmsPubliclyAccessibleInstances': require(__dirname + '/plugins/aws/dms/dmsPubliclyAccessibleInstances.js'), + 'dmsMultiAZFeatureEnabled' : require(__dirname + '/plugins/aws/dms/dmsMultiAZFeatureEnabled.js'), + 'autoMinorVersionUpgrade' : require(__dirname + '/plugins/aws/dms/autoMinorVersionUpgrade.js'), 'dynamoKmsEncryption' : require(__dirname + '/plugins/aws/dynamodb/dynamoKmsEncryption.js'), + 'daxClusterEncryption' : require(__dirname + '/plugins/aws/dynamodb/daxClusterEncryption.js'), + 'dynamoDeletionProtection' : require(__dirname + '/plugins/aws/dynamodb/dynamoDeletionProtection.js'), + 'dynamoContinuousBackups' : require(__dirname + '/plugins/aws/dynamodb/dynamoContinuousBackups.js'), + 'dynamoTableBackupExists' : require(__dirname + '/plugins/aws/dynamodb/dynamoTableBackupExists.js'), + 'dynamoTableHasTags' : require(__dirname + '/plugins/aws/dynamodb/dynamoTableHasTags.js'), + 'dynamoUnusedTable' : require(__dirname + '/plugins/aws/dynamodb/dynamoUnusedTable.js'), + + 'enhancedMetadataEnabled' : require(__dirname + '/plugins/aws/imagebuilder/enhancedMetadataEnabled.js'), - 'defaultSecurityGroup' : require(__dirname + '/plugins/aws/ec2/defaultSecurityGroup.js'), - 'launchWizardSecurityGroups' : require(__dirname + '/plugins/aws/ec2/launchWizardSecurityGroups'), - 'elasticIpLimit' : require(__dirname + '/plugins/aws/ec2/elasticIpLimit.js'), - 'subnetIpAvailability' : require(__dirname + '/plugins/aws/ec2/subnetIpAvailability.js'), - 'excessiveSecurityGroups' : require(__dirname + '/plugins/aws/ec2/excessiveSecurityGroups.js'), 'instanceLimit' : require(__dirname + '/plugins/aws/ec2/instanceLimit.js'), 'instanceVcpusLimit' : require(__dirname + '/plugins/aws/ec2/instanceVcpusLimit.js'), 'instanceMaxCount' : require(__dirname + '/plugins/aws/ec2/instanceMaxCount.js'), 'instanceKeyBasedLogin' : require(__dirname + '/plugins/aws/ec2/instanceKeyBasedLogin.js'), 'openAllPortsProtocols' : require(__dirname + '/plugins/aws/ec2/openAllPortsProtocols.js'), 'openCIFS' : require(__dirname + '/plugins/aws/ec2/openCIFS.js'), + 'openCustomPorts' : require(__dirname + '/plugins/aws/ec2/openCustomPorts.js'), 'openDNS' : require(__dirname + '/plugins/aws/ec2/openDNS.js'), 'openDocker' : require(__dirname + '/plugins/aws/ec2/openDocker.js'), 'openFTP' : require(__dirname + '/plugins/aws/ec2/openFTP.js'), 'openHadoopNameNode' : require(__dirname + '/plugins/aws/ec2/openHadoopNameNode.js'), 'openHadoopNameNodeWebUI' : require(__dirname + '/plugins/aws/ec2/openHadoopNameNodeWebUI.js'), + 'openOracleAutoDataWarehouse' : require(__dirname + '/plugins/aws/ec2/openOracleAutoDataWarehouse.js'), 'openKibana' : require(__dirname + '/plugins/aws/ec2/openKibana.js'), 'openMySQL' : require(__dirname + '/plugins/aws/ec2/openMySQL.js'), 'openOracle' : require(__dirname + '/plugins/aws/ec2/openOracle.js'), @@ -62,6 +167,19 @@ module.exports = { 'openVNCClient' : require(__dirname + '/plugins/aws/ec2/openVNCClient.js'), 'openVNCServer' : require(__dirname + '/plugins/aws/ec2/openVNCServer.js'), 'openElasticsearch' : require(__dirname + '/plugins/aws/ec2/openElasticsearch.js'), + 'openMongoDB' : require(__dirname + '/plugins/aws/ec2/openMongoDB.js'), + 'openCassandraClient' : require(__dirname + '/plugins/aws/ec2/openCassandraClient.js'), + 'openCassandraInternode' : require(__dirname + '/plugins/aws/ec2/openCassandraInternode.js'), + 'openCassandraMonitoring' : require(__dirname + '/plugins/aws/ec2/openCassandraMonitoring.js'), + 'openCassandraThrift' : require(__dirname + '/plugins/aws/ec2/openCassandraThrift.js'), + 'openLDAP' : require(__dirname + '/plugins/aws/ec2/openLDAP.js'), + 'openLDAPS' : require(__dirname + '/plugins/aws/ec2/openLDAPS.js'), + 'openSNMP' : require(__dirname + '/plugins/aws/ec2/openSNMP.js'), + 'openMemcached' : require(__dirname + '/plugins/aws/ec2/openMemcached.js'), + 'openInternalWeb' : require(__dirname + '/plugins/aws/ec2/openInternalWeb.js'), + 'openRedis' : require(__dirname + '/plugins/aws/ec2/openRedis.js'), + 'openHTTP' : require(__dirname + '/plugins/aws/ec2/openHTTP.js'), + 'openHTTPS' : require(__dirname + '/plugins/aws/ec2/openHTTPS.js'), 'vpcElasticIpLimit' : require(__dirname + '/plugins/aws/ec2/vpcElasticIpLimit.js'), 'classicInstances' : require(__dirname + '/plugins/aws/ec2/classicInstances.js'), 'flowLogsEnabled' : require(__dirname + '/plugins/aws/ec2/flowLogsEnabled.js'), @@ -69,53 +187,186 @@ module.exports = { 'overlappingSecurityGroups' : require(__dirname + '/plugins/aws/ec2/overlappingSecurityGroups.js'), 'publicAmi' : require(__dirname + '/plugins/aws/ec2/publicAmi.js'), 'encryptedAmi' : require(__dirname + '/plugins/aws/ec2/encryptedAmi.js'), + 'amiHasTags' : require(__dirname + '/plugins/aws/ec2/amiHasTags.js'), + 'amiNamingConvention' : require(__dirname + '/plugins/aws/ec2/amiNamingConvention.js'), + 'oldAmi' : require(__dirname + '/plugins/aws/ec2/oldAmi.js'), 'instanceIamRole' : require(__dirname + '/plugins/aws/ec2/instanceIamRole.js'), + 'ebsBackupEnabled' : require(__dirname + '/plugins/aws/ec2/ebsBackupEnabled.js'), 'ebsEncryptionEnabled' : require(__dirname + '/plugins/aws/ec2/ebsEncryptionEnabled.js'), + 'ebsSnapshotLifecycle' : require(__dirname + '/plugins/aws/ec2/ebsSnapshotLifecycle.js'), + 'ebsOldSnapshots' : require(__dirname + '/plugins/aws/ec2/ebsOldSnapshots.js'), + 'ebsRecentSnapshots' : require(__dirname + '/plugins/aws/ec2/ebsRecentSnapshots.js'), 'ebsSnapshotPrivate' : require(__dirname + '/plugins/aws/ec2/ebsSnapshotPrivate.js'), + 'ebsSnapshotPublic' : require(__dirname + '/plugins/aws/ec2/ebsSnapshotPublic.js'), + 'unusedSecurityGroups' : require(__dirname + '/plugins/aws/ec2/unusedSecurityGroups.js'), 'natMultiAz' : require(__dirname + '/plugins/aws/ec2/natMultiAz.js'), 'defaultVpcInUse' : require(__dirname + '/plugins/aws/ec2/defaultVpcInUse.js'), 'defaultVpcExists' : require(__dirname + '/plugins/aws/ec2/defaultVpcExists.js'), 'crossVpcPublicPrivate' : require(__dirname + '/plugins/aws/ec2/crossVpcPublicPrivate.js'), 'vpcEndpointAcceptance' : require(__dirname + '/plugins/aws/ec2/vpcEndpointAcceptance'), + 'vpcEndpointExposed' : require(__dirname + '/plugins/aws/ec2/vpcEndpointExposed.js'), + 'vpcEndpointCrossAccount' : require(__dirname + '/plugins/aws/ec2/vpcEndpointCrossAccount.js'), + 'vpcPeeringConnections' : require(__dirname + '/plugins/aws/ec2/vpcPeeringConnections.js'), 'ebsEncryptedSnapshots' : require(__dirname + '/plugins/aws/ec2/ebsEncryptedSnapshots.js'), - 'ec2MetadataOptions' : require(__dirname + '/plugins/aws/ec2/ec2MetadataOptions.js'), - + 'ebsDefaultEncryptionEnabled' : require(__dirname + '/plugins/aws/ec2/ebsDefaultEncryptionEnabled.js'), + 'ec2MetadataOptions' : require(__dirname + '/plugins/aws/ec2/ec2MetadataOptions.js'), + 'overutilizedEC2Instance' : require(__dirname + '/plugins/aws/ec2/overutilizedEC2Instance.js'), + + 'unusedEni' : require(__dirname + '/plugins/aws/ec2/unusedEni.js'), + 'unusedAmi' : require(__dirname + '/plugins/aws/ec2/unusedAmi.js'), + 'unusedVpcInternetGateways' : require(__dirname + '/plugins/aws/ec2/unusedVpcInternetGateways.js'), + 'managedNatGateway' : require(__dirname + '/plugins/aws/ec2/managedNatGateway.js'), + 'allowedCustomPorts' : require(__dirname + '/plugins/aws/ec2/allowedCustomPorts.js'), + 'ebsUnusedVolumes' : require(__dirname + '/plugins/aws/ec2/ebsUnusedVolumes.js'), + 'publicIpAddress' : require(__dirname + '/plugins/aws/ec2/publicIpAddress.js'), + 'unusedVirtualPrivateGateway' : require(__dirname + '/plugins/aws/ec2/unusedVirtualPrivateGateway.js'), + 'vpcSubnetInstancesPresent' : require(__dirname + '/plugins/aws/ec2/vpcSubnetInstancesPresent.js'), + 'webTierInstanceIamRole' : require(__dirname + '/plugins/aws/ec2/webTierInstanceIamRole.js'), + 'vpnTunnelState' : require(__dirname + '/plugins/aws/ec2/vpnTunnelState.js'), + 'networkAclOutboundTraffic' : require(__dirname + '/plugins/aws/ec2/networkAclOutboundTraffic.js'), + 'networkAclInboundTraffic' : require(__dirname + '/plugins/aws/ec2/networkAclInboundTraffic.js'), + 'outdatedAmiInUse' : require(__dirname + '/plugins/aws/ec2/outdatedAmiInUse.js'), + 'appTierInstanceIamRole' : require(__dirname + '/plugins/aws/ec2/appTierInstanceIamRole.js'), + 'defaultSecurityGroup' : require(__dirname + '/plugins/aws/ec2/defaultSecurityGroup.js'), + 'launchWizardSecurityGroups' : require(__dirname + '/plugins/aws/ec2/launchWizardSecurityGroups'), + 'securityGroupRfc1918' : require(__dirname + '/plugins/aws/ec2/securityGroupRfc1918.js'), + 'elasticIpLimit' : require(__dirname + '/plugins/aws/ec2/elasticIpLimit.js'), + 'unassociatedElasticIp' : require(__dirname + '/plugins/aws/ec2/unassociatedElasticIp.js'), + 'subnetIpAvailability' : require(__dirname + '/plugins/aws/ec2/subnetIpAvailability.js'), + 'excessiveSecurityGroups' : require(__dirname + '/plugins/aws/ec2/excessiveSecurityGroups.js'), + 'vpnGatewayInVpc' : require(__dirname + '/plugins/aws/ec2/vpnGatewayInVpc.js'), + 'internetGatewayInVpc' : require(__dirname + '/plugins/aws/ec2/internetGatewayInVpc.js'), + 'enableDetailedMonitoring' : require(__dirname + '/plugins/aws/ec2/enableDetailedMonitoring.js'), + 'ec2HasTags' : require(__dirname + '/plugins/aws/ec2/ec2HasTags.js'), + 'securityGroupHasTags' : require(__dirname + '/plugins/aws/ec2/securityGroupsHasTags.js'), + 'vpcHasTags' : require(__dirname + '/plugins/aws/ec2/vpcHasTags'), + 'ebsSnapshotHasTags' : require(__dirname + '/plugins/aws/ec2/ebsSnapshotHasTags.js'), + 'networkAclHasTags' : require(__dirname + '/plugins/aws/ec2/networkAclHasTags.js'), + 'ebsVolumeHasTags' : require(__dirname + '/plugins/aws/ec2/ebsVolumeHasTags.js'), + 'openAllPortsProtocolsEgress' : require(__dirname + '/plugins/aws/ec2/openAllPortsProtocolsEgress.js'), + 'defaultSecurityGroupInUse' : require(__dirname + '/plugins/aws/ec2/defaultSecurityGroupInUse.js'), + 'ec2NetworkExposure' : require(__dirname + '/plugins/aws/ec2/ec2NetworkExposure.js'), + 'ec2PrivilegeAnalysis' : require(__dirname + '/plugins/aws/ec2/ec2PrivilegeAnalysis.js'), + + + 'efsCmkEncrypted' : require(__dirname + '/plugins/aws/efs/efsCmkEncrypted.js'), 'efsEncryptionEnabled' : require(__dirname + '/plugins/aws/efs/efsEncryptionEnabled.js'), + 'efsHasTags' : require(__dirname + '/plugins/aws/efs/efsHasTags.js'), + + 'dockerfileTemplateEncrypted' : require(__dirname + '/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.js'), 'ecrRepositoryPolicy' : require(__dirname + '/plugins/aws/ecr/ecrRepositoryPolicy.js'), + 'ecrRepositoryEncrypted' : require(__dirname + '/plugins/aws/ecr/ecrRepositoryEncrypted.js'), 'ecrRepositoryTagImmutability' : require(__dirname + '/plugins/aws/ecr/ecrRepositoryTagImmutability.js'), + 'ecrRepositoryHasTags' : require(__dirname + '/plugins/aws/ecr/ecrRepositoryHasTags.js'), + 'ecrImageVulnerability' : require(__dirname + '/plugins/aws/ecr/ecrImageVulnerability.js'), + + 'managedPlatformUpdates' : require(__dirname + '/plugins/aws/elasticbeanstalk/managedPlatformUpdates.js'), + 'enhancedHealthReporting' : require(__dirname + '/plugins/aws/elasticbeanstalk/enhancedHealthReporting.js'), + 'environmentAccessLogs' : require(__dirname + '/plugins/aws/elasticbeanstalk/environmentAccessLogs.js'), + 'environmentPersistentLogs' : require(__dirname + '/plugins/aws/elasticbeanstalk/environmentPersistentLogs.js'), 'eksKubernetesVersion' : require(__dirname + '/plugins/aws/eks/eksKubernetesVersion.js'), 'eksLoggingEnabled' : require(__dirname + '/plugins/aws/eks/eksLoggingEnabled.js'), 'eksPrivateEndpoint' : require(__dirname + '/plugins/aws/eks/eksPrivateEndpoint.js'), + 'eksSecretsEncrypted' : require(__dirname + '/plugins/aws/eks/eksSecretsEncrypted.js'), 'eksSecurityGroups' : require(__dirname + '/plugins/aws/eks/eksSecurityGroups.js'), + 'eksLatestPlatformVersion' : require(__dirname + '/plugins/aws/eks/eksLatestPlatformVersion.js'), + 'eksClusterHasTags' : require(__dirname + '/plugins/aws/eks/eksClusterHasTags.js'), + 'eksNetworkExposure' : require(__dirname + '/plugins/aws/eks/eksNetworkExposure.js'), + 'eksPrivilegeAnalysis' : require(__dirname + '/plugins/aws/eks/eksPrivilegeAnalysis.js'), + + 'kendraIndexEncrypted' : require(__dirname + '/plugins/aws/kendra/kendraIndexEncrypted.js'), + + 'environmentTemplateEncrypted' : require(__dirname + '/plugins/aws/proton/environmentTemplateEncrypted.js'), + + 'crosszoneLoadBalancing' : require(__dirname + '/plugins/aws/elb/crosszoneLoadBalancing.js'), 'insecureCiphers' : require(__dirname + '/plugins/aws/elb/insecureCiphers.js'), 'elbHttpsOnly' : require(__dirname + '/plugins/aws/elb/elbHttpsOnly.js'), 'elbLoggingEnabled' : require(__dirname + '/plugins/aws/elb/elbLoggingEnabled.js'), 'elbNoInstances' : require(__dirname + '/plugins/aws/elb/elbNoInstances.js'), + 'classicELBInUse' : require(__dirname + '/plugins/aws/elb/classicELBInUse.js'), + 'connectionDrainingEnabled' : require(__dirname + '/plugins/aws/elb/connectionDrainingEnabled.js'), + 'appTierElbSecurity' : require(__dirname + '/plugins/aws/elb/appTierElbSecurity.js'), + 'elbHasTags' : require(__dirname + '/plugins/aws/elb/elbHasTags.js'), + 'elbUnhealthyInstances' : require(__dirname + '/plugins/aws/elb/elbUnhealthyInstances.js'), + + 'elbv2DeletionProtection' : require(__dirname + '/plugins/aws/elbv2/elbv2DeletionProtection.js'), + 'elbv2LoggingEnabled' : require(__dirname + '/plugins/aws/elbv2/elbv2LoggingEnabled.js'), + 'elbv2HttpsOnly' : require(__dirname + '/plugins/aws/elbv2/elbv2HttpsOnly.js'), + 'elbv2NoInstances' : require(__dirname + '/plugins/aws/elbv2/elbv2NoInstances.js'), + 'elbv2WafEnabled' : require(__dirname + '/plugins/aws/elbv2/elbv2WafEnabled.js'), + 'elbv2MinimumTargetInstances' : require(__dirname + '/plugins/aws/elbv2/elbv2MinimumTargetInstances.js'), + 'elbv2UnhealthyInstance' : require(__dirname + '/plugins/aws/elbv2/elbv2UnhealthyInstance.js'), + 'elbv2NlbListenerSecurity' : require(__dirname + '/plugins/aws/elbv2/elbv2NlbListenerSecurity.js'), + 'elbv2DeregistrationDelay' : require(__dirname + '/plugins/aws/elbv2/elbv2DeregistrationDelay.js'), + 'elbv2SslTermination' : require(__dirname + '/plugins/aws/elbv2/elbv2SslTermination.js'), + 'elbv2HasTags' : require(__dirname + '/plugins/aws/elbv2/elbv2HasTags.js'), + 'elbv2DeprecatedSslPolicies' : require(__dirname + '/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.js'), + 'elbv2InsecureCiphers' : require(__dirname + '/plugins/aws/elbv2/elbv2InsecureCiphers.js'), + 'elbv2TLSVersionCipherEnabled' : require(__dirname + '/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.js'), + 'elbv2crosszoneLoadBalancing' : require(__dirname + '/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.js'), + 'elasticacheDefaultPorts' : require(__dirname + '/plugins/aws/elasticache/elasticacheDefaultPorts.js'), + + 'emrClusterLogging' : require(__dirname + '/plugins/aws/emr/emrClusterLogging.js'), + 'emrClusterInVPC' : require(__dirname + '/plugins/aws/emr/emrClusterInVPC.js'), + 'emrEncryptionInTransit' : require(__dirname + '/plugins/aws/emr/emrEncryptionInTransit.js'), + 'emrEncryptionAtRest' : require(__dirname + '/plugins/aws/emr/emrEncryptionAtRest.js'), + 'emrDesiredInstanceType' : require(__dirname + '/plugins/aws/emr/emrDesiredInstanceType.js'), + 'emrInstanceCount' : require(__dirname + '/plugins/aws/emr/emrInstanceCount.js'), + 'emrClusterHasTags' : require(__dirname + '/plugins/aws/emr/emrClusterHasTags.js'), + + 'opensearchClusterStatus' : require(__dirname + '/plugins/aws/opensearch/opensearchClusterStatus.js'), + 'opensearchAccessFromIps' : require(__dirname + '/plugins/aws/opensearch/opensearchAccessFromIps.js'), + 'opensearchCrossAccountAccess' : require(__dirname + '/plugins/aws/opensearch/opensearchCrossAccountAccess'), + 'opensearchEnableAuditLogs' : require(__dirname + '/plugins/aws/opensearch/opensearchEnableAuditLogs'), + 'opensearchDedicatedMasterEnabled' : require(__dirname + '/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.js'), + 'opensearchDesiredInstanceTypes': require(__dirname + '/plugins/aws/opensearch/opensearchDesiredInstanceTypes.js'), + 'opensearchDomainEncryptionEnabled': require(__dirname + '/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.js'), + 'opensearchEncryptedDomain' : require(__dirname + '/plugins/aws/opensearch/opensearchEncryptedDomain.js'), + 'opensearchExposedDomain' : require(__dirname + '/plugins/aws/opensearch/opensearchExposedDomain.js'), + 'opensearchHttpsOnly' : require(__dirname + '/plugins/aws/opensearch/opensearchHttpsOnly.js'), + 'opensearchLoggingEnabled' : require(__dirname + '/plugins/aws/opensearch/opensearchLoggingEnabled.js'), + 'opensearchNodeToNodeEncryption': require(__dirname + '/plugins/aws/opensearch/opensearchNodeToNodeEncryption.js'), + 'opensearchPublicEndpoint' : require(__dirname + '/plugins/aws/opensearch/opensearchPublicEndpoint.js'), + 'opensearchRequireIAMAuth' : require(__dirname + '/plugins/aws/opensearch/opensearchRequireIAMAuth.js'), + 'opensearchTlsVersion' : require(__dirname + '/plugins/aws/opensearch/opensearchTlsVersion.js'), + 'opensearchUpgradeAvailable' : require(__dirname + '/plugins/aws/opensearch/opensearchUpgradeAvailable.js'), + 'opensearchVersion' : require(__dirname + '/plugins/aws/opensearch/opensearchVersion.js'), + 'opensearchZoneAwarenessEnabled': require(__dirname + '/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.js'), + + 'vaultPublicAccess' : require(__dirname + '/plugins/aws/s3glacier/vaultPublicAccess.js'), + 'firehoseEncrypted' : require(__dirname + '/plugins/aws/firehose/firehoseEncrypted.js'), + 'deliveryStreamEncrypted' : require(__dirname + '/plugins/aws/firehose/deliveryStreamEncrypted.js'), + + 'eventBusCrossAccountAccess' : require(__dirname + '/plugins/aws/eventbridge/eventBusCrossAccountAccess.js'), + 'eventBusPublicAccess' : require(__dirname + '/plugins/aws/eventbridge/eventBusPublicAccess.js'), + 'eventsInUse' : require(__dirname + '/plugins/aws/eventbridge/eventsInUse.js'), - 'elbv2LoggingEnabled' : require(__dirname + '/plugins/aws/elbv2/elbv2LoggingEnabled.js'), - 'elbv2HttpsOnly' : require(__dirname + '/plugins/aws/elbv2/elbv2HttpsOnly.js'), - 'elbv2NoInstances' : require(__dirname + '/plugins/aws/elbv2/elbv2NoInstances.js'), - 'elbv2WafEnabled' : require(__dirname + '/plugins/aws/elbv2/elbv2WafEnabled.js'), + 'glueCloudwatchLogsEncrypted' : require(__dirname + '/plugins/aws/glue/glueCloudwatchLogsEncrypted.js'), + 'glueS3EncryptionEnabled' : require(__dirname + '/plugins/aws/glue/glueS3EncryptionEnabled.js'), + 'dataCatalogCmkEncrypted' : require(__dirname + '/plugins/aws/glue/dataCatalogCmkEncrypted.js'), + 'bookmarkEncryptionEnabled' : require(__dirname + '/plugins/aws/glue/bookmarkEncryptionEnabled.js'), + 'dataCatalogEncryptionEnabled' : require(__dirname + '/plugins/aws/glue/dataCatalogEncryptionEnabled.js'), - 'esPublicEndpoint' : require(__dirname + '/plugins/aws/es/esPublicEndpoint.js'), - 'esRequireIAMAuth' : require(__dirname + '/plugins/aws/es/esRequireIAMAuth.js'), - 'esEncryptedDomain' : require(__dirname + '/plugins/aws/es/esEncryptedDomain.js'), - 'esNodeToNodeEncryption' : require(__dirname + '/plugins/aws/es/esNodeToNodeEncryption.js'), - 'esLoggingEnabled' : require(__dirname + '/plugins/aws/es/esLoggingEnabled.js'), - 'esUpgradeAvailable' : require(__dirname + '/plugins/aws/es/esUpgradeAvailable.js'), - 'esHttpsOnly' : require(__dirname + '/plugins/aws/es/esHttpsOnly.js'), + 'imageRecipeVolumeEncrypted' : require(__dirname + '/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.js'), + 'imgBuilderComponentsEncrypted' : require(__dirname + '/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.js'), 'accessKeysExtra' : require(__dirname + '/plugins/aws/iam/accessKeysExtra.js'), 'accessKeysLastUsed' : require(__dirname + '/plugins/aws/iam/accessKeysLastUsed.js'), 'accessKeysRotated' : require(__dirname + '/plugins/aws/iam/accessKeysRotated.js'), 'certificateExpiry' : require(__dirname + '/plugins/aws/iam/certificateExpiry.js'), + 'crossAccountMfaExtIdAccess' : require(__dirname + '/plugins/aws/iam/crossAccountMfaExtIdAccess.js'), 'emptyGroups' : require(__dirname + '/plugins/aws/iam/emptyGroups.js'), + 'groupInlinePolicies' : require(__dirname + '/plugins/aws/iam/groupInlinePolicies.js'), + 'iamMasterManagerRoles' : require(__dirname + '/plugins/aws/iam/iamMasterManagerRoles.js'), 'iamUserAdmins' : require(__dirname + '/plugins/aws/iam/iamUserAdmins.js'), 'iamUserNameRegex' : require(__dirname + '/plugins/aws/iam/iamUserNameRegex.js'), + 'iamUserUnauthorizedToEdit' : require(__dirname + '/plugins/aws/iam/iamUserUnauthorizedToEdit'), + 'iamUserHasTags' : require(__dirname + '/plugins/aws/iam/iamUserHasTags.js'), 'iamRolePolicies' : require(__dirname + '/plugins/aws/iam/iamRolePolicies.js'), + 'iamRoleLastUsed' : require(__dirname + '/plugins/aws/iam/iamRoleLastUsed.js'), 'maxPasswordAge' : require(__dirname + '/plugins/aws/iam/maxPasswordAge.js'), 'minPasswordLength' : require(__dirname + '/plugins/aws/iam/minPasswordLength.js'), 'noUserIamPolicies' : require(__dirname + '/plugins/aws/iam/noUserIamPolicies.js'), @@ -126,37 +377,85 @@ module.exports = { 'passwordRequiresUppercase' : require(__dirname + '/plugins/aws/iam/passwordRequiresUppercase.js'), 'passwordReusePrevention' : require(__dirname + '/plugins/aws/iam/passwordReusePrevention.js'), 'rootAccessKeys' : require(__dirname + '/plugins/aws/iam/rootAccessKeys.js'), + 'rootSigningCertificate' : require(__dirname + '/plugins/aws/iam/rootSigningCertificate.js'), 'rootAccountInUse' : require(__dirname + '/plugins/aws/iam/rootAccountInUse.js'), 'rootHardwareMfa' : require(__dirname + '/plugins/aws/iam/rootHardwareMfa.js'), 'rootMfaEnabled' : require(__dirname + '/plugins/aws/iam/rootMfaEnabled.js'), 'sshKeysRotated' : require(__dirname + '/plugins/aws/iam/sshKeysRotated.js'), + 'trustedCrossAccountRoles' : require(__dirname + '/plugins/aws/iam/trustedCrossAccountRoles.js'), 'usersMfaEnabled' : require(__dirname + '/plugins/aws/iam/usersMfaEnabled.js'), 'usersPasswordAndKeys' : require(__dirname + '/plugins/aws/iam/usersPasswordAndKeys.js'), 'usersPasswordLastUsed' : require(__dirname + '/plugins/aws/iam/usersPasswordLastUsed.js'), 'canaryKeysUsed' : require(__dirname + '/plugins/aws/iam/canaryKeysUsed.js'), + 'iamPoliciesPresent' : require(__dirname + '/plugins/aws/iam/iamPoliciesPresent.js'), + 'iamDbAuthenticationEnabled' : require(__dirname + '/plugins/aws/rds/iamDbAuthenticationEnabled.js'), + 'iamSupportPolicy' : require(__dirname + '/plugins/aws/iam/iamSupportPolicy.js'), + 'iamUserPresent' : require(__dirname + '/plugins/aws/iam/iamUserPresent.js'), + 'rolePolicyUnusedServices' : require(__dirname + '/plugins/aws/iam/rolePolicyUnusedServices.js'), + 'iamUserInUse' : require(__dirname + '/plugins/aws/iam/iamUserInUse.js'), + 'policyAllowsToChangePassword' : require(__dirname + '/plugins/aws/iam/policyAllowsToChangePassword.js'), + 'iamRoleHasTags' : require(__dirname + '/plugins/aws/iam/iamRoleHasTags.js'), + 'passwordPolicyExists' : require(__dirname + '/plugins/aws/iam/passwordPolicyExists.js'), + 'iamUserNotInUse' : require(__dirname + '/plugins/aws/iam/iamUserNotInUse.js'), + 'iamUserWithoutPermissions' : require(__dirname + '/plugins/aws/iam/iamUserWithoutPermissions.js'), + 'kinesisEncrypted' : require(__dirname + '/plugins/aws/kinesis/kinesisEncrypted.js'), - 'firehoseEncrypted' : require(__dirname + '/plugins/aws/firehose/firehoseEncrypted.js'), + 'kinesisDataStreamsEncrypted' : require(__dirname + '/plugins/aws/kinesis/kinesisDataStreamsEncrypted.js'), + 'videostreamDataEncrypted' : require(__dirname + '/plugins/aws/kinesisvideo/videostreamDataEncrypted.js'), + 'kmsKeyRotation' : require(__dirname + '/plugins/aws/kms/kmsKeyRotation.js'), 'kmsScheduledDeletion' : require(__dirname + '/plugins/aws/kms/kmsScheduledDeletion.js'), 'kmsKeyPolicy' : require(__dirname + '/plugins/aws/kms/kmsKeyPolicy.js'), 'kmsDefaultKeyUsage' : require(__dirname + '/plugins/aws/kms/kmsDefaultKeyUsage.js'), + 'kmsAppTierCmk' : require(__dirname + '/plugins/aws/kms/kmsAppTierCmk.js'), + 'kmsGrantLeastPrivilege' : require(__dirname + '/plugins/aws/kms/kmsGrantLeastPrivilege.js'), + 'kmsDuplicateGrants' : require(__dirname + '/plugins/aws/kms/kmsDuplicateGrants.js'), 'rdsAutomatedBackups' : require(__dirname + '/plugins/aws/rds/rdsAutomatedBackups.js'), 'rdsEncryptionEnabled' : require(__dirname + '/plugins/aws/rds/rdsEncryptionEnabled.js'), + 'rdsCmkEncryptionEnabled' : require(__dirname + '/plugins/aws/rds/rdsCmkEncryptionEnabled.js'), 'rdsLoggingEnabled' : require(__dirname + '/plugins/aws/rds/rdsLoggingEnabled.js'), 'rdsPubliclyAccessible' : require(__dirname + '/plugins/aws/rds/rdsPubliclyAccessible.js'), 'rdsRestorable' : require(__dirname + '/plugins/aws/rds/rdsRestorable.js'), 'rdsMultiAz' : require(__dirname + '/plugins/aws/rds/rdsMultiAz.js'), 'rdsSnapshotEncryption' : require(__dirname + '/plugins/aws/rds/rdsSnapshotEncryption.js'), 'rdsMinorVersionUpgrade' : require(__dirname + '/plugins/aws/rds/rdsMinorVersionUpgrade.js'), + 'sqlServerTLSVersion' : require(__dirname + '/plugins/aws/rds/sqlServerTLSVersion'), + 'rdsTransportEncryption' : require(__dirname + '/plugins/aws/rds/rdsTransportEncryption'), + 'rdsDeletionProtectionEnabled' : require(__dirname + '/plugins/aws/rds/rdsDeletionProtectionEnabled.js'), + 'rdsDefaultUsername' : require(__dirname + '/plugins/aws/rds/rdsDefaultUsername.js'), + 'rdsDefaultPort' : require(__dirname + '/plugins/aws/rds/rdsDefaultPort.js'), + 'rdsSnapshotPubliclyAccessible' : require(__dirname + '/plugins/aws/rds/rdsSnapshotPubliclyAccessible.js'), + 'rdsInstanceHasTags' : require(__dirname + '/plugins/aws/rds/rdsInstanceHasTags.js'), + 'rdsPublicSubnet' : require(__dirname + '/plugins/aws/rds/rdsPublicSubnet.js'), + 'mysqlVulnerabilityCheck' : require(__dirname + '/plugins/aws/rds/mysqlVulnerabilityCheck.js'), + 'rdsInstanceGeneration' : require(__dirname + '/plugins/aws/rds/rdsInstanceGeneration.js'), + 'overutilizedRDSInstance' : require(__dirname + '/plugins/aws/rds/overutilizedRDSInstance.js'), + 'idleRDSInstance' : require(__dirname + '/plugins/aws/rds/idleRDSInstance.js'), + + 'redisClusterEncryptionInTransit': require(__dirname + '/plugins/aws/elasticache/redisClusterEncryptionInTransit.js'), + 'elasticacheClusterInVpc' : require(__dirname + '/plugins/aws/elasticache/elasticacheClusterInVpc.js'), + 'elasticacheInstanceGeneration' : require(__dirname + '/plugins/aws/elasticache/elasticacheInstanceGeneration.js'), + 'redisEngineVersions' : require(__dirname + '/plugins/aws/elasticache/redisEngineVersions.js'), + 'elasticacheNodesCount' : require(__dirname + '/plugins/aws/elasticache/elasticacheNodesCount.js'), + 'elasticacheRedisMultiAZ' : require(__dirname + '/plugins/aws/elasticache/elasticacheRedisMultiAZ.js'), + 'idleElastiCacheNode' : require(__dirname + '/plugins/aws/elasticache/idleElastiCacheNode.js'), + 'elasticaheDesiredNodeType' : require(__dirname + '/plugins/aws/elasticache/elasticaheDesiredNodeType.js'), + 'elastiCacheClusterHasTags' : require(__dirname + '/plugins/aws/elasticache/elasticCacheClusterHasTags.js'), 'domainAutoRenew' : require(__dirname + '/plugins/aws/route53/domainAutoRenew.js'), 'domainExpiry' : require(__dirname + '/plugins/aws/route53/domainExpiry.js'), + 'route53InUse' : require(__dirname + '/plugins/aws/route53/route53InUse.js'), 'domainTransferLock' : require(__dirname + '/plugins/aws/route53/domainTransferLock.js'), + 'danglingDnsRecords' : require(__dirname + '/plugins/aws/route53/danglingDnsRecords.js'), + 'privacyProtection' : require(__dirname + '/plugins/aws/route53/privacyProtection.js'), + 'senderPolicyFwInUse' : require(__dirname + '/plugins/aws/route53/senderPolicyFwInUse.js'), + 'senderPolicyFwRecordPresent' : require(__dirname + '/plugins/aws/route53/senderPolicyFwRecordPresent.js'), 'bucketEncryptionInTransit' : require(__dirname + '/plugins/aws/s3/bucketEncryptionInTransit.js'), 'bucketAllUsersPolicy' : require(__dirname + '/plugins/aws/s3/bucketAllUsersPolicy.js'), 'bucketAllUsersAcl' : require(__dirname + '/plugins/aws/s3/bucketAllUsersAcl.js'), + 'bucketPolicyCloudFrontOai' : require(__dirname + '/plugins/aws/s3/bucketPolicyCloudFrontOai.js'), 'bucketVersioning' : require(__dirname + '/plugins/aws/s3/bucketVersioning.js'), 'bucketLogging' : require(__dirname + '/plugins/aws/s3/bucketLogging.js'), 's3Encryption' : require(__dirname + '/plugins/aws/s3/s3Encryption.js'), @@ -164,31 +463,141 @@ module.exports = { 'bucketEncryption' : require(__dirname + '/plugins/aws/s3/bucketEncryption.js'), 'bucketWebsiteEnabled' : require(__dirname + '/plugins/aws/s3/bucketWebsiteEnabled.js'), 'bucketEnforceEncryption' : require(__dirname + '/plugins/aws/s3/bucketEnforceEncryption.js'), - + 'bucketSecureTransportEnabled' : require(__dirname + '/plugins/aws/s3/bucketSecureTransportEnabled.js'), + 'bucketLifecycleConfiguration' : require(__dirname + '/plugins/aws/s3/bucketLifecycleConfiguration'), + 'bucketTransferAcceleration' : require(__dirname + '/plugins/aws/s3/bucketTransferAcceleration'), + 'bucketDnsCompliantName' : require(__dirname + '/plugins/aws/s3/bucketDnsCompliantName.js'), + 'versionedBucketsLC' : require(__dirname + '/plugins/aws/s3/versionedBucketsLC.js'), + 's3BucketHasTags' : require(__dirname + '/plugins/aws/s3/s3BucketHasTags.js'), + 'bucketPolicyCloudFrontOac' : require(__dirname + '/plugins/aws/s3/bucketPolicyCloudFrontOac.js'), + 'bucketMFADeleteEnabled' : require(__dirname + '/plugins/aws/s3/bucketMFADeleteEnabled.js'), + 'objectLevelReadEventLogging' : require(__dirname + '/plugins/aws/s3/objectLevelReadEventLogging.js'), + 'objectLevelWriteEventLogging' : require(__dirname + '/plugins/aws/s3/objectLevelWriteEventLogging.js'), 'notebookDataEncrypted' : require(__dirname + '/plugins/aws/sagemaker/notebookDataEncrypted.js'), 'notebookDirectInternetAccess' : require(__dirname + '/plugins/aws/sagemaker/notebookDirectInternetAccess.js'), + 'notebookInstanceInVpc' : require(__dirname + '/plugins/aws/sagemaker/notebookInstanceInVpc.js'), 'dkimEnabled' : require(__dirname + '/plugins/aws/ses/dkimEnabled.js'), + 'emailMessagesEncrypted' : require(__dirname + '/plugins/aws/ses/emailMessagesEncrypted.js'), + 'topicEncrypted' : require(__dirname + '/plugins/aws/sns/topicEncrypted.js'), 'topicPolicies' : require(__dirname + '/plugins/aws/sns/topicPolicies.js'), + 'topicCmkEncrypted' : require(__dirname + '/plugins/aws/sns/topicCmkEncrypted.js'), + 'snsCrossAccount' : require(__dirname + '/plugins/aws/sns/snsCrossAccount.js'), + 'snsValidSubscribers' : require(__dirname + '/plugins/aws/sns/snsValidSubscribers.js'), + 'snsTopicHasTags' : require(__dirname + '/plugins/aws/sns/snsTopicHasTags.js'), + 'snsTopicNoHttpPolicy' : require(__dirname + '/plugins/aws/sns/snsTopicNoHttpPolicy.js'), + 'snsSubscriptionHTTPSonly' : require(__dirname + '/plugins/aws/sns/snsSubscriptionHTTPSonly.js'), + 'sqsCrossAccount' : require(__dirname + '/plugins/aws/sqs/sqsCrossAccount.js'), + 'sqsHasTags' : require(__dirname + '/plugins/aws/sqs/sqsHasTags.js'), + 'sqsDeadLetterQueue' : require(__dirname + '/plugins/aws/sqs/sqsDeadLetterQueue.js'), 'sqsEncrypted' : require(__dirname + '/plugins/aws/sqs/sqsEncrypted.js'), + 'sqsPublicAccess' : require(__dirname + '/plugins/aws/sqs/sqsPublicAccess.js'), + 'queueUnprocessedMessages' : require(__dirname + '/plugins/aws/sqs/queueUnprocessedMessages.js'), + 'sqsEncryptionEnabled' : require(__dirname + '/plugins/aws/sqs/sqsEncryptionEnabled.js'), 'ssmEncryptedParameters' : require(__dirname + '/plugins/aws/ssm/ssmEncryptedParameters.js'), 'ssmActiveOnAllInstances' : require(__dirname + '/plugins/aws/ssm/ssmActiveOnAllInstances.js'), 'ssmAgentLatestVersion' : require(__dirname + '/plugins/aws/ssm/ssmAgentLatestVersion.js'), + 'ssmAgentAutoUpdateEnabled' : require(__dirname + '/plugins/aws/ssm/ssmAgentAutoUpdateEnabled'), + 'ssmDocumentPublicAccess' : require(__dirname + '/plugins/aws/ssm/ssmDocumentPublicAccess'), + 'ssmManagedInstances' : require(__dirname + '/plugins/aws/ssm/ssmManagedInstances'), + 'ssmSessionDuration' : require(__dirname + '/plugins/aws/ssm/ssmSessionDuration'), + + 'ledgerEncrypted' : require(__dirname + '/plugins/aws/qldb/ledgerEncrypted'), + 'ledgerHasTags' : require(__dirname + '/plugins/aws/qldb/ledgerHasTags'), + 'ledgerDeletionProtection' : require(__dirname + '/plugins/aws/qldb/ledgerDeletionProtection'), + 'lambdaAdminPrivileges' : require(__dirname + '/plugins/aws/lambda/lambdaAdminPrivileges.js'), + 'envVarsClientSideEncryption' : require(__dirname + '/plugins/aws/lambda/envVarsClientSideEncryption.js'), 'lambdaOldRuntimes' : require(__dirname + '/plugins/aws/lambda/lambdaOldRuntimes.js'), 'lambdaVpcConfig' : require(__dirname + '/plugins/aws/lambda/lambdaVpcConfig.js'), 'lambdaPublicAccess' : require(__dirname + '/plugins/aws/lambda/lambdaPublicAccess.js'), + 'lambdaCodeSigningEnabled' : require(__dirname + '/plugins/aws/lambda/lambdaCodeSigningEnabled.js'), 'lambdaLogGroups' : require(__dirname + '/plugins/aws/lambda/lambdaLogGroups.js'), + 'lambdaTracingEnabled' : require(__dirname + '/plugins/aws/lambda/lambdaTracingEnabled.js'), + 'lambdaHasTags' : require(__dirname + '/plugins/aws/lambda/lambdaHasTags.js'), + 'lambdaDeadLetterQueue' : require(__dirname + '/plugins/aws/lambda/lambdaDeadLetterQueue.js'), + 'lambdaEnhancedMonitoring' : require(__dirname + '/plugins/aws/lambda/lambdaEnhancedMonitoring.js'), + 'lambdaUniqueExecutionRole' : require(__dirname + '/plugins/aws/lambda/lambdaUniqueExecutionRole.js'), + 'lambdaNetworkExposure' : require(__dirname + '/plugins/aws/lambda/lambdaNetworkExposure.js'), + 'lambdaPrivilegeAnalysis' : require(__dirname + '/plugins/aws/lambda/lambdaPrivilegeAnalysis.js'), + + 'webServerPublicAccess' : require(__dirname + '/plugins/aws/mwaa/webServerPublicAccess.js'), + 'environmentAdminPrivileges' : require(__dirname + '/plugins/aws/mwaa/environmentAdminPrivileges.js'), + 'environmentDataEncrypted' : require(__dirname + '/plugins/aws/mwaa/environmentDataEncrypted.js'), + + 'neptuneDBInstanceEncrypted' : require(__dirname + '/plugins/aws/neptune/neptuneDBInstanceEncrypted.js'), + 'neptuneDBMinorVersionUpgrade' : require(__dirname + '/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.js'), + 'neptuneInstanceBackupRetention': require(__dirname + '/plugins/aws/neptune/neptuneInstanceBackupRetention.js'), + 'neptuneDBMultiAz' : require(__dirname + '/plugins/aws/neptune/neptuneDBMultiAz.js'), + 'neptuneDbDeletionProtection' : require(__dirname + '/plugins/aws/neptune/neptuneDbDeletionProtection.js'), + 'neptuneDBIamAuth' : require(__dirname + '/plugins/aws/neptune/neptuneDBIamAuth.js'), 'monitoringMetrics' : require(__dirname + '/plugins/aws/cloudwatchlogs/monitoringMetrics.js'), + 'customerProfilesDomainEncrypted' : require(__dirname + '/plugins/aws/connect/customerProfilesDomainEncrypted.js'), + 'wisdomDomainEncrypted' : require(__dirname + '/plugins/aws/connect/wisdomDomainEncrypted.js'), + 'voiceIdDomainEncrypted' : require(__dirname + '/plugins/aws/connect/voiceIdDomainEncrypted.js'), + + 'logRetentionPeriod' : require(__dirname + '/plugins/aws/cloudwatchlogs/logRetentionPeriod.js'), + 'logGroupsEncrypted' : require(__dirname + '/plugins/aws/cloudwatchlogs/logGroupsEncrypted.js'), + + 'vpcFlowLogsMetricAlarm' : require(__dirname + '/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.js'), + + 'timestreamDatabaseEncrypted' : require(__dirname + '/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.js'), + + 'mqAutoMinorVersionUpgrade' : require(__dirname + '/plugins/aws/mq/mqAutoMinorVersionUpgrade.js'), + 'mqLogExports' : require(__dirname + '/plugins/aws/mq/mqLogExports.js'), + 'mqDeploymentMode' : require(__dirname + '/plugins/aws/mq/mqDeploymentMode.js'), + 'mqDesiredInstanceType' : require(__dirname + '/plugins/aws/mq/mqDesiredInstanceType.js'), + 'mqBrokerEncrypted' : require(__dirname + '/plugins/aws/mq/mqBrokerEncrypted.js'), + 'mqLatestEngineVersion' : require(__dirname + '/plugins/aws/mq/mqLatestEngineVersion.js'), + 'mqBrokerPublicAccess' : require(__dirname + '/plugins/aws/mq/mqBrokerPublicAccess.js'), + + 'memorydbClusterEncrypted' : require(__dirname + '/plugins/aws/memorydb/memorydbClusterEncrypted.js'), + + 'mskClusterCBEncryption' : require(__dirname + '/plugins/aws/msk/mskClusterCBEncryption.js'), + + 'mskClusterPublicAccess' : require(__dirname + '/plugins/aws/msk/mskClusterPublicAccess.js'), + 'mskClusterUnauthAccess' : require(__dirname + '/plugins/aws/msk/mskClusterUnauthAccess.js'), + 'mskClusterEncryptionAtRest' : require(__dirname + '/plugins/aws/msk/mskClusterEncryptionAtRest.js'), + 'mskClusterEncryptionInTransit' : require(__dirname + '/plugins/aws/msk/mskClusterEncryptionInTransit.js'), + + 'auditLoggingEnabled' : require(__dirname + '/plugins/aws/redshift/auditLoggingEnabled.js'), + 'redshiftClusterCmkEncrypted' : require(__dirname + '/plugins/aws/redshift/redshiftClusterCmkEncrypted.js'), 'redshiftEncryptionEnabled' : require(__dirname + '/plugins/aws/redshift/redshiftEncryptionEnabled.js'), 'redshiftPubliclyAccessible' : require(__dirname + '/plugins/aws/redshift/redshiftPubliclyAccessible.js'), + 'redshiftAllowVersionUpgrade' : require(__dirname + '/plugins/aws/redshift/redshiftAllowVersionUpgrade.js'), + 'redshiftSSLEnabled' : require(__dirname + '/plugins/aws/redshift/redshiftSSLEnabled.js'), + 'redshiftClusterInVpc' : require(__dirname + '/plugins/aws/redshift/redshiftClusterInVpc.js'), + 'redshiftClusterDefaultPort' : require(__dirname + '/plugins/aws/redshift/redshiftClusterDefaultPort.js'), + 'redshiftClusterMasterUsername' : require(__dirname + '/plugins/aws/redshift/redshiftClusterMasterUsername.js'), + 'snapshotRetentionPeriod' : require(__dirname + '/plugins/aws/redshift/snapshotRetentionPeriod.js'), + 'userActivityLoggingEnabled' : require(__dirname + '/plugins/aws/redshift/userActivityLoggingEnabled.js'), + 'redshiftNodesCount' : require(__dirname + '/plugins/aws/redshift/redshiftNodesCount.js'), + 'redshiftUnusedReservedNodes' : require(__dirname + '/plugins/aws/redshift/redshiftUnusedReservedNodes.js'), + 'redshiftDesiredNodeType' : require(__dirname + '/plugins/aws/redshift/redshiftDesiredNodeType.js'), + 'underutilizedRedshiftCluster' : require(__dirname + '/plugins/aws/redshift/underutilizedRedshiftCluster.js'), + + 'redisClusterEncryptionAtRest' : require(__dirname + '/plugins/aws/elasticache/redisClusterEncryptionAtRest.js'), + 'reservedNodePaymentPending.js' : require(__dirname + '/plugins/aws/elasticache/reservedNodePaymentPending.js'), + 'unusedElastiCacheReservedNode' : require(__dirname + '/plugins/aws/elasticache/unusedElastiCacheReservedNode.js'), + 'reservedNodePaymentFailed' : require(__dirname + '/plugins/aws/elasticache/reservedNodePaymentFailed.js'), + 'reservedNodeLeaseExpiration' : require(__dirname + '/plugins/aws/elasticache/reservedNodeLeaseExpiration.js'), 'transferLoggingEnabled' : require(__dirname + '/plugins/aws/transfer/transferLoggingEnabled.js'), + 'transferPrivateLinkInUse' : require(__dirname + '/plugins/aws/transfer/transferPrivateLinkInUse.js'), + + 'serviceEncrypted' : require(__dirname + '/plugins/aws/apprunner/serviceEncrypted.js'), + + 'secretRotationEnabled' : require(__dirname + '/plugins/aws/secretsmanager/secretRotationEnabled.js'), + 'secretsManagerEncrypted' : require(__dirname + '/plugins/aws/secretsmanager/secretsManagerEncrypted.js'), + 'secretsManagerInUse' : require(__dirname + '/plugins/aws/secretsmanager/secretsManagerInUse.js'), + 'secretHasTags' : require(__dirname + '/plugins/aws/secretsmanager/secretHasTags.js'), + + 'finspaceEnvironmentEncrypted' : require(__dirname + '/plugins/aws/finspace/finspaceEnvironmentEncrypted.js'), 'shieldAdvancedEnabled' : require(__dirname + '/plugins/aws/shield/shieldAdvancedEnabled.js'), 'shieldEmergencyContacts' : require(__dirname + '/plugins/aws/shield/shieldEmergencyContacts.js'), @@ -196,10 +605,119 @@ module.exports = { 'enableAllFeatures' : require(__dirname + '/plugins/aws/organizations/enableAllFeatures.js'), 'organizationInvite' : require(__dirname + '/plugins/aws/organizations/organizationInvite.js'), + 'guardDutyEnabled' : require(__dirname + '/plugins/aws/guardduty/guarddutyEnabled.js'), + 'eksProtectionEnabled' : require(__dirname + '/plugins/aws/guardduty/eksProtectionEnabled.js'), 'guardDutyMaster' : require(__dirname + '/plugins/aws/guardduty/guarddutyMaster.js'), + 'noActiveFindings' : require(__dirname + '/plugins/aws/guardduty/noActiveFindings'), + 's3ProtectionEnabled' : require(__dirname + '/plugins/aws/guardduty/s3ProtectionEnabled.js'), + 'rdsProtectionEnabled' : require(__dirname + '/plugins/aws/guardduty/rdsProtectionEnabled.js'), + 'exportedFindingsEncrypted' : require(__dirname + '/plugins/aws/guardduty/exportedFindingsEncrypted.js'), + 'lambdaProtectionEnabled' : require(__dirname + '/plugins/aws/guardduty/lambdaProtectionEnabled.js'), + + 'workspacesVolumeEncryption' : require(__dirname + '/plugins/aws/workspaces/workspacesVolumeEncryption.js'), + 'workSpacesHealthyInstances' : require(__dirname + '/plugins/aws/workspaces/workSpacesHealthyInstances.js'), + 'workspacesIpAccessControl' : require(__dirname + '/plugins/aws/workspaces/workspacesIpAccessControl.js'), + 'unusedWorkspaces' : require(__dirname + '/plugins/aws/workspaces/unusedWorkspaces.js'), + 'workspacesInstanceCount' : require(__dirname + '/plugins/aws/workspaces/workspacesInstanceCount.js'), + 'workspacesDesiredBundleType' : require(__dirname + '/plugins/aws/workspaces/workspacesDesiredBundleType.js'), + + 'xrayEncryptionEnabled' : require(__dirname + '/plugins/aws/xray/xrayEncryptionEnabled.js'), + + 'codebuildValidSourceProviders' : require(__dirname + '/plugins/aws/codebuild/codebuildValidSourceProviders.js'), + 'projectArtifactsEncrypted' : require(__dirname + '/plugins/aws/codebuild/projectArtifactsEncrypted.js'), + 'buildProjectEnvPriviligedMode' : require(__dirname + '/plugins/aws/codebuild/buildProjectEnvPriviligedMode.js'), + 'codebuildProjectLoggingEnabled': require(__dirname + '/plugins/aws/codebuild/codebuildProjectLoggingEnabled.js'), + + 'codestarValidRepoProviders' : require(__dirname + '/plugins/aws/codestar/codestarValidRepoProviders.js'), + 'codestarHasTags' : require(__dirname + '/plugins/aws/codestar/codestarHasTags.js'), + + 'pipelineArtifactsEncrypted' : require(__dirname + '/plugins/aws/codepipeline/pipelineArtifactsEncrypted.js'), + + 'dataStoreEncrypted' : require(__dirname + '/plugins/aws/healthlake/dataStoreEncrypted.js'), + 'dataStoreHasTags' : require(__dirname + '/plugins/aws/healthlake/dataStoreHasTags.js'), + + 'codeartifactDomainEncrypted' : require(__dirname + '/plugins/aws/codeartifact/codeartifactDomainEncrypted.js'), + + 'auditmanagerDataEncrypted' : require(__dirname + '/plugins/aws/auditmanager/auditmanagerDataEncrypted.js'), + + 'flowEncrypted' : require(__dirname + '/plugins/aws/appflow/flowEncrypted.js'), + + 'pipelineDataEncrypted' : require(__dirname + '/plugins/aws/elastictranscoder/pipelineDataEncrypted.js'), + 'jobOutputsEncrypted' : require(__dirname + '/plugins/aws/elastictranscoder/jobOutputsEncrypted.js'), + + 'translateJobOutputEncrypted' : require(__dirname + '/plugins/aws/translate/translateJobOutputEncrypted.js'), + + 'databrewJobOutputEncrypted' : require(__dirname + '/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.js'), + + 'networkMemberDataEncrypted' : require(__dirname + '/plugins/aws/managedblockchain/networkMemberDataEncrypted.js'), + 'networkMemberCloudwatchLogs' : require(__dirname + '/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.js'), + + 'docdbClusterEncrypted' : require(__dirname + '/plugins/aws/documentDB/docdbClusterEncrypted.js'), + 'docDbHasTags' : require(__dirname + '/plugins/aws/documentDB/docDbHasTags.js'), + 'docdbDeletionProtectionEnabled': require(__dirname + '/plugins/aws/documentDB/docdbDeletionProtectionEnabled.js'), + 'docdbClusterBackupRetention' : require(__dirname + '/plugins/aws/documentDB/docdbClusterBackupRetention.js'), + 'docdbCertificateRotated' : require(__dirname + '/plugins/aws/documentDB/docdbCertificateRotated.js'), + 'docdbClusterProfilerEnabled' : require(__dirname + '/plugins/aws/documentDB/docdbClusterProfilerEnabled.js'), + 'docdbEncryptionInTransit' : require(__dirname + '/plugins/aws/documentDB/docdbEncryptionInTransit.js'), + 'docdbAuditLoggingEnabled' : require(__dirname + '/plugins/aws/documentDB/docdbAuditLoggingEnabled.js'), + + 'instanceMediaStreamsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceMediaStreamsEncrypted.js'), + 'instanceTranscriptsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceTranscriptsEncrypted.js'), + 'instanceReportsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceReportsEncrypted.js'), + 'instanceCallRecordingEncrypted': require(__dirname + '/plugins/aws/connect/instanceCallRecordingEncrypted.js'), + 'instanceAttachmentsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceAttachmentsEncrypted.js'), + + 'backupVaultEncrypted' : require(__dirname + '/plugins/aws/backup/backupVaultEncrypted.js'), + 'backupResourceProtection' : require(__dirname + '/plugins/aws/backup/backupResourceProtection.js'), + 'backupInUseForRDSSnapshots' : require(__dirname + '/plugins/aws/backup/backupInUseForRDSSnapshots.js'), + 'backupNotificationEnabled' : require(__dirname + '/plugins/aws/backup/backupNotificationEnabled.js'), + 'backupDeletionProtection' : require(__dirname + '/plugins/aws/backup/backupDeletionProtection.js'), + 'compliantLifecycleConfigured' : require(__dirname + '/plugins/aws/backup/compliantLifecycleConfigured.js'), + 'backupVaultHasTags' : require(__dirname + '/plugins/aws/backup/backupVaultHasTags.js'), + 'backupVaultPolicies' : require(__dirname + '/plugins/aws/backup/backupVaultPolicies.js'), + + 'equipmentdatasetEncrypted' : require(__dirname + '/plugins/aws/lookout/equipmentdatasetEncrypted.js'), + + 'iotsitewiseDataEncrypted' : require(__dirname + '/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.js'), + + 'trackerDataEncrypted' : require(__dirname + '/plugins/aws/location/trackerDataEncrypted.js'), + 'geoCollectionDataEncrypted' : require(__dirname + '/plugins/aws/location/geoCollectionDataEncrypted.js'), + + 'modelDataEncrypted' : require(__dirname + '/plugins/aws/lookout/modelDataEncrypted.js'), + 'anomalyDetectorEncrypted' : require(__dirname + '/plugins/aws/lookout/anomalyDetectorEncrypted.js'), + + 'lexAudioLogsEncrypted' : require(__dirname + '/plugins/aws/lex/lexAudioLogsEncrypted.js'), - 'xrayEncryptionEnabled' : require(__dirname + '/plugins/aws/xray/xrayEncryptionEnabled.js') + 'forecastDatasetEncrypted' : require(__dirname + '/plugins/aws/forecast/forecastDatasetEncrypted.js'), + 'datasetExportEncrypted' : require(__dirname + '/plugins/aws/forecast/datasetExportEncrypted.js'), + + 'fsxFileSystemEncrypted' : require(__dirname + '/plugins/aws/fsx/fsxFileSystemEncrypted.js'), + + 'wafv2InUse' : require(__dirname + '/plugins/aws/wafv2/wafv2InUse.js'), + 'wafv2WebAclLoggingEnabled' : require(__dirname + '/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.js'), + 'wafv2CloudwatchMetricsEnabled' : require(__dirname + '/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.js'), + 'aclRulesDefaultAction' : require(__dirname + '/plugins/aws/wafv2/aclRulesDefaultAction.js'), + + 'wafInUse' : require(__dirname + '/plugins/aws/waf/wafInUse.js'), + + 'fraudDetectorDataEncrypted' : require(__dirname + '/plugins/aws/frauddetector/fraudDetectorDataEncrypted.js'), + + 'ecsContainerInsightsEnabled' : require(__dirname + '/plugins/aws/ecs/ecsContainerInsightsEnabled.js'), + 'ecsClustersHaveTags' : require(__dirname + '/plugins/aws/ecs/ecsClustersHaveTags.js'), + 'ecsClusterWithActiveTask' : require(__dirname + '/plugins/aws/ecs/ecsClusterWithActiveTask.js'), + 'ecsClusterActiveService' : require(__dirname + '/plugins/aws/ecs/ecsClusterActiveService.js'), + 'ecsServicePublicIpDisabled' : require(__dirname + '/plugins/aws/ecs/ecsServicePublicIpDisabled.js'), + 'ecsFargatePlatformVersion' : require(__dirname + '/plugins/aws/ecs/ecsFargatePlatformVersion.js'), + + 'cognitoHasWafEnabled' : require(__dirname + '/plugins/aws/cognito/cognitoHasWafEnabled.js'), + 'cognitoMFAEnabled' : require(__dirname + '/plugins/aws/cognito/cognitoMFAEnabled.js'), + + 'opensearchCollectionCmkEncrypted': require(__dirname + '/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.js'), + 'opensearchCollectionPublicAccess': require(__dirname + '/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.js'), + + 'securityHubEnabled' : require(__dirname + '/plugins/aws/securityhub/securityHubEnabled.js'), + 'securityHubActiveFindings' : require(__dirname + '/plugins/aws/securityhub/securityHubActiveFindings.js'), }, azure : { 'fileServiceEncryption' : require(__dirname + '/plugins/azure/storageaccounts/fileServiceEncryption.js'), @@ -211,15 +729,62 @@ module.exports = { 'storageAccountsAADEnabled' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountsAADEnabled.js'), 'blobServiceEncryption' : require(__dirname + '/plugins/azure/storageaccounts/blobServiceEncryption.js'), 'trustedMsAccessEnabled' : require(__dirname + '/plugins/azure/storageaccounts/trustedMsAccessEnabled.js'), + 'blobSoftDeletionEnabled' : require(__dirname + '/plugins/azure/storageaccounts/blobSoftDeletionEnabled.js'), + 'storageAccountsTlsVersion' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountsTlsVersion.js'), + 'storageAccountHasTags' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountHasTags.js'), + 'storageAccountPrivateEndpoint' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js'), + 'infrastructureEncryption' : require(__dirname + '/plugins/azure/storageaccounts/infrastructureEncryption.js'), + 'queueServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/queueServiceLoggingEnabled.js'), + 'tableServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/tableServiceLoggingEnabled.js'), + 'blobServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/blobServiceLoggingEnabled.js'), + 'storageAccountPublicNetworkAccess': require(__dirname + '/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js'), 'blobContainersPrivateAccess' : require(__dirname + '/plugins/azure/blobservice/blobContainersPrivateAccess.js'), 'blobServiceImmutable' : require(__dirname + '/plugins/azure/blobservice/blobServiceImmutable.js'), + 'blobContainersCmkEncrypted' : require(__dirname + '/plugins/azure/blobservice/blobContainersCmkEncrypted.js'), 'fileServiceAllAccessAcl' : require(__dirname + '/plugins/azure/fileservice/fileServiceAllAccessAcl.js'), 'tableServiceAllAccessAcl' : require(__dirname + '/plugins/azure/tableservice/tableServiceAllAccessAcl.js'), 'queueServiceAllAccessAcl' : require(__dirname + '/plugins/azure/queueservice/queueServiceAllAccessAcl.js'), + 'externalNetworkAccess' : require(__dirname + '/plugins/azure/containerapps/externalNetworkAccess.js'), + 'containerAppManagedIdentity' : require(__dirname + '/plugins/azure/containerapps/containerAppManagedIdentity.js'), + 'containerAppAuthEnabled' : require(__dirname + '/plugins/azure/containerapps/containerAppAuthEnabled.js'), + 'containerAppVolumeMount' : require(__dirname + '/plugins/azure/containerapps/containerAppVolumeMount.js'), + 'containerAppHttpsOnly' : require(__dirname + '/plugins/azure/containerapps/containerAppHttpsOnly.js'), + 'containerAppHasTags' : require(__dirname + '/plugins/azure/containerapps/containerAppHasTags.js'), + 'containerAppIPRestriction' : require(__dirname + '/plugins/azure/containerapps/containerAppIPRestriction.js'), + + 'mlWorkspaceHBI' : require(__dirname + '/plugins/azure/machinelearning/mlWorkspaceHBI.js'), + 'workspacePublicAccessDisabled' : require(__dirname + '/plugins/azure/machinelearning/workspacePublicAccessDisabled.js'), + 'mlWorkspaceCMKEncrypted' : require(__dirname + '/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.js'), + 'workspaceLoggingEnabled' : require(__dirname + '/plugins/azure/machinelearning/workspaceLoggingEnabled.js'), + 'mlRegistryHasTags' : require(__dirname + '/plugins/azure/machinelearning/mlRegistryHasTags.js'), + 'mlWorkspaceHasTags' : require(__dirname + '/plugins/azure/machinelearning/mlWorkspaceHasTags.js'), + 'mlRegistryPublicAccess' : require(__dirname + '/plugins/azure/machinelearning/mlRegistryPublicAccess.js'), + + + 'minimumTlsVersion' : require(__dirname + '/plugins/azure/redisCache/minimumTlsVersion.js'), + 'sslAccessOnlyEnabled' : require(__dirname + '/plugins/azure/redisCache/sslAccessOnlyEnabled.js'), + 'redisCacheHasTags' : require(__dirname + '/plugins/azure/redisCache/redisCacheHasTags.js'), + 'redisCachePrivateEndpoint' : require(__dirname + '/plugins/azure/redisCache/redisCachePrivateEndpoint.js'), + 'redisCacheManagedIdentity' : require(__dirname + '/plugins/azure/redisCache/redisCacheManagedIdentity.js'), + 'redisCacheDiagnosticLogs' : require(__dirname + '/plugins/azure/redisCache/redisCacheDiagnosticLogs.js'), + 'redisCacheScheduledUpdates' : require(__dirname + '/plugins/azure/redisCache/redisCacheScheduledUpdates.js'), + 'redisVersion' : require(__dirname + '/plugins/azure/redisCache/redisVersion.js'), + 'redisCacheVNetIntegrated' : require(__dirname + '/plugins/azure/redisCache/redisCacheVNetIntegrated.js'), + 'multipleSubnets' : require(__dirname + '/plugins/azure/virtualnetworks/multipleSubnets.js'), + 'ddosStandardProtectionEnabled' : require(__dirname + '/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.js'), + 'noNetworkGatewaysInUse' : require(__dirname + '/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.js'), + 'virtualNetworkPeering' : require(__dirname + '/plugins/azure/virtualnetworks/virtualNetworkPeering.js'), + 'noGatewayConnections' : require(__dirname + '/plugins/azure/virtualnetworks/noGatewayConnections.js'), + 'managedNatGateway' : require(__dirname + '/plugins/azure/virtualnetworks/managedNatGateway.js'), + 'virtualNetworkHasTags' : require(__dirname + '/plugins/azure/virtualnetworks/virtualNetworkHasTags.js'), + 'virtualNetworkFlowLogs' : require(__dirname + '/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.js'), + 'routeTableHasTags' : require(__dirname + '/plugins/azure/virtualnetworks/routeTableHasTags.js'), + 'virtualNetworksLoggingEnabled' : require(__dirname + '/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.js'), + 'publicIpDdosProtectionEnabled' : require(__dirname + '/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.js'), 'vmInstanceLimit' : require(__dirname + '/plugins/azure/virtualmachines/vmInstanceLimit.js'), 'classicInstances' : require(__dirname + '/plugins/azure/virtualmachines/classicInstances.js'), @@ -229,15 +794,56 @@ module.exports = { 'vmEndpointProtection' : require(__dirname + '/plugins/azure/virtualmachines/vmEndpointProtection.js'), 'vmAutoUpdateEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmAutoUpdateEnabled.js'), 'vmAvailabilitySetEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmAvailabilitySetEnabled.js'), - 'scaleSetMultiAz' : require(__dirname + '/plugins/azure/virtualmachines/scaleSetMultiAz.js'), - 'scaleSetAutoscaleEnabled' : require(__dirname + '/plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.js'), 'vmAvailabilitySetLimit' : require(__dirname + '/plugins/azure/virtualmachines/vmAvailabilitySetLimit.js'), + 'vmDailyBackupRetention' : require(__dirname + '/plugins/azure/virtualmachines/vmDailyBackupRetention.js'), + 'vmBackupsEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmBackupsEnabled.js'), + 'premiumSsdDisabled' : require(__dirname + '/plugins/azure/virtualmachines/premiumSsdDisabled.js'), + 'vmManagedDisks' : require(__dirname + '/plugins/azure/virtualmachines/vmManagedDisks.js'), + 'acceleratedNetworkingEnabled' : require(__dirname + '/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.js'), + 'passwordAuthDisabled' : require(__dirname + '/plugins/azure/virtualmachines/passwordAuthDisabled.js'), + 'approvedVmImage' : require(__dirname + '/plugins/azure/virtualmachines/approvedVmImage.js'), + 'noUnattachedDisks' : require(__dirname + '/plugins/azure/virtualmachines/noUnattachedDisks.js'), + 'instantRestoreRetention' : require(__dirname + '/plugins/azure/virtualmachines/instantRestoreRetention.js'), + 'desiredSkuSize' : require(__dirname + '/plugins/azure/virtualmachines/desiredSkuSize.js'), + 'approvedVmExtension' : require(__dirname + '/plugins/azure/virtualmachines/approvedVmExtension.js'), + 'guestLevelDiagnosticsEnabled' : require(__dirname + '/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js'), + 'oldVmDiskSnapshots' : require(__dirname + '/plugins/azure/virtualmachines/oldVmDiskSnapshots.js'), + 'vmAdAuthenticationEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js'), + 'performanceDiagnosticsEnabled' : require(__dirname + '/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.js'), + 'vmBootDiagnosticsEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.js'), + 'diskByokEncryptionEnabled' : require(__dirname + '/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js'), + 'vmImageHasTags' : require(__dirname + '/plugins/azure/virtualmachines/vmImageHasTags'), + 'vmHasTags' : require(__dirname + '/plugins/azure/virtualmachines/vmHasTags.js'), + 'vmDiskHasTags' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskHasTags.js'), + 'snapshotHasTags' : require(__dirname + '/plugins/azure/virtualmachines/snapshotHasTags.js'), + 'unattachedDiskWithDefaultEncryption': require(__dirname + '/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.js'), + 'unAttachedDiskByokEncryptionEnabled': require(__dirname + '/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js'), + 'snapshotPublicAccessDisabled' : require(__dirname + '/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.js'), + 'snapshotByokEncryptionEnabled' : require(__dirname + '/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.js'), + 'systemAssignedIdentityEnabled' : require(__dirname + '/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.js'), + 'vmWindowsAntiMalwareExtension' : require(__dirname + '/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js'), + 'vmSecurityType' : require(__dirname + '/plugins/azure/virtualmachines/vmSecurityType.js'), + 'vmVTPMEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmVTPMEnabled.js'), + 'vmSecureBootEnabled' : require(__dirname + '/plugins/azure/virtualmachines/vmSecureBootEnabled.js'), + 'vmDiskDeleteConfig' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskDeleteConfig.js'), + 'vmDiskDoubleEncryption' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskDoubleEncryption.js'), + 'vmEncryptionAtHost' : require(__dirname + '/plugins/azure/virtualmachines/vmEncryptionAtHost.js'), + 'vmDiskCMKRotation' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskCMKRotation.js'), + 'vmDiskPublicAccess' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskPublicAccess.js'), + 'computeGalleryRbacSharing' : require(__dirname + '/plugins/azure/virtualmachines/computeGalleryRbacSharing.js'), + 'vmPrivilegeAnalysis' : require(__dirname + '/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js'), + 'vmNetworkExposure' : require(__dirname + '/plugins/azure/virtualmachines/vmNetworkExposure.js'), + + 'bastionHostExists' : require(__dirname + '/plugins/azure/bastion/bastionHostExists.js'), + 'bastionHostDiagnosticLogs' : require(__dirname + '/plugins/azure/bastion/bastionHostDiagnosticLogs.js'), + 'bastionHostHasTags' : require(__dirname + '/plugins/azure/bastion/bastionHostHasTags.js'), - 'lbLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/monitor/lbLogAnalyticsEnabled.js'), - 'kvLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/monitor/kvLogAnalyticsEnabled.js'), - 'nsgLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/monitor/nsgLogAnalyticsEnabled.js'), 'logProfileArchiveData' : require(__dirname + '/plugins/azure/monitor/logProfileArchiveData.js'), - 'logProfileRetentionPolicy' : require(__dirname + '/plugins/azure/monitor/logProfileRetentionPolicy.js'), + 'logAnalyticsWorkspacePublic' : require(__dirname + '/plugins/azure/monitor/logAnalyticsWorkspacePublic.js'), + 'monitorLogsEnabled' : require(__dirname + '/plugins/azure/monitor/monitorLogsEnabled.js'), + 'diagnosticsCapturedCategories' : require(__dirname + '/plugins/azure/monitor/diagnosticsCapturedCategories.js'), + 'diagnosticsSettingsEnabled' : require(__dirname + '/plugins/azure/monitor/diagnosticsSettingsEnabled.js'), + 'resourceAppropriateSKU' : require(__dirname + '/plugins/azure/monitor/monitorResourceSku.js'), 'securityPolicyAlertsEnabled' : require(__dirname + '/plugins/azure/logalerts/securityPolicyAlertsEnabled.js'), 'nsgLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/nsgLoggingEnabled.js'), @@ -246,39 +852,73 @@ module.exports = { 'securitySolutionLogging' : require(__dirname + '/plugins/azure/logalerts/securitySolutionLogging.js'), 'nsgRuleLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/nsgRuleLoggingEnabled.js'), 'policyAssignmentLogging' : require(__dirname + '/plugins/azure/logalerts/policyAssignmentLogging.js'), + 'keyVaultsLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/keyVaultsLoggingEnabled.js'), + 'storageAccountLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/storageAccountLoggingEnabled.js'), + 'sqlServerDatabaseLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.js'), + 'loadBalancerLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/loadBalancerLoggingEnabled.js'), + 'virtualMachineLogging' : require(__dirname + '/plugins/azure/logalerts/virtualMachineLogging.js'), + 'flexibleServerLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/flexibleServerLoggingEnabled.js'), + 'mysqlFlexibleServerLoggingEnabled': require(__dirname + '/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.js'), + 'postgreSqlDBLoggingEnabled' : require(__dirname + '/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.js'), + 'sqlServerDatabaseRenameAlert' : require(__dirname + '/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.js'), + 'virtualMachinesPowerOffAlert' : require(__dirname + '/plugins/azure/logalerts/virtualMachinesPowerOffAlert.js'), + 'virtualMachinesDeallocateAlert': require(__dirname + '/plugins/azure/logalerts/virtualMachinesDeallocateAlert.js'), + 'publicIpAddressLoggingEnabled' : require(__dirname+ '/plugins/azure/logalerts/publicIpAddressLoggingEnabled.js'), - 'monitorEndpointProtection' : require(__dirname + '/plugins/azure/securitycenter/monitorEndpointProtection.js'), 'monitorBlobEncryption' : require(__dirname + '/plugins/azure/securitycenter/monitorBlobEncryption.js'), - 'monitorSystemUpdates' : require(__dirname + '/plugins/azure/securitycenter/monitorSystemUpdates.js'), - 'monitorJitNetworkAccess' : require(__dirname + '/plugins/azure/securitycenter/monitorJitNetworkAccess.js'), 'monitorVMVulnerability' : require(__dirname + '/plugins/azure/securitycenter/monitorVMVulnerability.js'), 'monitorSQLEncryption' : require(__dirname + '/plugins/azure/securitycenter/monitorSqlEncryption.js'), 'monitorSQLAuditing' : require(__dirname + '/plugins/azure/securitycenter/monitorSqlAuditing.js'), 'monitorDiskEncryption' : require(__dirname + '/plugins/azure/securitycenter/monitorDiskEncryption.js'), - 'appWhitelistingEnabled' : require(__dirname + '/plugins/azure/securitycenter/appWhitelistingEnabled.js'), - 'securityConfigMonitoring' : require(__dirname + '/plugins/azure/securitycenter/securityConfigMonitoring.js'), - 'autoProvisioningEnabled' : require(__dirname + '/plugins/azure/securitycenter/autoProvisioningEnabled.js'), 'adminSecurityAlertsEnabled' : require(__dirname + '/plugins/azure/securitycenter/adminSecurityAlertsEnabled.js'), - 'securityContactsEnabled' : require(__dirname + '/plugins/azure/securitycenter/securityContactsEnabled.js'), 'monitorNsgEnabled' : require(__dirname + '/plugins/azure/securitycenter/monitorNsgEnabled.js'), - 'highSeverityAlertsEnabled' : require(__dirname + '/plugins/azure/securitycenter/highSeverityAlertsEnabled.js'), - 'standardPricingEnabled' : require(__dirname + '/plugins/azure/securitycenter/standardPricingEnabled.js'), 'resourceAllowedLocations' : require(__dirname + '/plugins/azure/policyservice/resourceAllowedLocations.js'), 'resourceLocationMatch' : require(__dirname + '/plugins/azure/policyservice/resourceLocationMatch.js'), + 'mysqlFlexibleServerHasTags' : require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.js'), 'enforceMySQLSSLConnection' : require(__dirname + '/plugins/azure/mysqlserver/enforceMySQLSSLConnection.js'), + 'mysqlFlexibleServersMinTls' : require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.js'), + 'mysqlFlexibleServerVersion' : require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.js'), + 'mysqlServerHasTags' : require(__dirname + '/plugins/azure/mysqlserver/mysqlServerHasTags.js'), + 'mysqlFlexibleServerCMKEncrypted': require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.js'), + 'mysqlFlexibleServerPublicAccess': require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js'), + 'mysqlFlexibleServerDignosticLogs': require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.js'), + 'mysqlFlexibleServerIdentity' : require(__dirname + '/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js'), 'logRetentionDays' : require(__dirname + '/plugins/azure/postgresqlserver/logRetentionDays.js'), 'connectionThrottlingEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/connectionThrottlingEnabled.js'), 'logDurationEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/logDurationEnabled.js'), + 'postgresqlCMKEncrypted' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.js'), 'logDisconnectionsEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/logDisconnectionsEnabled.js'), 'logConnectionsEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/logConnectionsEnabled.js'), 'logCheckpointsEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/logCheckpointsEnabled.js'), 'enforcePostgresSSLConnection' : require(__dirname + '/plugins/azure/postgresqlserver/enforcePostgresSSLConnection.js'), + 'storageAutoGrowthEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.js'), + 'activeDirectoryAdminEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js'), + 'geoRedundantBackupEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.js'), + 'postgresqlServerHasTags' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlServerHasTags.js'), + 'postgresqlInfraDoubleEncryption': require(__dirname + '/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.js'), + 'postgresqlPrivateEndpoints' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.js'), + 'azureServicesAccessDisabled' : require(__dirname + '/plugins/azure/postgresqlserver/azureServicesAccessDisabled.js'), + 'postgresqlTlsVersion' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlTlsVersion.js'), + 'postgresqlServerPublicAccess' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js'), + 'postgresqlFlexibleServerPublicAccess': require(__dirname + '/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js'), + 'flexibleServerPrivateAccess' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerPrivateAccess'), + 'diagnosticLoggingEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.js'), + 'flexibleServerLogDisconnections': require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.js'), + 'flexibleServerSCRAMEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.js'), + 'flexibleServerVNetIntegrated' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.js'), + 'flexibleServerDiagnosticLogs' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.js'), + 'flexibleServerPrivateDns' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerPrivateDns.js'), + 'flexibleServerVersion' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerVersion.js'), + 'flexibleServerLogDuration' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerLogDuration.js'), + 'flexibleServerConnectionThrottle': require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.js'), + 'flexibleServerATP' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerATP.js'), 'openOracleAutoDataWarehouse' : require(__dirname + '/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.js'), - 'networkWatcherEnabled' : require(__dirname + '/plugins/azure/networksecuritygroups/networkWatcherEnabled.js'), + 'nsgFlowLogsEnabled' : require(__dirname + '/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.js'), + 'nsgFlowLogsRetentionPeriod' : require(__dirname + '/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.js'), 'excessiveSecurityGroups' : require(__dirname + '/plugins/azure/networksecuritygroups/excessiveSecurityGroups.js'), 'defaultSecurityGroup' : require(__dirname + '/plugins/azure/networksecuritygroups/defaultSecurityGroup.js'), 'openAllPorts' : require(__dirname + '/plugins/azure/networksecuritygroups/openAllPorts.js'), @@ -303,6 +943,24 @@ module.exports = { 'openSalt' : require(__dirname + '/plugins/azure/networksecuritygroups/openSalt.js'), 'openSMBoTCP' : require(__dirname + '/plugins/azure/networksecuritygroups/openSMBoTCP.js'), 'openSQLServer' : require(__dirname + '/plugins/azure/networksecuritygroups/openSQLServer.js'), + 'openUDP' : require(__dirname + '/plugins/azure/networksecuritygroups/openUDP.js'), + 'openSNMP' : require(__dirname + '/plugins/azure/networksecuritygroups/openSNMP.js'), + 'openRedis' : require(__dirname + '/plugins/azure/networksecuritygroups/openRedis.js'), + 'openMongoDB' : require(__dirname + '/plugins/azure/networksecuritygroups/openMongoDB.js'), + 'openMemcached' : require(__dirname + '/plugins/azure/networksecuritygroups/openMemcached.js'), + 'openLDAPS' : require(__dirname + '/plugins/azure/networksecuritygroups/openLDAPS.js'), + 'openLDAP' : require(__dirname + '/plugins/azure/networksecuritygroups/openLDAP.js'), + 'openInternalWeb' : require(__dirname + '/plugins/azure/networksecuritygroups/openInternalWeb.js'), + 'openElasticsearch' : require(__dirname + '/plugins/azure/networksecuritygroups/openElasticsearch.js'), + 'openCassandraClient' : require(__dirname + '/plugins/azure/networksecuritygroups/openCassandraClient.js'), + 'openCassandraInternode' : require(__dirname + '/plugins/azure/networksecuritygroups/openCassandraInternode.js'), + 'openCassandraMonitoring' : require(__dirname + '/plugins/azure/networksecuritygroups/openCassandraMonitoring.js'), + 'openCassandraThrift' : require(__dirname + '/plugins/azure/networksecuritygroups/openCassandraThrift.js'), + 'openHTTP' : require(__dirname + '/plugins/azure/networksecuritygroups/openHTTP.js'), + 'openHTTPS' : require(__dirname + '/plugins/azure/networksecuritygroups/openHTTPS.js'), + 'nsgLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.js'), + + 'networkWatcherEnabled' : require(__dirname + '/plugins/azure/networkwatchers/networkWatcherEnabled.js'), 'resourceUsageLimit' : require(__dirname + '/plugins/azure/resources/resourceUsageLimit.js'), 'managementLockEnabled' : require(__dirname + '/plugins/azure/resources/managementLockEnabled.js'), @@ -312,48 +970,301 @@ module.exports = { 'advancedDataSecurityEnabled' : require(__dirname + '/plugins/azure/sqlserver/advancedDataSecurityEnabled.js'), 'tdeProtectorEncrypted' : require(__dirname + '/plugins/azure/sqlserver/tdeProtectorEncrypted.js'), 'noPublicAccess' : require(__dirname + '/plugins/azure/sqlserver/noPublicAccess.js'), + 'serverPrivateEndpoints' : require(__dirname + '/plugins/azure/sqlserver/serverPrivateEndpoints.js'), 'auditRetentionPolicy' : require(__dirname + '/plugins/azure/sqlserver/auditRetentionPolicy.js'), 'auditActionGroupsEnabled' : require(__dirname + '/plugins/azure/sqlserver/auditActionGroupsEnabled.js'), 'serverAuditingEnabled' : require(__dirname + '/plugins/azure/sqlserver/serverAuditingEnabled.js'), 'azureADAdminEnabled' : require(__dirname + '/plugins/azure/sqlserver/azureADAdminEnabled.js'), + 'sqlServerTlsVersion' : require(__dirname + '/plugins/azure/sqlserver/sqlServerTlsVersion.js'), + 'autoFailoverGroupsEnabled' : require(__dirname + '/plugins/azure/sqlserver/autoFailoverGroupsEnabled.js'), + 'automaticTuningEnabled' : require(__dirname + '/plugins/azure/sqlserver/automaticTuningEnabled.js'), + 'enableATP' : require(__dirname + '/plugins/azure/sqlserver/enableATP.js'), + 'serverSendEmailToAdmins' : require(__dirname + '/plugins/azure/sqlserver/serverSendEmailToAdmins.js'), + 'sqlServerRecurringScans' : require(__dirname + '/plugins/azure/sqlserver/sqlServerRecurringScans.js'), + 'sqlServerSendScanReports' : require(__dirname + '/plugins/azure/sqlserver/sqlServerSendScanReports.js'), + 'sqlServerHasTags' : require(__dirname + '/plugins/azure/sqlserver/sqlServerHasTags.js'), + 'restrictOutboundNetworking' : require(__dirname + '/plugins/azure/sqlserver/restrictOutboundNetworking.js'), + 'auditOperationsEnabled' : require(__dirname + '/plugins/azure/sqlserver/auditOperationsEnabled.js'), + 'serverConnectionPolicy' : require(__dirname + '/plugins/azure/sqlserver/serverConnectionPolicy.js'), + 'auditStorageAuthType' : require(__dirname + '/plugins/azure/sqlserver/auditStorageAuthType.js'), + 'sqlServerVNetRuleIntegrated' : require(__dirname + '/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js'), + 'sqlAzureServicesAccessDisabled': require(__dirname + '/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.js'), + 'sqlServerManagedIdentity' : require(__dirname + '/plugins/azure/sqlserver/sqlServerManagedIdentity.js'), 'javaVersion' : require(__dirname + '/plugins/azure/appservice/javaVersion.js'), 'phpVersion' : require(__dirname + '/plugins/azure/appservice/phpVersion.js'), 'pythonVersion' : require(__dirname + '/plugins/azure/appservice/pythonVersion.js'), 'clientCertEnabled' : require(__dirname + '/plugins/azure/appservice/clientCertEnabled.js'), 'netFrameworkVersion' : require(__dirname + '/plugins/azure/appservice/netFrameworkVersion.js'), + 'nodeJsVersion' : require(__dirname + '/plugins/azure/appservice/nodeJsVersion.js'), 'authEnabled' : require(__dirname + '/plugins/azure/appservice/authEnabled.js'), 'identityEnabled' : require(__dirname + '/plugins/azure/appservice/identityEnabled.js'), 'http20Enabled' : require(__dirname + '/plugins/azure/appservice/http20Enabled.js'), 'httpsOnlyEnabled' : require(__dirname + '/plugins/azure/appservice/httpsOnlyEnabled.js'), 'tlsVersionCheck' : require(__dirname + '/plugins/azure/appservice/tlsVersionCheck.js'), + 'remoteDebuggingDisabled' : require(__dirname + '/plugins/azure/appservice/remoteDebuggingDisabled.js'), + 'alwaysOnEnabled' : require(__dirname + '/plugins/azure/appservice/alwaysOnEnabled.js'), + 'vnetIntegrated' : require(__dirname + '/plugins/azure/appservice/vnetIntegrated.js'), + 'certificateExpiry' : require(__dirname + '/plugins/azure/appservice/certificateExpiry.js'), + 'scmSiteAccessRestriction' : require(__dirname + '/plugins/azure/appservice/scmSiteAccessRestriction.js'), + 'secureHttptriggerFunction' : require(__dirname + '/plugins/azure/appservice/secureHttptriggerFunction.js'), + 'appServiceAccessRestriction' : require(__dirname + '/plugins/azure/appservice/appServiceAccessRestriction.js'), + 'webAppsADEnabled' : require(__dirname + '/plugins/azure/appservice/webAppsADEnabled.js'), + 'securityLogsEnabled' : require(__dirname + '/plugins/azure/appservice/securityLogsEnabled.js'), + 'appInsightsEnabled' : require(__dirname + '/plugins/azure/appservice/appInsightsEnabled.js'), + 'automatedBackupsEnabled' : require(__dirname + '/plugins/azure/appservice/automatedBackupsEnabled.js'), + 'ftpsOnlyAccessEnabled' : require(__dirname + '/plugins/azure/appservice/ftpsOnlyAccessEnabled.js'), + 'backupRetentionPeriod' : require(__dirname + '/plugins/azure/appservice/backupRetentionPeriod.js'), + 'privateEndpointsEnabled' : require(__dirname + '/plugins/azure/appservice/privateEndpointsEnabled.js'), + 'disableFTPDeployments' : require(__dirname + '/plugins/azure/appservice/disableFTPDeployments.js'), + 'accessControlAllowCredential' : require(__dirname + '/plugins/azure/appservice/accessControlAllowCredential.js'), + 'appServiceDiagnosticLogs' : require(__dirname + '/plugins/azure/appservice/appServiceDiagnosticLogs.js'), + 'functionPrivilegeAnalysis' : require(__dirname + '/plugins/azure/appservice/functionPrivilegeAnalysis.js'), + 'functionAppNetworkExposure' : require(__dirname + '/plugins/azure/appservice/functionAppNetworkExposure.js'), + 'appServicePublicAccess' : require(__dirname + '/plugins/azure/appservice/appServicePublicAccess.js'), 'rbacEnabled' : require(__dirname + '/plugins/azure/kubernetesservice/rbacEnabled.js'), + 'aksManagedIdentity' : require(__dirname + '/plugins/azure/kubernetesservice/aksManagedIdentity.js'), 'aksLatestVersion' : require(__dirname + '/plugins/azure/kubernetesservice/aksLatestVersion.js'), + 'aksAgentVersion' : require(__dirname + '/plugins/azure/kubernetesservice/aksAgentVersion.js'), + 'aksClusterHasTags' : require(__dirname + '/plugins/azure/kubernetesservice/aksClusterHasTags.js'), + 'aksEncryptionAtRestWithCMK' : require(__dirname + '/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK'), + 'aksPrivateCluster' : require(__dirname + '/plugins/azure/kubernetesservice/aksPrivateCluster.js'), + 'aksDiagnosticLogsEnabled' : require(__dirname + '/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.js'), + 'aksHostBasedEncryption' : require(__dirname + '/plugins/azure/kubernetesservice/aksHostBasedEncryption.js'), + 'aksApiAuthorizedIpRanges' : require(__dirname + '/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.js'), + 'aksNetworkExposure' : require(__dirname + '/plugins/azure/kubernetesservice/aksNetworkExposure.js'), + 'aksPrivilegeAnalysis' : require(__dirname + '/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js'), 'acrAdminUser' : require(__dirname + '/plugins/azure/containerregistry/acrAdminUser.js'), + 'acrHasTags' : require(__dirname + '/plugins/azure/containerregistry/acrHasTags.js'), + 'acrTrustedServiceEnabled' : require(__dirname + '/plugins/azure/containerregistry/acrTrustedServiceEnabled.js'), + 'acrManagedIdentityEnabled' : require(__dirname + '/plugins/azure/containerregistry/acrManagedIdentityEnabled.js'), + 'acrPublicAccess' : require(__dirname + '/plugins/azure/containerregistry/acrPublicAccess.js'), + 'acrCMKEncryption' : require(__dirname + '/plugins/azure/containerregistry/acrCMKEncryption.js'), + 'acrLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/containerregistry/acrLogAnalyticsEnabled.js'), + 'acrAnonymousPullAccessEnabled' : require(__dirname + '/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.js'), + 'acrContentTrustEnabled' : require(__dirname + '/plugins/azure/containerregistry/acrContentTrustEnabled.js'), 'endpointLoggingEnabled' : require(__dirname + '/plugins/azure/cdnprofiles/endpointLoggingEnabled.js'), 'detectInsecureCustomOrigin' : require(__dirname + '/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js'), - 'passwordRequiresLowercase' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresLowercase.js'), - 'passwordRequiresNumbers' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresNumbers.js'), - 'passwordRequiresSymbols' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresSymbols.js'), - 'passwordRequiresUppercase' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresUppercase.js'), - 'minPasswordLength' : require(__dirname + '/plugins/azure/activedirectory/minPasswordLength.js'), - 'ensureNoGuestUser' : require(__dirname + '/plugins/azure/activedirectory/ensureNoGuestUser.js'), - 'noCustomOwnerRoles' : require(__dirname + '/plugins/azure/activedirectory/noCustomOwnerRoles.js'), + 'passwordRequiresLowercase' : require(__dirname + '/plugins/azure/entraid/passwordRequiresLowercase.js'), + 'passwordRequiresNumbers' : require(__dirname + '/plugins/azure/entraid/passwordRequiresNumbers.js'), + 'passwordRequiresSymbols' : require(__dirname + '/plugins/azure/entraid/passwordRequiresSymbols.js'), + 'passwordRequiresUppercase' : require(__dirname + '/plugins/azure/entraid/passwordRequiresUppercase.js'), + 'minPasswordLength' : require(__dirname + '/plugins/azure/entraid/minPasswordLength.js'), + 'ensureNoGuestUser' : require(__dirname + '/plugins/azure/entraid/ensureNoGuestUser.js'), + 'noCustomOwnerRoles' : require(__dirname + '/plugins/azure/entraid/noCustomOwnerRoles.js'), + 'appOrgnaizationalDirectoryAccess' : require(__dirname + '/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js'), 'dbAuditingEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbAuditingEnabled.js'), + 'dbDataMaskingEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbDataMaskingEnabled.js'), 'sqlDbMultiAz' : require(__dirname + '/plugins/azure/sqldatabases/sqlDbMultiAz.js'), + 'dbDiagnosticLoggingEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.js'), 'dbRestorable' : require(__dirname + '/plugins/azure/sqldatabases/dbRestorable.js'), + 'dbLedgerDigestStorageEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.js'), + 'pitrBackupEnabled' : require(__dirname + '/plugins/azure/sqldatabases/pitrBackupEnabled.js'), + 'dbSyncGroupPrivateLink' : require(__dirname + '/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.js'), + 'dbTDEEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbTDEEnabled.js'), + 'dbLedgerEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbLedgerEnabled.js'), + 'dbEnableSecureEnclaves' : require(__dirname + '/plugins/azure/sqldatabases/dbEnableSecureEnclaves.js'), + 'dbDataDiscoveryClassification' : require(__dirname + '/plugins/azure/sqldatabases/dbDataDiscoveryClassification.js'), 'lbHttpsOnly' : require(__dirname + '/plugins/azure/loadbalancer/lbHttpsOnly.js'), 'lbNoInstances' : require(__dirname + '/plugins/azure/loadbalancer/lbNoInstances.js'), + 'lbHasTags' : require(__dirname + '/plugins/azure/loadbalancer/lbHasTags.js'), + 'lbPublicIp' : require(__dirname + '/plugins/azure/loadbalancer/lbPublicIp.js'), + 'lbLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.js'), 'kvRecoveryEnabled' : require(__dirname + '/plugins/azure/keyvaults/kvRecoveryEnabled.js'), 'keyExpirationEnabled' : require(__dirname + '/plugins/azure/keyvaults/keyExpirationEnabled.js'), 'secretExpirationEnabled' : require(__dirname + '/plugins/azure/keyvaults/secretExpirationEnabled.js'), + 'databaseTierCmkInUse' : require(__dirname + '/plugins/azure/keyvaults/databaseTierCmkInUse.js'), + 'sslCertificateAutoRenewal' : require(__dirname + '/plugins/azure/keyvaults/sslCertificateAutoRenewal.js'), + 'manageKeyAccessAndPermissions' : require(__dirname + '/plugins/azure/keyvaults/manageKeyAccessAndPermissions.js'), + 'rsaCertificateKeySize' : require(__dirname + '/plugins/azure/keyvaults/rsaCertificateKeySize.js'), + 'keyVaultSecretExpiry' : require(__dirname + '/plugins/azure/keyvaults/keyVaultSecretExpiry.js'), + 'keyVaultSecretExpiryNonRbac' : require(__dirname + '/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js'), + 'keyVaultKeyExpiry' : require(__dirname + '/plugins/azure/keyvaults/keyVaultKeyExpiry.js'), + 'keyVaultKeyExpiryNonRbac' : require(__dirname + '/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js'), + 'allowedCertificateKeyTypes' : require(__dirname + '/plugins/azure/keyvaults/allowedCertificateKeyTypes.js'), + 'appTierCmkInUse' : require(__dirname + '/plugins/azure/keyvaults/appTierCmkInUse.js'), + 'keyVaultInUse' : require(__dirname + '/plugins/azure/keyvaults/keyVaultInUse.js'), + 'restrictDefaultNetworkAccess' : require(__dirname + '/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js'), + 'trustedServicesEnabled' : require(__dirname + '/plugins/azure/keyvaults/trustedServicesEnabled.js'), + 'keyVaultHasTags' : require(__dirname + '/plugins/azure/keyvaults/keyVaultHasTags.js'), + 'keyVaultsPrivateEndpoint' : require(__dirname + '/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.js'), + 'kvLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/keyvaults/kvLogAnalyticsEnabled.js'), + 'keyVaultPublicAccess' : require(__dirname + '/plugins/azure/keyvaults/keyVaultPublicAccess.js'), + + 'advancedThreatProtection' : require(__dirname + '/plugins/azure/cosmosdb/advancedThreatProtection.js'), + 'cosmosdbDiagnosticLogs' : require(__dirname + '/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.js'), + 'cosmosPublicAccessDisabled' : require(__dirname + '/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.js'), + 'automaticFailoverEnabled' : require(__dirname + '/plugins/azure/cosmosdb/automaticFailoverEnabled.js'), + 'cosmosdbHasTags' : require(__dirname + '/plugins/azure/cosmosdb/cosmosdbHasTags.js'), + 'cosmosdbManagedIdentity' : require(__dirname + '/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js'), + 'cosmosdbLocalAuth' : require(__dirname + '/plugins/azure/cosmosdb/cosmosdbLocalAuth.js'), + + 'checkAdvisorRecommendations' : require(__dirname + '/plugins/azure/advisor/checkAdvisorRecommendations.js'), + + 'enableDefenderForStorage' : require(__dirname + '/plugins/azure/defender/enableDefenderForStorage.js'), + 'enableDefenderForContainers' : require(__dirname + '/plugins/azure/defender/enableDefenderForContainers.js'), + 'enableDefenderForSqlServers' : require(__dirname + '/plugins/azure/defender/enableDefenderForSqlServers.js'), + 'enableDefenderForOSRD' : require(__dirname + '/plugins/azure/defender/enableDefenderForOSRD.js'), + 'enableEndpointIntegration' : require(__dirname + '/plugins/azure/defender/enableEndpointIntegration.js'), + 'enableDefenderForDNS' : require(__dirname + '/plugins/azure/defender/enableDefenderForDNS.js'), + 'enableDefenderForKeyVaults' : require(__dirname + '/plugins/azure/defender/enableDefenderForKeyVaults.js'), + 'enableDefenderForVMs' : require(__dirname + '/plugins/azure/defender/enableDefenderForVMs.js'), + 'enableDefenderForAppService' : require(__dirname + '/plugins/azure/defender/enableDefenderForAppService.js'), + 'enableDefenderForCSPM' : require(__dirname + '/plugins/azure/defender/enableDefenderForCSPM.js'), + 'enableDefenderForARM' : require(__dirname + '/plugins/azure/defender/enableDefenderForARM.js'), + 'enableDefenderForAPIs' : require(__dirname + '/plugins/azure/defender/enableDefenderForAPIs.js'), + 'enableDefenderForCosmosDB' : require(__dirname + '/plugins/azure/defender/enableDefenderForCosmosDB.js'), + 'enableDefenderForSqlServersVMs': require(__dirname + '/plugins/azure/defender/enableDefenderForSqlServersVMs.js'), + 'highSeverityAlertsEnabled' : require(__dirname + '/plugins/azure/defender/highSeverityAlertsEnabled.js'), + 'standardPricingEnabled' : require(__dirname + '/plugins/azure/defender/standardPricingEnabled.js'), + 'monitorExternalAccounts' : require(__dirname + '/plugins/azure/defender/monitorExternalAccounts.js'), + 'monitorIpForwarding' : require(__dirname + '/plugins/azure/defender/monitorIpForwarding.js'), + 'monitorNextGenerationFirewall' : require(__dirname + '/plugins/azure/defender/monitorNextGenerationFirewall.js'), + 'monitorSubscriptionOwners' : require(__dirname + '/plugins/azure/defender/monitorSubscriptionOwners.js'), + 'securityContactAdditionalEmail': require(__dirname + '/plugins/azure/defender/securityContactAdditionalEmail.js'), + 'securityContactRoleSetToOwner' : require(__dirname + '/plugins/azure/defender/securityContactRoleSetToOwner.js'), + 'appWhitelistingEnabled' : require(__dirname + '/plugins/azure/defender/appWhitelistingEnabled.js'), + 'securityConfigMonitoring' : require(__dirname + '/plugins/azure/defender/securityConfigMonitoring.js'), + 'autoProvisioningEnabled' : require(__dirname + '/plugins/azure/defender/autoProvisioningEnabled.js'), + 'monitorSystemUpdates' : require(__dirname + '/plugins/azure/defender/monitorSystemUpdates.js'), + 'monitorEndpointProtection' : require(__dirname + '/plugins/azure/defender/monitorEndpointProtection.js'), + 'monitorJitNetworkAccess' : require(__dirname + '/plugins/azure/defender/monitorJitNetworkAccess.js'), + 'securityContactsEnabled' : require(__dirname + '/plugins/azure/defender/securityContactsEnabled.js'), + + 'agWafEnabled' : require(__dirname + '/plugins/azure/applicationGateway/agWafEnabled'), + 'applicationGatewayHasTags' : require(__dirname + '/plugins/azure/applicationGateway/applicationGatewayHasTags.js'), + 'agSecurityLoggingEnabled' : require(__dirname + '/plugins/azure/applicationGateway/agSecurityLoggingEnabled.js'), + 'agSslPolicy' : require(__dirname + '/plugins/azure/applicationGateway/agSslPolicy'), + 'agPreventionModeEnabled' : require(__dirname + '/plugins/azure/applicationGateway/agPreventionModeEnabled.js'), + 'agRequestBodyInspection' : require(__dirname + '/plugins/azure/applicationGateway/agRequestBodyInspection'), + 'agRequestBodySize' : require(__dirname + '/plugins/azure/applicationGateway/agRequestBodySize.js'), + 'agHttpsListenerOnly' : require(__dirname + '/plugins/azure/applicationGateway/agHttpsListenerOnly.js'), + + 'subscriptionHasTags' : require(__dirname + '/plugins/azure/subscription/subscriptionHasTags.js'), + + 'rgHasTags' : require(__dirname + '/plugins/azure/resourceGroup/rgHasTags.js'), + + 'wafPolicyHasTags' : require(__dirname + '/plugins/azure/waf/wafPolicyHasTags.js'), + + 'recoveryVaultByokEncrypted' : require(__dirname + '/plugins/azure/recoveryService/recoveryVaultByokEncrypted.js'), + 'recoveryVaultLoggingEnabled' : require(__dirname + '/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.js'), + + 'domainPublicAccessEnabled' : require(__dirname + '/plugins/azure/eventGrid/domainPublicAccess.js'), + 'domainMinimumTlsVersion' : require(__dirname + '/plugins/azure/eventGrid/domainMinimumTlsVersion.js'), + 'domainDiagnosticLogs' : require(__dirname + '/plugins/azure/eventGrid/domainDiagnosticLogs.js'), + 'domainLocalAuthDisabled' : require(__dirname + '/plugins/azure/eventGrid/domainLocalAuthDisabled.js'), + 'domainManagedIdentity' : require(__dirname + '/plugins/azure/eventGrid/domainManagedIdentity.js'), + + 'eventHubMinimumTLSversion' : require(__dirname + '/plugins/azure/eventhub/eventHubMinimumTLSversion.js'), + 'eventHubNamespaceHasTags' : require(__dirname + '/plugins/azure/eventhub/eventHubNamespaceHasTags.js'), + 'eventHubNamespaceAutoInflate' : require(__dirname + '/plugins/azure/eventhub/eventHubNamespaceAutoInflate.js'), + 'eventHubLocalAuthDisabled' : require(__dirname + '/plugins/azure/eventhub/eventHubLocalAuthDisabled.js'), + 'eventHubPublicAccess' : require(__dirname + '/plugins/azure/eventhub/eventHubPublicAccess.js'), + 'eventHubNamespaceCmkEncrypted' : require(__dirname + '/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.js'), + 'eventHubDiagnosticLogs' : require(__dirname + '/plugins/azure/eventhub/eventHubDiagnosticLogs.js'), + 'eventHubManagedIdentity' : require(__dirname + '/plugins/azure/eventhub/eventHubManagedIdentity.js'), + + 'accessLogsEnabled' : require(__dirname + '/plugins/azure/frontdoor/accessLogsEnabled.js'), + 'frontDoorMinimumTlsVersion' : require(__dirname + '/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.js'), + 'afdSecurityLoggingEnabled' : require(__dirname + '/plugins/azure/frontdoor/afdSecurityLoggingEnabled.js'), + 'frontDoorWafDefaultRateLimit' : require(__dirname + '/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.js'), + 'frontDoorAzureManagedDomain' : require(__dirname + '/plugins/azure/frontdoor/frontDoorAzureManagedDomain.js'), + 'frontDoorWafDetectionMode' : require(__dirname + '/plugins/azure/frontdoor/frontDoorWafDetectionMode.js'), + 'frontDoorRequestBodyInspection': require(__dirname + '/plugins/azure/frontdoor/frontDoorRequestBodyInspection.js'), + 'frontDoorWafEnabled' : require(__dirname + '/plugins/azure/frontdoor/frontDoorWafEnabled.js'), + 'frontDoorHttpsOnly' : require(__dirname + '/plugins/azure/frontdoor/frontDoorHttpsOnly.js'), + 'botProtectionEnabled' : require(__dirname + '/plugins/azure/frontdoor/botProtectionEnabled.js'), + 'frontDoorManagedIdentity' : require(__dirname + '/plugins/azure/frontdoor/frontDoorManagedIdentity.js'), + 'frontDoorWafDefaultRuleSet' : require(__dirname + '/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.js'), + + 'namespaceEncryptionAtRest' : require(__dirname + '/plugins/azure/servicebus/namespaceEncryptionAtRest.js'), + 'namespaceTlsVersion' : require(__dirname + '/plugins/azure/servicebus/namespaceTlsVersion.js'), + 'namespaceManagedIdentity' : require(__dirname + '/plugins/azure/servicebus/namespaceManagedIdentity.js'), + 'namespaceLocalAuth' : require(__dirname + '/plugins/azure/servicebus/namespaceLocalAuth.js'), + 'namespaceHasTags' : require(__dirname + '/plugins/azure/servicebus/namespaceHasTags.js'), + 'namespaceLoggingEnabled' : require(__dirname + '/plugins/azure/servicebus/namespaceLoggingEnabled.js'), + 'namespacePublicAccess' : require(__dirname + '/plugins/azure/servicebus/namespacePublicAccess.js'), + 'namespaceInfraEncryption' : require(__dirname + '/plugins/azure/servicebus/namespaceInfraEncryption.js'), + + 'amsStorageAccountIdentity' : require(__dirname + '/plugins/azure/mediaServices/amsStorageAccountIdentity.js'), + 'amsDiagnosticLogsEnabled' : require(__dirname + '/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.js'), + 'amsPublicAccessDisabled' : require(__dirname + '/plugins/azure/mediaServices/amsPublicAccessDisabled.js'), + 'amsManagedIdentityEnabled' : require(__dirname + '/plugins/azure/mediaServices/amsManagedIdentityEnabled.js'), + 'amsClassicApiDisabled' : require(__dirname + '/plugins/azure/mediaServices/amsClassicApiDisabled.js'), + 'amsContentKeyPolicy' : require(__dirname + '/plugins/azure/mediaServices/amsContentKeyPolicy.js'), + + 'scaleSetMultiAz' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scaleSetMultiAz.js'), + 'scaleSetAutoscaleEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.js'), + 'scaleSetHealthMonitoring' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.js'), + 'vmScaleSetHasTags' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.js'), + 'noEmptyScaleSets' : require(__dirname + '/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.js'), + 'autoscaleNotificationsEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.js'), + 'autoOsUpgradesEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.js'), + 'autoInstanceRepairsEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.js'), + 'vmssTrustedLaunchEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.js'), + 'scaleSetAdAuthEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js'), + 'vmssManagedIdentityEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js'), + 'scalesetVTPMEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.js'), + 'scalesetSecureBootEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.js'), + 'vmssApprovedExtensions' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions'), + 'healthMonitoringExtensionHttps': require(__dirname + '/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.js'), + 'vmssBootDiagnosticsEnabled' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled'), + 'vmssWindowsAntiMalwareExt' : require(__dirname + '/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt'), + + 'appConfigManagedIdentity' : require(__dirname + '/plugins/azure/appConfigurations/appConfigManagedIdentity.js'), + 'appConfigurationDiagnosticLogs': require(__dirname + '/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.js'), + 'appConfigurationPublicAccess' : require(__dirname + '/plugins/azure/appConfigurations/appConfigurationPublicAccess.js'), + 'appConfigurationCmkEncrypted' : require(__dirname + '/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.js'), + 'appConfigHasTags' : require(__dirname + '/plugins/azure/appConfigurations/appConfigHasTags.js'), + 'appConfigAccessKeyAuthDisabled': require(__dirname + '/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js'), + + 'automationAcctDiagnosticLogs' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.js'), + 'automationAcctManagedIdentity' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctManagedIdentity.js'), + 'automationAcctApprovedCerts' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctApprovedCerts.js'), + 'automationAcctEncryptedVars' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctEncryptedVars.js'), + 'automationAcctPublicAccess' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctPublicAccess.js'), + 'automationAcctExpiredWebhooks' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.js'), + 'automationAcctPrivateEndpoints': require(__dirname + '/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.js'), + 'automationAcctHasTags' : require(__dirname + '/plugins/azure/automationAccounts/automationAcctHasTags.js'), + 'validSourceControls' : require(__dirname + '/plugins/azure/automationAccounts/validSourceControls.js'), + + 'batchAccountCmkEncrypted' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountCmkEncrypted.js'), + 'batchAccountDiagnosticLogs' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.js'), + 'batchAccountsAADEnabled' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountsAADEnabled.js'), + 'batchAccountsHasTags' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountsHasTags.js'), + 'batchAccountsPublicAccess' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountsPublicAccess.js'), + 'batchAccountsManagedIdentity' : require(__dirname + '/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js'), + + 'accountCMKEncrypted' : require(__dirname + '/plugins/azure/openai/accountCMKEncrypted.js'), + 'accountManagedIdentity' : require(__dirname + '/plugins/azure/openai/accountManagedIdentity.js'), + 'accountDiagnosticLogging' : require(__dirname + '/plugins/azure/openai/accountDiagnosticLogging.js'), + 'accountHasTags' : require(__dirname + '/plugins/azure/openai/accountHasTags.js'), + 'accountPublicAccessDisabled' : require(__dirname + '/plugins/azure/openai/accountPublicAccessDisabled.js'), + + 'workspaceDbfsInfraEncryption' : require(__dirname + '/plugins/azure/databricks/workspaceDbfsInfraEncryption.js'), + 'workspaceSecureCluster' : require(__dirname + '/plugins/azure/databricks/workspaceSecureCluster.js'), + 'workspaceDiagnosticLogs' : require(__dirname + '/plugins/azure/databricks/workspaceDiagnosticLogs.js'), + 'workspaceManagedServicesCmk' : require(__dirname + '/plugins/azure/databricks/workspaceManagedServicesCmk.js'), + 'workspaceManagedDiskCmk' : require(__dirname + '/plugins/azure/databricks/workspaceManagedDiskCmk.js'), + 'workspaceHasTags' : require(__dirname + '/plugins/azure/databricks/workspaceHasTags.js'), + + 'workspaceManagedIdentity' : require(__dirname + '/plugins/azure/synapse/workspaceManagedIdentity.js'), + 'synapseWorkspaceAdAuthEnabled' : require(__dirname + '/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js'), + 'synapseWorkspacPrivateEndpoint': require(__dirname + '/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.js'), + 'synapseWorkspaceHasTags' : require(__dirname + '/plugins/azure/synapse/synapseWorkspaceHasTags.js'), + 'workspaceDiagnosticLogsEnabled': require(__dirname + '/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.js'), + 'workspaceDoubleEncryption' : require(__dirname + '/plugins/azure/synapse/workspaceDoubleEncryption.js'), + + 'apiInstanceManagedIdentity' : require(__dirname + '/plugins/azure/apiManagement/apiInstanceManagedIdentity.js'), + 'apiInstanceHasTags' : require(__dirname + '/plugins/azure/apiManagement/apiInstanceHasTags.js'), + }, github: { 'publicKeysRotated' : require(__dirname + '/plugins/github/users/publicKeysRotated.js'), @@ -376,6 +1287,8 @@ module.exports = { 'bootVolumeRestorable' : require(__dirname + '/plugins/oracle/compute/bootVolumeRestorable.js'), 'bootVolumeBackupEnabled' : require(__dirname + '/plugins/oracle/compute/bootVolumeBackupEnabled.js'), 'instancePolicyProtection' : require(__dirname + '/plugins/oracle/compute/instancePolicyProtection.js'), + 'bootVolumeCMKEncryption' : require(__dirname + '/plugins/oracle/compute/bootVolumeCMKEncryption.js'), + 'legacyEndpointDisabled' : require(__dirname + '/plugins/oracle/compute/legacyEndpointDisabled.js'), 'usersMfaEnabled' : require(__dirname + '/plugins/oracle/identity/usersMfaEnabled.js'), 'passwordRequiresLowercase' : require(__dirname + '/plugins/oracle/identity/passwordRequiresLowercase.js'), @@ -387,6 +1300,19 @@ module.exports = { 'excessivePolicies' : require(__dirname + '/plugins/oracle/identity/excessivePolicies.js'), 'excessivePolicyStatements' : require(__dirname + '/plugins/oracle/identity/excessivePolicyStatements.js'), 'policyLeastPrivilege' : require(__dirname + '/plugins/oracle/identity/policyLeastPrivilege.js'), + 'usersEmailVerified' : require(__dirname + '/plugins/oracle/identity/usersEmailVerified.js'), + 'adminUserAPIKeys' : require(__dirname + '/plugins/oracle/identity/adminUserAPIKeys.js'), + 'userAuthTokenRotated' : require(__dirname + '/plugins/oracle/identity/userAuthTokenRotated.js'), + 'userCustomerSecretKeysRotated' : require(__dirname + '/plugins/oracle/identity/userCustomerSecretKeysRotated.js'), + 'userAPIKeysRotated' : require(__dirname + '/plugins/oracle/identity/userAPIKeysRotated.js'), + 'usersPasswordLastUsed' : require(__dirname + '/plugins/oracle/identity/usersPasswordLastUsed.js'), + 'defaultTagsForResources' : require(__dirname + '/plugins/oracle/identity/defaultTagsForResources.js'), + 'notificationTopicSubscription' : require(__dirname + '/plugins/oracle/identity/notificationTopicSubscription.js'), + 'identityProviderChanges' : require(__dirname + '/plugins/oracle/identity/identityProviderChanges.js'), + 'idpGroupMappingChanges' : require(__dirname + '/plugins/oracle/identity/idpGroupMappingChanges.js'), + 'iamGroupChanges' : require(__dirname + '/plugins/oracle/identity/iamGroupChanges.js'), + 'iamPolicyChanges' : require(__dirname + '/plugins/oracle/identity/iamPolicyChanges.js'), + 'userChanges' : require(__dirname + '/plugins/oracle/identity/userChanges.js'), 'openSSH' : require(__dirname + '/plugins/oracle/networking/openSSH.js'), 'openOracleAutoDataWarehouse' : require(__dirname + '/plugins/oracle/networking/openOracleAutoDataWarehouse.js'), @@ -411,7 +1337,12 @@ module.exports = { 'openKibana' : require(__dirname + '/plugins/oracle/networking/openKibana.js'), 'openSalt' : require(__dirname + '/plugins/oracle/networking/openSalt.js'), 'openSMTP' : require(__dirname + '/plugins/oracle/networking/openSMTP.js'), - + 'flowLogsEnabled' : require(__dirname + '/plugins/oracle/networking/flowLogsEnabled.js'), + 'vcnChanges' : require(__dirname + '/plugins/oracle/networking/vcnChanges.js'), + 'routeTableChanges' : require(__dirname + '/plugins/oracle/networking/routeTableChanges.js'), + 'securityListChanges' : require(__dirname + '/plugins/oracle/networking/securityListChanges.js'), + 'securityGroupChanges' : require(__dirname + '/plugins/oracle/networking/securityGroupChanges.js'), + 'networkGatewayChanges' : require(__dirname + '/plugins/oracle/networking/networkGatewayChanges.js'), 'statelessSecurityRules' : require(__dirname + '/plugins/oracle/networking/statelessSecurityRules.js'), 'defaultSecurityList' : require(__dirname + '/plugins/oracle/networking/defaultSecurityList.js'), 'excessiveSecurityLists' : require(__dirname + '/plugins/oracle/networking/excessiveSecurityLists.js'), @@ -419,17 +1350,23 @@ module.exports = { 'lbNSGEnabled' : require(__dirname + '/plugins/oracle/networking/lbNSGEnabled.js'), 'lbNoInstances' : require(__dirname + '/plugins/oracle/networking/lbNoInstances.js'), 'wafPublicIpEnabled' : require(__dirname + '/plugins/oracle/networking/wafPublicIpEnabled.js'), - + 'inboundSecurityLists' : require(__dirname + '/plugins/oracle/networking/inboundSecurityLists.js'), 'multipleSubnets' : require(__dirname + '/plugins/oracle/networking/multipleSubnets.js'), 'subnetMultiAd' : require(__dirname + '/plugins/oracle/networking/subnetMultiAd.js'), + 'openHTTP' : require(__dirname + '/plugins/oracle/networking/openHTTP.js'), 'bucketPublicAccessType' : require(__dirname + '/plugins/oracle/objectstore/bucketPublicAccessType.js'), 'preAuthRequestsExpiry' : require(__dirname + '/plugins/oracle/objectstore/preAuthRequestsExpiry.js'), 'preAuthRequestsAccess' : require(__dirname + '/plugins/oracle/objectstore/preAuthRequestsAccess.js'), 'objectPolicyProtection' : require(__dirname + '/plugins/oracle/objectstore/objectPolicyProtection.js'), + 'bucketCMKEncryption' : require(__dirname + '/plugins/oracle/objectstore/bucketCMKEncryption.js'), + 'bucketVersioning' : require(__dirname + '/plugins/oracle/objectstore/bucketVersioning.js'), + 'bucketObjectEvents' : require(__dirname + '/plugins/oracle/objectstore/bucketObjectEvents.js'), + 'bucketWriteLogsEnabled' : require(__dirname + '/plugins/oracle/objectstore/bucketWriteLogsEnabled.js'), 'nfsPublicAccess' : require(__dirname + '/plugins/oracle/filestorage/nfsPublicAccess.js'), 'nfsPolicyProtection' : require(__dirname + '/plugins/oracle/filestorage/nfsPolicyProtection.js'), + 'fileSystemsCMKEncryption' : require(__dirname + '/plugins/oracle/filestorage/fileSystemsCMKEncryption.js'), 'dbBackupEnabled' : require(__dirname + '/plugins/oracle/database/dbBackupEnabled.js'), 'dbPrivateSubnetOnly' : require(__dirname + '/plugins/oracle/database/dbPrivateSubnetOnly.js'), @@ -440,11 +1377,21 @@ module.exports = { 'blockVolumeBackupEnabled' : require(__dirname + '/plugins/oracle/blockstorage/blockVolumeBackupEnabled.js'), 'volumeGroupsRestorable' : require(__dirname + '/plugins/oracle/blockstorage/volumeGroupsRestorable.js'), 'blockPolicyProtection' : require(__dirname + '/plugins/oracle/blockstorage/blockPolicyProtection.js'), + 'blockVolumeCMKEncryption' : require(__dirname + '/plugins/oracle/blockstorage/blockVolumeCMKEncryption.js'), 'logRetentionPeriod' : require(__dirname + '/plugins/oracle/audit/logRetentionPeriod.js'), + + 'okePrivateEndpoint' : require(__dirname + '/plugins/oracle/oke/okePrivateEndpoint.js'), + 'okeSecretsEncrypted' : require(__dirname + '/plugins/oracle/oke/okeSecretsEncrypted.js'), + 'okeSecurityGroups' : require(__dirname + '/plugins/oracle/oke/okeSecurityGroups.js'), + + 'cloudguardEnabled' : require(__dirname + '/plugins/oracle/cloudguard/cloudguardEnabled.js'), + + 'keyRotation' : require(__dirname + '/plugins/oracle/vaults/keyRotation.js'), }, google: { 'excessiveFirewallRules' : require(__dirname + '/plugins/google/vpcnetwork/excessiveFirewallRules.js'), + 'openCassandra' : require(__dirname + '/plugins/google/vpcnetwork/openCassandra.js'), 'openDNS' : require(__dirname + '/plugins/google/vpcnetwork/openDNS.js'), 'openDocker' : require(__dirname + '/plugins/google/vpcnetwork/openDocker.js'), 'openSSH' : require(__dirname + '/plugins/google/vpcnetwork/openSSH.js'), @@ -454,11 +1401,14 @@ module.exports = { 'openHadoopNameNode' : require(__dirname + '/plugins/google/vpcnetwork/openHadoopNameNode.js'), 'openHadoopNameNodeWebUI' : require(__dirname + '/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.js'), 'openKibana' : require(__dirname + '/plugins/google/vpcnetwork/openKibana.js'), + 'openMongo' : require(__dirname + '/plugins/google/vpcnetwork/openMongo.js'), + 'openMsSQL' : require(__dirname + '/plugins/google/vpcnetwork/openMsSQL.js'), 'openMySQL' : require(__dirname + '/plugins/google/vpcnetwork/openMySQL.js'), 'openNetBIOS' : require(__dirname + '/plugins/google/vpcnetwork/openNetBIOS.js'), 'openOracle' : require(__dirname + '/plugins/google/vpcnetwork/openOracle.js'), 'openPostgreSQL' : require(__dirname + '/plugins/google/vpcnetwork/openPostgreSQL.js'), 'openRDP' : require(__dirname + '/plugins/google/vpcnetwork/openRDP.js'), + 'openRedis' : require(__dirname + '/plugins/google/vpcnetwork/openRedis.js'), 'openRPC' : require(__dirname + '/plugins/google/vpcnetwork/openRPC.js'), 'openSalt' : require(__dirname + '/plugins/google/vpcnetwork/openSalt.js'), 'openSMBoTCP' : require(__dirname + '/plugins/google/vpcnetwork/openSMBoTCP.js'), @@ -472,6 +1422,24 @@ module.exports = { 'defaultVpcInUse' : require(__dirname + '/plugins/google/vpcnetwork/defaultVpcInUse.js'), 'flowLogsEnabled' : require(__dirname + '/plugins/google/vpcnetwork/flowLogsEnabled.js'), 'privateAccessEnabled' : require(__dirname + '/plugins/google/vpcnetwork/privateAccessEnabled.js'), + 'dnsLoggingEnabled' : require(__dirname + '/plugins/google/vpcnetwork/dnsLoggingEnabled.js'), + 'openCustomPorts' : require(__dirname + '/plugins/google/vpcnetwork/openCustomPorts.js'), + 'firewallLoggingMetadata' : require(__dirname + '/plugins/google/vpcnetwork/firewallLoggingMetadata.js'), + 'openInternalWeb' : require(__dirname + '/plugins/google/vpcnetwork/openInternalWeb.js'), + 'openLDAPS' : require(__dirname + '/plugins/google/vpcnetwork/openLDAPS.js'), + 'openMemcached' : require(__dirname + '/plugins/google/vpcnetwork/openMemcached.js'), + 'openSNMP' : require(__dirname + '/plugins/google/vpcnetwork/openSNMP.js'), + 'openLDAP' : require(__dirname + '/plugins/google/vpcnetwork/openLDAP.js'), + 'openElasticsearch' : require(__dirname + '/plugins/google/vpcnetwork/openElasticsearch.js'), + 'openCassandraClient' : require(__dirname + '/plugins/google/vpcnetwork/openCassandraClient.js'), + 'openCassandraMonitoring' : require(__dirname + '/plugins/google/vpcnetwork/openCassandraMonitoring.js'), + 'openCassandraThrift' : require(__dirname + '/plugins/google/vpcnetwork/openCassandraThrift.js'), + 'openCassandraInternode' : require(__dirname + '/plugins/google/vpcnetwork/openCassandraInternode.js'), + 'legacyNetworksExist' : require(__dirname + '/plugins/google/vpcnetwork/legacyNetworksExist.js'), + 'defaultVPCExists' : require(__dirname + '/plugins/google/vpcnetwork/defaultVPCExists.js'), + 'openHTTP' : require(__dirname + '/plugins/google/vpcnetwork/openHTTP.js'), + 'instanceDefaultNetwork' : require(__dirname + '/plugins/google/vpcnetwork/instanceDefaultNetwork.js'), + 'openAllPortsEgress' : require(__dirname + '/plugins/google/vpcnetwork/openAllPortsEgress.js'), 'instanceMaxCount' : require(__dirname + '/plugins/google/compute/instanceMaxCount.js'), 'instancesMultiAz' : require(__dirname + '/plugins/google/compute/instancesMultiAz.js'), @@ -482,8 +1450,41 @@ module.exports = { 'connectSerialPortsDisabled' : require(__dirname + '/plugins/google/compute/connectSerialPortsDisabled.js'), 'csekEncryptionEnabled' : require(__dirname + '/plugins/google/compute/csekEncryptionEnabled.js'), 'osLoginEnabled' : require(__dirname + '/plugins/google/compute/osLoginEnabled.js'), - + 'instancePublicAccess' : require(__dirname + '/plugins/google/compute/instancePublicAccess.js'), + 'instanceDefaultServiceAccount' : require(__dirname + '/plugins/google/compute/instanceDefaultServiceAccount.js'), + 'shieldedVmEnabled' : require(__dirname + '/plugins/google/compute/shieldedVmEnabled.js'), + 'instanceDeletionProtection' : require(__dirname + '/plugins/google/compute/instanceDeletionProtection.js'), + 'autoscaleMinCpuUtilization' : require(__dirname + '/plugins/google/compute/autoscaleMinCpuUtilization.js'), + 'instanceDesiredMachineTypes' : require(__dirname + '/plugins/google/compute/instanceDesiredMachineTypes.js'), + 'automaticRestartEnabled' : require(__dirname + '/plugins/google/compute/automaticRestartEnabled.js'), + 'instanceTemplateMachineTypes' : require(__dirname + '/plugins/google/compute/instanceTemplateMachineTypes.js'), + 'diskMultiAz' : require(__dirname + '/plugins/google/compute/diskMultiAz.js'), + 'persistentDisksAutoDelete' : require(__dirname + '/plugins/google/compute/persistentDisksAutoDelete.js'), + 'VMDisksCMKEncrypted' : require(__dirname + '/plugins/google/compute/VMDisksCMKEncrypted.js'), + 'instanceMaintenanceBehavior' : require(__dirname + '/plugins/google/compute/instanceMaintenanceBehavior.js'), + 'instancePreemptibility' : require(__dirname + '/plugins/google/compute/instancePreemptibility.js'), + 'diskInUse' : require(__dirname + '/plugins/google/compute/diskInUse.js'), + 'osLogin2FAEnabled' : require(__dirname + '/plugins/google/compute/osLogin2FAEnabled.js'), + 'diskAutomaticBackupEnabled' : require(__dirname + '/plugins/google/compute/diskAutomaticBackupEnabled.js'), + 'diskOldSnapshots' : require(__dirname + '/plugins/google/compute/diskOldSnapshots.js'), + 'frequentlyUsedSnapshots' : require(__dirname + '/plugins/google/compute/frequentlyUsedSnapshots.js'), + 'applicationConsistentSnapshots': require(__dirname + '/plugins/google/compute/applicationConsistentSnapshots.js'), + 'deprecatedImages' : require(__dirname + '/plugins/google/compute/deprecatedImages.js'), + 'enableUsageExport' : require(__dirname + '/plugins/google/compute/enableUsageExport.js'), + 'instanceGroupAutoHealing' : require(__dirname + '/plugins/google/compute/instanceGroupAutoHealing.js'), + 'publicDiskImages' : require(__dirname + '/plugins/google/compute/publicDiskImages.js'), + 'snapshotLabelsAdded' : require(__dirname + '/plugins/google/compute/snapshotLabelsAdded.js'), + 'diskLabelsAdded' : require(__dirname + '/plugins/google/compute/diskLabelsAdded.js'), + 'imageLabelsAdded' : require(__dirname + '/plugins/google/compute/imageLabelsAdded.js'), + 'instanceLabelsAdded' : require(__dirname + '/plugins/google/compute/instanceLabelsAdded.js'), + 'confidentialComputingEnabled' : require(__dirname + '/plugins/google/compute/confidentialComputingEnabled.js'), + 'imagesCMKEncrypted' : require(__dirname + '/plugins/google/compute/imagesCMKEncrypted.js'), + 'snapshotEncryption' : require(__dirname + '/plugins/google/compute/snapshotEncryption.js'), + 'instanceNetworkExposure' : require(__dirname + '/plugins/google/compute/instanceNetworkExposure.js'), + 'computePrivilegeAnalysis' : require(__dirname + '/plugins/google/compute/computePrivilegeAnalysis.js'), 'keyRotation' : require(__dirname + '/plugins/google/cryptographickeys/keyRotation.js'), + 'keyProtectionLevel' : require(__dirname + '/plugins/google/cryptographickeys/keyProtectionLevel.js'), + 'kmsPublicAccess' : require(__dirname + '/plugins/google/cryptographickeys/kmsPublicAccess.js'), 'dbRestorable' : require(__dirname + '/plugins/google/sql/dbRestorable.js'), 'dbAutomatedBackups' : require(__dirname + '/plugins/google/sql/dbAutomatedBackups.js'), @@ -491,15 +1492,55 @@ module.exports = { 'dbPubliclyAccessible' : require(__dirname + '/plugins/google/sql/dbPubliclyAccessible.js'), 'dbSSLEnabled' : require(__dirname + '/plugins/google/sql/dbSSLEnabled.js'), 'anyHostRootAccess' : require(__dirname + '/plugins/google/sql/anyHostRootAccess.js'), + 'postgresqlLogMinError' : require(__dirname + '/plugins/google/sql/postgresqlLogMinError.js'), + 'postgresqlLogTempFiles' : require(__dirname + '/plugins/google/sql/postgresqlLogTempFiles.js'), + 'postgresqlLogMinDuration' : require(__dirname + '/plugins/google/sql/postgresqlLogMinDuration.js'), + 'postgresqlLogLockWaits' : require(__dirname + '/plugins/google/sql/postgresqlLogLockWaits.js'), + 'mysqlLocalInfile' : require(__dirname + '/plugins/google/sql/mysqlLocalInfile.js'), + 'postgresqlLogConnections' : require(__dirname + '/plugins/google/sql/postgresqlLogConnections.js'), + 'postgresqlLogDisconnections' : require(__dirname + '/plugins/google/sql/postgresqlLogDisconnections.js'), + 'postgresqlLogCheckpoints' : require(__dirname + '/plugins/google/sql/postgresqlLogCheckpoints.js'), + 'sqlNoPublicIps' : require(__dirname + '/plugins/google/sql/sqlNoPublicIps.js'), + 'sqlCrossDbOwnership' : require(__dirname + '/plugins/google/sql/sqlCrossDbOwnership.js'), + 'sqlContainedDatabaseAuth' : require(__dirname + '/plugins/google/sql/sqlContainedDatabaseAuth.js'), + 'postgresqlMaxConnections' : require(__dirname + '/plugins/google/sql/postgresqlMaxConnections.js'), + 'mysqlSlowQueryLog' : require(__dirname + '/plugins/google/sql/mysqlSlowQueryLog.js'), + 'storageAutoIncreaseEnabled' : require(__dirname + '/plugins/google/sql/storageAutoIncreaseEnabled.js'), + 'serverCertificateRotation' : require(__dirname + '/plugins/google/sql/serverCertificateRotation.js'), + 'sqlCMKEncryption' : require(__dirname + '/plugins/google/sql/sqlCMKEncryption.js'), + 'mysqlLatestVersion' : require(__dirname + '/plugins/google/sql/mysqlLatestVersion.js'), + 'postgresqlLatestVersion' : require(__dirname + '/plugins/google/sql/postgresqlLatestVersion.js'), + 'sqlInstanceLabelsAdded' : require(__dirname + '/plugins/google/sql/sqlInstanceLabelsAdded.js'), + 'mysqlSkipShowDatabase' : require(__dirname + '/plugins/google/sql/mysqlSkipShowDatabase.js'), + 'postgresqlLogHostname' : require(__dirname + '/plugins/google/sql/postgresqlLogHostname.js'), + 'postgresqlPgAuditEnabled' : require(__dirname + '/plugins/google/sql/postgresqlPgAuditEnabled.js'), + 'sqlServerExternalScripts' : require(__dirname + '/plugins/google/sql/sqlServerExternalScripts.js'), + 'sqlServerRemoteAccessDisabled' : require(__dirname + '/plugins/google/sql/sqlServerRemoteAccessDisabled.js'), + 'sqlServerTraceFlagDisabled' : require(__dirname + '/plugins/google/sql/sqlServerTraceFlagDisabled.js'), + 'sqlServerContainedDBAuth' : require(__dirname + '/plugins/google/sql/sqlServerContainedDBAuth.js'), + 'postgresqlLogMinMessages' : require(__dirname + '/plugins/google/sql/postgresqlLogMinMessages.js'), + 'postgresqlLogStatement' : require(__dirname + '/plugins/google/sql/postgresqlLogStatement.js'), + 'sqlServerUserOptionsDisabled' : require(__dirname + '/plugins/google/sql/sqlServerUserOptionsDisabled.js'), + 'sqlServerUserConnections' : require(__dirname + '/plugins/google/sql/sqlServerUserConnections.js'), + 'postgresqlLogErrorVerbosity' : require(__dirname + '/plugins/google/sql/postgresqlLogErrorVerbosity.js'), + 'postgresqlLogExecutorStats' : require(__dirname + '/plugins/google/sql/postgresqlLogExecutorStats.js'), + 'postgresqlLogParserStats' : require(__dirname + '/plugins/google/sql/postgresqlLogParserStats.js'), + 'postgresqlLogPlannerStats' : require(__dirname + '/plugins/google/sql/postgresqlLogPlannerStats.js'), 'bucketVersioning' : require(__dirname + '/plugins/google/storage/bucketVersioning.js'), 'bucketLogging' : require(__dirname + '/plugins/google/storage/bucketLogging.js'), 'bucketAllUsersPolicy' : require(__dirname + '/plugins/google/storage/bucketAllUsersPolicy.js'), + 'bucketRetentionPolicy' : require(__dirname + '/plugins/google/storage/bucketRetentionPolicy.js'), + 'bucketUniformAccess' : require(__dirname + '/plugins/google/storage/bucketUniformAccess.js'), + 'bucketLifecycleConfigured' : require(__dirname + '/plugins/google/storage/bucketLifecycleConfigured.js'), + 'bucketEncryption' : require(__dirname + '/plugins/google/storage/bucketEncryption.js'), + 'bucketLabelsAdded' : require(__dirname + '/plugins/google/storage/bucketLabelsAdded.js'), 'clbHttpsOnly' : require(__dirname + '/plugins/google/clb/clbHttpsOnly.js'), 'clbNoInstances' : require(__dirname + '/plugins/google/clb/clbNoInstances.js'), 'clbSecurityPolicyEnabled' : require(__dirname + '/plugins/google/clb/clbSecurityPolicyEnabled.js'), 'clbCDNEnabled' : require(__dirname + '/plugins/google/clb/clbCDNEnabled.js'), + 'clbLoggingEnabled' : require(__dirname + '/plugins/google/clb/clbLoggingEnabled.js'), 'serviceLimits' : require(__dirname + '/plugins/google/iam/serviceLimits.js'), 'serviceAccountAdmin' : require(__dirname + '/plugins/google/iam/serviceAccountAdmin.js'), @@ -510,6 +1551,20 @@ module.exports = { 'serviceAccountManagedKeys' : require(__dirname + '/plugins/google/iam/serviceAccountManagedKeys.js'), 'corporateEmailsOnly' : require(__dirname + '/plugins/google/iam/corporateEmailsOnly.js'), + 'serviceAccountTokenCreator' : require(__dirname + '/plugins/google/iam/serviceAccountTokenCreator.js'), + 'memberAdmin' : require(__dirname + '/plugins/google/iam/memberAdmin.js'), + 'serviceAccountRole' : require(__dirname + '/plugins/google/iam/serviceAccountRole.js'), + 'bigtableAdmin' : require(__dirname + '/plugins/google/iam/bigtableAdmin.js'), + 'bigqueryAdmin' : require(__dirname + '/plugins/google/iam/bigqueryAdmin.js'), + 'pubsubAdmin' : require(__dirname + '/plugins/google/iam/pubsubAdmin.js'), + + + 'apiKeyRotation' : require(__dirname + '/plugins/google/api/apiKeyRotation.js'), + 'apiKeyApplicationRestriction' : require(__dirname + '/plugins/google/api/apiKeyApplicationRestriction.js'), + 'apiKeyActiveServices' : require(__dirname + '/plugins/google/api/apiKeyActiveServices.js'), + 'projectAPIKeys' : require(__dirname + '/plugins/google/api/projectAPIKeys.js'), + 'apiKeyAPIRestriction' : require(__dirname + '/plugins/google/api/apiKeyAPIRestriction.js'), + 'privateEndpoint' : require(__dirname + '/plugins/google/kubernetes/privateEndpoint.js'), 'monitoringEnabled' : require(__dirname + '/plugins/google/kubernetes/monitoringEnabled.js'), 'clusterLeastPrivilege' : require(__dirname + '/plugins/google/kubernetes/clusterLeastPrivilege.js'), @@ -524,12 +1579,21 @@ module.exports = { 'autoNodeRepairEnabled' : require(__dirname + '/plugins/google/kubernetes/autoNodeRepairEnabled.js'), 'autoNodeUpgradesEnabled' : require(__dirname + '/plugins/google/kubernetes/autoNodeUpgradesEnabled.js'), 'networkPolicyEnabled' : require(__dirname + '/plugins/google/kubernetes/networkPolicyEnabled.js'), - 'podSecurityPolicyEnabled' : require(__dirname + '/plugins/google/kubernetes/podSecurityPolicyEnabled.js'), 'privateClusterEnabled' : require(__dirname + '/plugins/google/kubernetes/privateClusterEnabled.js'), 'basicAuthenticationDisabled' : require(__dirname + '/plugins/google/kubernetes/basicAuthenticationDisabled.js'), - + 'kubernetesAlphaDisabled' : require(__dirname + '/plugins/google/kubernetes/kubernetesAlphaDisabled.js'), + 'shieldedNodes' : require(__dirname + '/plugins/google/kubernetes/shieldedNodes.js'), + 'integrityMonitoringEnabled' : require(__dirname + '/plugins/google/kubernetes/integrityMonitoringEnabled.js'), + 'secureBootEnabled' : require(__dirname + '/plugins/google/kubernetes/secureBootEnabled.js'), + 'nodeEncryption' : require(__dirname + '/plugins/google/kubernetes/nodeEncryption.js'), + 'clusterEncryption' : require(__dirname + '/plugins/google/kubernetes/clusterEncryption.js'), + 'binaryAuthorizationEnabled' : require(__dirname + '/plugins/google/kubernetes/binaryAuthorizationEnabled.js'), + 'clientCertificateDisabled' : require(__dirname + '/plugins/google/kubernetes/clientCertificateDisabled.js'), + 'clusterNetworkExposure' : require(__dirname + '/plugins/google/kubernetes/clusterNetworkExposure.js'), + 'kubernetesPrivilegeAnalysis' : require(__dirname + '/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js'), 'dnsSecEnabled' : require(__dirname + '/plugins/google/dns/dnsSecEnabled.js'), 'dnsSecSigningAlgorithm' : require(__dirname + '/plugins/google/dns/dnsSecSigningAlgorithm.js'), + 'dnsZoneLabelsAdded' : require(__dirname + '/plugins/google/dns/dnsZoneLabelsAdded.js'), 'auditLoggingEnabled' : require(__dirname + '/plugins/google/logging/auditLoggingEnabled.js'), 'projectOwnershipLogging' : require(__dirname + '/plugins/google/logging/projectOwnershipLogging.js'), @@ -541,5 +1605,168 @@ module.exports = { 'vpcNetworkRouteLogging' : require(__dirname + '/plugins/google/logging/vpcNetworkRouteLogging.js'), 'vpcNetworkLogging' : require(__dirname + '/plugins/google/logging/vpcNetworkLogging.js'), 'logSinksEnabled' : require(__dirname + '/plugins/google/logging/logSinksEnabled.js'), + + 'datasetAllUsersPolicy' : require(__dirname + '/plugins/google/bigquery/datasetAllUsersPolicy.js'), + 'tablesCMKEncrypted' : require(__dirname + '/plugins/google/bigquery/tablesCMKEncrypted.js'), + 'datasetLabelsAdded' : require(__dirname + '/plugins/google/bigquery/datasetLabelsAdded.js'), + 'datasetsCMKEncrypted' : require(__dirname + '/plugins/google/bigquery/datasetsCMKEncrypted.js'), + + 'topicEncryption' : require(__dirname + '/plugins/google/pubsub/topicEncryption.js'), + 'deadLetteringEnabled' : require(__dirname + '/plugins/google/pubsub/deadLetteringEnabled.js'), + 'topicLabelsAdded' : require(__dirname + '/plugins/google/pubsub/topicLabelsAdded.js'), + 'topicAllUsersPolicy' : require(__dirname + '/plugins/google/pubsub/topicAllUsersPolicy.js'), + + 'dataflowHangedJobs' : require(__dirname + '/plugins/google/dataflow/dataflowHangedJobs.js'), + 'dataflowJobsEncryption' : require(__dirname + '/plugins/google/dataflow/dataflowJobsEncryption.js'), + + 'deleteExpiredDeployments' : require(__dirname + '/plugins/google/deploymentmanager/deleteExpiredDeployments.js'), + + 'instanceNodeCount' : require(__dirname + '/plugins/google/spanner/instanceNodeCount.js'), + + 'httpTriggerRequireHttps' : require(__dirname + '/plugins/google/cloudfunctions/httpTriggerRequireHttps.js'), + 'functionDefaultServiceAccount' : require(__dirname + '/plugins/google/cloudfunctions/functionDefaultServiceAccount.js'), + 'ingressAllTrafficDisabled' : require(__dirname + '/plugins/google/cloudfunctions/ingressAllTrafficDisabled.js'), + 'cloudFunctionLabelsAdded' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.js'), + 'cloudFunctionOldRuntime' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionOldRuntime.js'), + 'functionAllUsersPolicy' : require(__dirname + '/plugins/google/cloudfunctions/functionAllUsersPolicy.js'), + 'serverlessVPCAccess' : require(__dirname + '/plugins/google/cloudfunctions/serverlessVPCAccess.js'), + 'cloudFunctionNetworkExposure' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js'), + 'cloudFunctionsPrivilegeAnalysis': require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js'), + + 'cloudFunctionV2HttpsOnly' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js'), + 'functionV2DefaultServiceAccount': require(__dirname + '/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js'), + 'cloudFunctionV2IngressSettings': require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js'), + 'cloudFunctionV2LabelsAdded' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js'), + 'cloudFunctionV2OldRuntime' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js'), + 'cloudFunctionV2VPCConnector' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js'), + + 'computeAllowedExternalIPs' : require(__dirname + '/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js'), + 'disableAutomaticIAMGrants' : require(__dirname + '/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js'), + 'disableGuestAttributes' : require(__dirname + '/plugins/google/cloudresourcemanager/disableGuestAttributes.js'), + 'disableSerialPortAccess' : require(__dirname + '/plugins/google/cloudresourcemanager/disableSerialPortAccess.js'), + 'disableKeyCreation' : require(__dirname + '/plugins/google/cloudresourcemanager/disableKeyCreation.js'), + 'disableKeyUpload' : require(__dirname + '/plugins/google/cloudresourcemanager/disableKeyUpload.js'), + 'disableIdentityClusterCreation': require(__dirname + '/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.js'), + 'detailedAuditLoggingMode' : require(__dirname + '/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.js'), + 'uniformBucketLevelAccess' : require(__dirname + '/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.js'), + 'requireOsLogin' : require(__dirname + '/plugins/google/cloudresourcemanager/requireOsLogin.js'), + 'restrictAuthorizedNetworks' : require(__dirname + '/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.js'), + 'disableDefaultEncryption' : require(__dirname + '/plugins/google/cloudresourcemanager/disableDefaultEncryption.js'), + 'restrictLoadBalancerCreation' : require(__dirname + '/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.js'), + 'restrictSharedVPCSubnetworks' : require(__dirname + '/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.js'), + 'restrictVPCPeering' : require(__dirname + '/plugins/google/cloudresourcemanager/restrictVPCPeering.js'), + 'restrictVPNPeerIPs' : require(__dirname + '/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.js'), + 'disableVMIPForwarding' : require(__dirname + '/plugins/google/cloudresourcemanager/disableVMIPForwarding.js'), + 'trustedImageProjects' : require(__dirname + '/plugins/google/cloudresourcemanager/trustedImageProjects.js'), + 'skipDefaultNetworkCreation' : require(__dirname + '/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.js'), + 'locationBasedRestriction' : require(__dirname + '/plugins/google/cloudresourcemanager/locationBasedRestriction.js'), + 'disableServiceAccountCreation' : require(__dirname + '/plugins/google/cloudresourcemanager/disableServiceAccountCreation.js'), + 'essentialContactsConfigured' : require(__dirname + '/plugins/google/cloudresourcemanager/essentialContactsConfigured.js'), + + 'dataprocClusterLabelsAdded' : require(__dirname + '/plugins/google/dataproc/dataprocClusterLabelsAdded.js'), + 'hadoopSecureModeEnabled' : require(__dirname + '/plugins/google/dataproc/hadoopSecureModeEnabled.js'), + 'dataprocClusterEncryption' : require(__dirname + '/plugins/google/dataproc/dataprocClusterEncryption.js'), + + 'bigtableInstanceLabelsAdded' : require(__dirname + '/plugins/google/bigtable/bigtableInstanceLabelsAdded.js'), + + 'assetInventoryEnabled' : require(__dirname + '/plugins/google/serviceusage/assetInventoryEnabled.js'), + + 'accessApprovalEnabled' : require(__dirname + '/plugins/google/security/accessApprovalEnabled.js'), + + 'commentControlEnabled' : require(__dirname + '/plugins/google/cloudbuild/commentControlEnabled.js'), + 'userApprovalEnabled' : require(__dirname + '/plugins/google/cloudbuild/userApprovalEnabled.js'), + 'specificSourceBranch' : require(__dirname + '/plugins/google/cloudbuild/specificSourceBranch.js'), + 'triggerHasTags' : require(__dirname + '/plugins/google/cloudbuild/triggerHasTags.js'), + + 'environmentLabelsAdded' : require(__dirname + '/plugins/google/composer/environmentLabelsAdded.js'), + 'environmentEncryption' : require(__dirname + '/plugins/google/composer/environmentEncryption.js'), + 'envDefaultServiceAccount' : require(__dirname + '/plugins/google/composer/envDefaultServiceAccount.js'), + 'webserverPublicAccess' : require(__dirname + '/plugins/google/composer/webserverPublicAccess.js'), + + 'vertexAIDatasetLabels' : require(__dirname + '/plugins/google/vertexai/vertexAIDatasetLabels.js'), + 'vertexAIDatasetEncryption' : require(__dirname + '/plugins/google/vertexai/vertexAIDatasetEncryption.js'), + 'modelLabelsAdded' : require(__dirname + '/plugins/google/vertexai/modelLabelsAdded.js'), + 'modelEncryption' : require(__dirname + '/plugins/google/vertexai/modelEncryption.js'), + }, + alibaba: { + 'passwordMinLength' : require(__dirname + '/plugins/alibaba/ram/passwordMinLength.js'), + 'passwordRequiresNumbers' : require(__dirname + '/plugins/alibaba/ram/passwordRequiresNumbers.js'), + 'passwordRequiresSymbols' : require(__dirname + '/plugins/alibaba/ram/passwordRequiresSymbols.js'), + 'passwordRequiresLowercase' : require(__dirname + '/plugins/alibaba/ram/passwordRequiresLowercase.js'), + 'inactiveUserDisabled' : require(__dirname + '/plugins/alibaba/ram/inactiveUserDisabled.js'), + 'passwordRequiresUppercase' : require(__dirname + '/plugins/alibaba/ram/passwordRequiresUppercase.js'), + 'usersMfaEnabled' : require(__dirname + '/plugins/alibaba/ram/usersMfaEnabled.js'), + 'accessKeysRotation' : require(__dirname + '/plugins/alibaba/ram/accessKeysRotation.js'), + 'passwordNoReuse' : require(__dirname + '/plugins/alibaba/ram/passwordNoReuse.js'), + 'passwordExpiry' : require(__dirname + '/plugins/alibaba/ram/passwordExpiry.js'), + 'passwordBlockLogon' : require(__dirname + '/plugins/alibaba/ram/passwordBlockLogon.js'), + 'ramPolicyAttachments' : require(__dirname + '/plugins/alibaba/ram/ramPolicyAttachments.js'), + 'ramAdminPolicy' : require(__dirname + '/plugins/alibaba/ram/ramAdminPolicy.js'), + + 'openSSH' : require(__dirname + '/plugins/alibaba/ecs/openSSH.js'), + 'openRDP' : require(__dirname + '/plugins/alibaba/ecs/openRDP.js'), + 'openDNS' : require(__dirname + '/plugins/alibaba/ecs/openDNS.js'), + 'openCIFS' : require(__dirname + '/plugins/alibaba/ecs/openCIFS.js'), + 'openDocker' : require(__dirname + '/plugins/alibaba/ecs/openDocker.js'), + 'openPostgreSQL' : require(__dirname + '/plugins/alibaba/ecs/openPostgreSQL.js'), + 'openElasticsearch' : require(__dirname + '/plugins/alibaba/ecs/openElasticsearch.js'), + 'openFTP' : require(__dirname + '/plugins/alibaba/ecs/openFTP.js'), + 'openHadoopNameNode' : require(__dirname + '/plugins/alibaba/ecs/openHadoopNameNode.js'), + 'openHadoopNameNodeWebUI' : require(__dirname + '/plugins/alibaba/ecs/openHadoopNameNodeWebUI.js'), + 'openKibana' : require(__dirname + '/plugins/alibaba/ecs/openKibana.js'), + 'openMySQL' : require(__dirname + '/plugins/alibaba/ecs/openMySQL.js'), + 'openNetBIOS' : require(__dirname + '/plugins/alibaba/ecs/openNetBIOS.js'), + 'openOracle' : require(__dirname + '/plugins/alibaba/ecs/openOracle.js'), + 'dataDisksEncrypted' : require(__dirname + '/plugins/alibaba/ecs/dataDisksEncrypted.js'), + 'openCustomPorts' : require(__dirname + '/plugins/alibaba/ecs/openCustomPorts.js'), + 'openOracleAutoDataWarehouse' : require(__dirname + '/plugins/alibaba/ecs/openOracleAutoDataWarehouse.js'), + 'openSalt' : require(__dirname + '/plugins/alibaba/ecs/openSalt.js'), + 'openSMTP' : require(__dirname + '/plugins/alibaba/ecs/openSMTP.js'), + 'openSMBoTCP' : require(__dirname + '/plugins/alibaba/ecs/openSMBoTCP.js'), + 'openSQLServer' : require(__dirname + '/plugins/alibaba/ecs/openSQLServer.js'), + 'openTelnet' : require(__dirname + '/plugins/alibaba/ecs/openTelnet.js'), + 'openVNCClient' : require(__dirname + '/plugins/alibaba/ecs/openVNCClient.js'), + 'openVNCServer' : require(__dirname + '/plugins/alibaba/ecs/openVNCServer.js'), + 'openAllPortsProtocols' : require(__dirname + '/plugins/alibaba/ecs/openAllPortsProtocols.js'), + 'systemDisksEncrypted' : require(__dirname + '/plugins/alibaba/ecs/systemDisksEncrypted.js'), + + 'bucketLoggingEnabled' : require(__dirname + '/plugins/alibaba/oss/bucketLoggingEnabled.js'), + 'bucketPayByRequester' : require(__dirname + '/plugins/alibaba/oss/bucketPayByRequester.js'), + 'ossBucketPrivate' : require(__dirname + '/plugins/alibaba/oss/ossBucketPrivate.js'), + 'ossBucketLifecycle' : require(__dirname + '/plugins/alibaba/oss/ossBucketLifecycle.js'), + 'bucketCmkEncrypted' : require(__dirname + '/plugins/alibaba/oss/bucketCmkEncrypted.js'), + 'ossBucketVersioning' : require(__dirname + '/plugins/alibaba/oss/ossBucketVersioning.js'), + 'ossBucketTransferAcceleration' : require(__dirname + '/plugins/alibaba/oss/ossBucketTransferAcceleration.js'), + 'bucketCrossRegionReplication' : require(__dirname + '/plugins/alibaba/oss/bucketCrossRegionReplication.js'), + + 'ossBucketIpRestriction' : require(__dirname + '/plugins/alibaba/oss/ossBucketIpRestriction.js'), + 'ossBucketSecureTransport' : require(__dirname + '/plugins/alibaba/oss/ossBucketSecureTransport.js'), + + 'rdsLogDuration' : require(__dirname + '/plugins/alibaba/rds/rdsLogDuration.js'), + 'rdsSslEncryptionEnabled' : require(__dirname + '/plugins/alibaba/rds/rdsSslEncryptionEnabled.js'), + 'rdsAuditingEnabled' : require(__dirname + '/plugins/alibaba/rds/rdsAuditingEnabled.js'), + 'rdsPublicAccess' : require(__dirname + '/plugins/alibaba/rds/rdsPublicAccess.js'), + 'rdsLogConnectionEnabled' : require(__dirname + '/plugins/alibaba/rds/rdsLogConnectionEnabled.js'), + 'rdsLogDisconnectionsEnabled' : require(__dirname + '/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.js'), + 'rdsSqlAuditRetentionPeriod' : require(__dirname + '/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.js'), + 'rdsTdeEnabled' : require(__dirname + '/plugins/alibaba/rds/rdsTdeEnabled.js'), + + 'actiontrailGlobalExportLogs' : require(__dirname + '/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.js'), + 'actiontrailBucketPrivate' : require(__dirname + '/plugins/alibaba/actiontrail/actiontrailBucketPrivate.js'), + + 'apiProtocol' : require(__dirname + '/plugins/alibaba/apigateway/apiProtocol.js'), + 'apiGroupTlsVersion' : require(__dirname + '/plugins/alibaba/apigateway/apiGroupTlsVersion.js'), + + 'webDashboardDisabled' : require(__dirname + '/plugins/alibaba/ack/webDashboardDisabled.js'), + 'multipleIPmode' : require(__dirname + '/plugins/alibaba/ack/multipleIPmode.js'), + 'ackPrivateClusterEnabled' : require(__dirname + '/plugins/alibaba/ack/ackPrivateClusterEnabled.js'), + 'networkPolicyEnabled' : require(__dirname + '/plugins/alibaba/ack/networkPolicyEnabled.js'), + 'logServiceEnabled' : require(__dirname + '/plugins/alibaba/ack/logServiceEnabled.js'), + 'cloudMonitorEnabled' : require(__dirname + '/plugins/alibaba/ack/cloudMonitorEnabled.js'), + + 'securityCenterEdition' : require(__dirname + '/plugins/alibaba/securitycenter/securityCenterEdition.js'), + 'securityAgentInstalled' : require(__dirname + '/plugins/alibaba/securitycenter/securityAgentInstalled.js'), + 'securityNotificationsEnabled' : require(__dirname + '/plugins/alibaba/securitycenter/securityNotificationsEnabled.js'), + 'vulnerabilityScanEnabled' : require(__dirname + '/plugins/alibaba/securitycenter/vulnerabilityScanEnabled.js') } }; diff --git a/exports.spec.js b/exports.spec.js index 02635e468..2bfbd7b19 100644 --- a/exports.spec.js +++ b/exports.spec.js @@ -1,4 +1,3 @@ -var assert = require('assert'); var expect = require('chai').expect; var tests = require('./exports'); @@ -95,6 +94,15 @@ describe('exports', function () { expect(lTest.remediation_min_version, `Test: ${test} remediation_min_version parameter is not a string`).to.be.a('string'); expect(lTest.remediation_min_version.length, `Test: ${test} remediation_min_version parameter length is not 12`).to.equal(12); + + if (lTest.remediation_inputs) { + expect(lTest.remediation_inputs, `Test: ${test} remediation_inputs is not an object`).to.be.an('object'); + Object.keys(lTest.remediation_inputs).forEach(function(rInput){ + expect(lTest.remediation_inputs[rInput].name, `Test: ${test} remediation_inputs.${rInput}.name is not a string`).to.be.a('string'); + expect(lTest.remediation_inputs[rInput].description, `Test: ${test} remediation_inputs.${rInput}.description is not a string`).to.be.a('string'); + expect(lTest.remediation_inputs[rInput].regex, `Test: ${test} remediation_inputs.${rInput}.regex is not a string`).to.be.a('string'); + }); + } } if (lTest.rollback) { diff --git a/helpers/alibaba/functions.js b/helpers/alibaba/functions.js new file mode 100644 index 000000000..2785f8002 --- /dev/null +++ b/helpers/alibaba/functions.js @@ -0,0 +1,157 @@ +var helpers = require('../shared.js'); + +function defaultRegion(settings) { + if (settings.defaultRegion) return settings.defaultRegion; + return 'cn-hangzhou'; +} + +function createArn(service, account, resourceType, resourceId, region) { + if (!region) region = ''; + return `arn:acs:${service}:${region}:${account}:${resourceType}/${resourceId}`; +} + +function findOpenPorts(cache, groups, ports, service, region, results) { + var found = false; + var defRegion = defaultRegion({}); + + for (var group of groups) { + if (!group.SecurityGroupId) continue; + + var accountId = helpers.addSource(cache, {}, ['sts', 'GetCallerIdentity', defRegion, 'data']); + + var resource = createArn('ecs', accountId, 'securitygroup', group.SecurityGroupId, region); + + var describeSecurityGroupAttribute = helpers.addSource(cache, {}, + ['ecs', 'DescribeSecurityGroupAttribute', region, group.SecurityGroupId]); + + if (!describeSecurityGroupAttribute || describeSecurityGroupAttribute.err || !describeSecurityGroupAttribute.data) { + helpers.addResult(results, 3, + `Unable to query security group attributes: ${describeSecurityGroupAttribute}`, region, resource); + continue; + } + + var string; + var openV4Ports = []; + + if (describeSecurityGroupAttribute.data.Permissions && describeSecurityGroupAttribute.data.Permissions.Permission && describeSecurityGroupAttribute.data.Permissions.Permission.length){ + for (var permission of describeSecurityGroupAttribute.data.Permissions.Permission) { + if (permission.Direction && permission.Direction !== 'ingress') continue; + let protocol = permission.IpProtocol.toLowerCase(); + if (permission.SourceCidrIp === '0.0.0.0/0' && ports[protocol]) { + for (var port of ports[protocol]) { + let fromPort = (Number(permission.PortRange.split('/')[0])) ? + Number(permission.PortRange.split('/')[0]) : Number(permission.PortRange); + let toPort = (Number(permission.PortRange.split('/')[1])) ? + Number(permission.PortRange.split('/')[1]) : Number(permission.PortRange); + + if (port.toString().indexOf('-') > -1) { + var rangeFrom = Number(port.split('-')[0]); + var rangeTo = Number(port.split('-')[1]); + + for (let i = rangeFrom; i <= rangeTo; i++) { + if (fromPort<= i && toPort >= i) { + string = `some of ${permission.IpProtocol}:${port}`; + if (openV4Ports.indexOf(string) === -1) openV4Ports.push(string); + found = true; + break; + } + } + } else { + port = Number(port); + if (fromPort <= port && toPort >= port) { + string = `${permission.IpProtocol}:${port}`; + if (openV4Ports.indexOf(string) === -1) openV4Ports.push(string); + found = true; + } + } + } + } + } + } + + if (openV4Ports.length) { + var resultsString = ''; + if (openV4Ports.length) { + resultsString = `Security group: ${group.SecurityGroupId} has ${service}:${openV4Ports.join(', ')} open to 0.0.0.0/0`; + } + + helpers.addResult(results, 2, resultsString, region, resource); + } + } + + if (!found) { + helpers.addResult(results, 0, 'No public open ports found', region); + } + + return; +} + +function getEncryptionLevel(kmsKey, encryptionLevels) { + if (kmsKey.Origin) { + if (kmsKey.Origin === 'Aliyun_KMS') { + if (kmsKey.ProtectionLevel) { + if (kmsKey.ProtectionLevel.toUpperCase() == 'SOFTWARE') return encryptionLevels.indexOf('alibabacmk'); + if (kmsKey.ProtectionLevel.toUpperCase() == 'HSM') return encryptionLevels.indexOf('cloudhsm'); + } + } + if (kmsKey.Origin === 'EXTERNAL') return encryptionLevels.indexOf('externalcmk'); + } + + return encryptionLevels.indexOf('none'); +} + +function normalizePolicyDocument(doc) { + /* + Convert a policy document for RAM into a normalized object that can be used + by plugins to check policy attributes. + Returns an array of statements with normalized effect, action, and resource. + */ + + if (typeof doc === 'string') { + // Need to parse to JSON + try { + // Need to urldecode + if (doc.charAt(0) === '%') doc = decodeURIComponent(doc); + doc = JSON.parse(doc); + } catch (e) { + //Could not parse policy document into JSON + return false; + } + } + + if (typeof doc !== 'object') { + //Could not parse policy document. Not valid JSON + return false; + } + + if (!doc.Statement) return false; + + var statementsToReturn = []; + + // If Statement is an object, convert to array + if (!Array.isArray(doc.Statement)) doc.Statement = [doc.Statement]; + + for (var statement of doc.Statement) { + if (!statement.Effect || !statement.Effect.length || + !statement.Action || !statement.Action.length) { + break; + } + + if (typeof statement.Effect !== 'string') break; + + if (!Array.isArray(statement.Action)) statement.Action = [statement.Action]; + if (statement.Resource && !Array.isArray(statement.Resource)) statement.Resource = [statement.Resource]; + + statementsToReturn.push(statement); + } + + return statementsToReturn; +} + +module.exports = { + defaultRegion: defaultRegion, + createArn: createArn, + findOpenPorts: findOpenPorts, + getEncryptionLevel: getEncryptionLevel, + normalizePolicyDocument: normalizePolicyDocument +}; \ No newline at end of file diff --git a/helpers/alibaba/index.js b/helpers/alibaba/index.js new file mode 100644 index 000000000..149b27500 --- /dev/null +++ b/helpers/alibaba/index.js @@ -0,0 +1,20 @@ +var shared = require(__dirname + '/../shared.js'); +var functions = require('./functions.js'); +var chinaRegions = require('./regions_china.js'); +var regRegions = require('./regions.js'); + +var regions = function(settings) { + if (settings.china) return chinaRegions; + return regRegions; +}; + +var helpers = { + regions: regions, + MAX_REGIONS_AT_A_TIME: 6, + ENCRYPTION_LEVELS: ['none', 'sse', 'cloudkms', 'alibabacmk', 'externalcmk', 'cloudhsm'] +}; + +for (var s in shared) helpers[s] = shared[s]; +for (var f in functions) helpers[f] = functions[f]; + +module.exports = helpers; diff --git a/helpers/alibaba/regions.js b/helpers/alibaba/regions.js new file mode 100644 index 000000000..7d25c104f --- /dev/null +++ b/helpers/alibaba/regions.js @@ -0,0 +1,37 @@ +// Source: https://www.alibabacloud.com/help/en/doc-detail/40654.htm + +var regions = [ + 'ap-southeast-1', // Singapore + 'ap-southeast-2', // Australia (Sydney) + 'ap-southeast-3', // Malaysia (Kuala Lumpur) + 'ap-southeast-5', // Indonesia (Jakarta) + 'ap-northeast-1', // Japan (Tokyo) + 'ap-southeast-6', // Philippines (Manila) + 'ap-south-1', // India (Mumbai) + 'eu-central-1', // Germany (Frankfurt) + 'eu-west-1', // UK (London) + 'us-west-1', // US (Silicon Valley) + 'us-east-1', // US (Virginia) + 'me-east-1', // UAE (Dubai) +]; + +module.exports = { + default: ['cn-hangzhou'], + all: regions, + ecs: regions, + apigateway: [ 'ap-southeast-6', 'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', + 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'us-west-1', 'us-east-1', 'me-east-1' + ], + polardb: regions, + ram: ['cn-hangzhou'], + vpc: regions, + rds: regions, + sts: ['cn-hangzhou'], + oss: ['cn-hangzhou'], + kms: regions, + actiontrail: [ 'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', + 'eu-central-1', 'eu-west-1', 'us-west-1', 'us-east-1', 'me-east-1' + ], + ack: ['cn-hangzhou'], + tds: ['ap-southeast-3', 'ap-southeast-1'] +}; diff --git a/helpers/alibaba/regions_china.js b/helpers/alibaba/regions_china.js new file mode 100644 index 000000000..d24c4398f --- /dev/null +++ b/helpers/alibaba/regions_china.js @@ -0,0 +1,55 @@ +// Source: https://www.alibabacloud.com/help/en/doc-detail/40654.htm + +var regions = [ + 'cn-hangzhou', // China (Hangzhou) + 'cn-shanghai', // China (Shanghai) + 'cn-qingdao', // China (Qingdao) + 'cn-beijing', // China (Beijing) + 'cn-zhangjiakou', // China (Zhangjiakou) + 'cn-huhehaote', // China (Hohhot) + 'cn-wulanchabu', // China (Ulanqab) + 'cn-shenzhen', // China (Shenzhen) + 'cn-heyuan', // China (Heyuan) + 'cn-chengdu', // China (Chengdu) + 'cn-hongkong', // China (Hong Kong) + 'cn-guangzhou', // China (Guangzhou) + 'cn-nanjing', // China (Nanjing) + 'ap-southeast-1', // Singapore + 'ap-southeast-2', // Australia (Sydney) + 'ap-southeast-3', // Malaysia (Kuala Lumpur) + 'ap-southeast-5', // Indonesia (Jakarta) + 'ap-northeast-1', // Japan (Tokyo) + 'ap-southeast-6', // Philippines (Manila) + 'ap-south-1', // India (Mumbai) + 'eu-central-1', // Germany (Frankfurt) + 'eu-west-1', // UK (London) + 'us-west-1', // US (Silicon Valley) + 'us-east-1', // US (Virginia) + 'me-east-1', // UAE (Dubai) +]; + +module.exports = { + default: ['cn-hangzhou'], + all: regions, + ecs: [ 'cn-hangzhou', 'cn-shanghai', 'cn-qingdao', 'cn-beijing', 'cn-zhangjiakou', 'cn-huhehaote', 'cn-wulanchabu', + 'cn-shenzhen', 'cn-heyuan', 'cn-chengdu', 'cn-hongkong', 'cn-guangzhou', 'cn-nanjing', 'ap-southeast-1', 'ap-southeast-2', + 'ap-southeast-3', 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'us-west-1', 'us-east-1', 'me-east-1' + ], + apigateway: [ 'cn-hangzhou', 'cn-shanghai', 'cn-qingdao', 'cn-beijing', 'cn-zhangjiakou', 'cn-huhehaote', 'ap-southeast-6', + 'cn-shenzhen', 'cn-heyuan', 'cn-chengdu', 'cn-hongkong', 'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', + 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'us-west-1', 'us-east-1', 'me-east-1' + ], + polardb: regions, + ram: ['cn-hangzhou'], + vpc: regions, + rds: regions, + sts: ['cn-hangzhou'], + oss: ['cn-hangzhou'], + kms: regions, + actiontrail: [ 'cn-hangzhou', 'cn-shanghai', 'cn-qingdao', 'cn-beijing', 'cn-zhangjiakou', 'cn-huhehaote', 'cn-wulanchabu', + 'cn-shenzhen', 'cn-heyuan', 'cn-chengdu', 'cn-hongkong', 'cn-guangzhou', 'cn-nanjing', 'ap-southeast-1', 'ap-southeast-2', + 'ap-southeast-3', 'ap-southeast-5', 'ap-northeast-1', 'ap-south-1', 'eu-central-1', 'eu-west-1', 'us-west-1', 'us-east-1', 'me-east-1' + ], + ack: ['cn-hangzhou'], + tds: ['cn-hangzhou', 'ap-southeast-3', 'ap-southeast-1'] +}; diff --git a/helpers/asl.js b/helpers/asl.js new file mode 100644 index 000000000..cce219ec6 --- /dev/null +++ b/helpers/asl.js @@ -0,0 +1,279 @@ +var parse = function(obj, path) { + if (typeof path == 'string') path = path.split('.'); + if (Array.isArray(path) && path.length) { + var localPath = path.shift(); + if (obj[localPath] || typeof obj[localPath] === 'boolean') { + return parse(obj[localPath], path); + } else { + return 'not set'; + } + } else { + return obj; + } +}; + +var transform = function(val, transformation) { + if (transformation == 'DATE') { + return new Date(val); + } else if (transformation == 'INTEGER') { + return parseInt(val); + } else if (transformation == 'STRING') { + return val.toString(); + } else if (transformation == 'DAYSFROM') { + // Return the number of days between the date and now + var now = new Date(); + var then = new Date(val); + var timeDiff = then.getTime() - now.getTime(); + var diff = Math.round(timeDiff / (1000 * 3600 * 24)); + return diff; + } else if (transformation == 'COUNT') { + return val.length; + } else if (transformation == 'EACH') { + return val; + } else if (transformation == 'TOLOWERCASE') { + return val.toLowerCase(); + } else { + return val; + } +}; + +function evaluateConditions(obj, conditions){ + var preVal = {}; // place to hold the result evaluated till now + for (let i in conditions){ + let condition = conditions[i]; + let value = validate(obj,condition); + + if (!condition.logical){ + // no logical operation means + // first element of the conditions + if ( i == 0){ + // first element in the condition list,so set the preVal + preVal = value; + } else { + // error as no logical condition and not the first element + //err + console.error('No logical operator found and this is not the first element'); + } + } else { + if ( i == 0){ + // first element in the condition list,and with logical operation. Error case + console.error('logical operator found in first element'); + } else { + // this is not first element in array and also logical op is here. use the preVal and evaluate it + if (preVal){ + //as this is not first element in array preVal should be set. + //other wise the condition is not properly formed as AND OR are binary ops + if ( condition.logical === 'OR'){ + preVal.status = preVal.status || value.status; + } else if (condition.logical === 'AND'){ + preVal.status = preVal.status && value.status; + } else { + // unsupported operator. + console.error('wrong logical operator mentioned'); + } + preVal.message = preVal.message.concat(', ',value.message); + } else { + //condition is not properly formed as AND OR are binary ops + console.error('condition is malformed'); + } + } + } + } + + return preVal; +} + +var validate = function(obj, condition) { + var result = 0; + var message = []; + var override = false; + + // Extract the values for the conditions + + if (condition.property) { + var conditionResult = 0; + condition.parsed = parse(obj, condition.property); + + // Transform the property if required + if (condition.transform) { + condition.parsed = transform(condition.parsed, condition.transform); + } + + // Compare the property with the operator + if (condition.parsed === 'not set'){ + conditionResult = 2; + message.push(`${condition.property}: not set to any value`); + } else if (condition.op) { + if (condition.op == 'EQ') { + if (condition.parsed == condition.value) { + message.push(`${condition.property}: ${condition.parsed} matched: ${condition.value}`); + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} did not match: ${condition.value}`); + } + } else if (condition.op == 'GT') { + if (condition.parsed > condition.value) { + message.push(`${condition.property}: count of ${condition.parsed} was greater than: ${condition.value}`); + } else { + conditionResult = 2; + message.push(`${condition.property}: count of ${condition.parsed} was not greater than: ${condition.value}`); + } + } else if (condition.op == 'NE') { + if (condition.parsed !== condition.value) { + message.push(`${condition.property}: ${condition.parsed} is not: ${condition.value}`); + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} is: ${condition.value}`); + } + } else if (condition.op == 'MATCHES') { + var userRegex = RegExp(condition.value); + if (userRegex.test(condition.parsed)) { + message.push(`${condition.property}: ${condition.parsed} matches the regex: ${condition.value}`); + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); + } + } else if (condition.op == 'EXISTS') { + if (condition.parsed !== 'not set') { + message.push(`${condition.property}: set to ${condition.parsed}`); + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed}`); + } + } else if (condition.op == 'ISTRUE') { + if (condition.parsed) { + message.push(`${condition.property} is true`); + } else { + conditionResult = 2; + message.push(`${condition.property} is false`); + } + } else if (condition.op == 'ISFALSE') { + if (!condition.parsed) { + message.push(`${condition.property} is false`); + } else { + conditionResult = 2; + message.push(`${condition.property} is true`); + } + } else if (condition.op == 'CONTAINS') { + if (condition.parsed.includes(condition.value)) { + message.push(`${condition.property}: ${condition.value} found in ${condition.parsed}`); + } else { + conditionResult = 2; + message.push(`${condition.value} not found in ${condition.parsed}`); + } + } + } else if (condition.transform && condition.transform == 'EACH' && condition.conditions) { + // Recurse into the same function + var subProcessed = []; + condition.parsed.forEach(function(parsed) { + subProcessed.push(validate(parsed, condition.conditions)); + }); + subProcessed.forEach(function(sub) { + if (sub.status) conditionResult = sub.status; + if (sub.message) message.push(sub.message); + }); + } + if (condition.invert) conditionResult = (conditionResult ? 0 : 2); + + if (condition.override && !conditionResult) override = true; + if (conditionResult) result = conditionResult; + } + + + if (result && override) result = 0; + + if (!message.length) { + message = ['The resource matched all required conditions']; + } + + return { + status: result, + message: message.join(', ') + }; +}; + +var asl = function(source, input, resourceMap, callback) { + if (!source || !input) return callback('No source or input provided'); + if (!input.apis || !input.apis[0]) return callback('No APIs provided for input'); + if (!input.conditions || !input.conditions.length) return callback('No conditions provided for input'); + + // Split apis into service:api + // TODO: support conditions that use different APIs + var service = input.conditions[0].service; + var subService = (input.conditions[0].subservice) ? input.conditions[0].subservice : null; + var api = input.conditions[0].api; + var resourcePath; + if (resourceMap && + resourceMap[service] && + resourceMap[service][api]) { + resourcePath = resourceMap[service][api]; + } + + if (!source[service]) return callback(`Source data did not contain service: ${service}`); + if (subService && !source[service][subService]) return callback(`Source data did not contain service: ${service}:${subService}`); + if (subService && !source[service][subService][api]) return callback(`Source data did not contain API: ${api}`); + if (!subService && !source[service][api]) return callback(`Source data did not contain API: ${api}`); + + var results = []; + let data = subService ? source[service][subService][api] : source[service][api]; + + for (var region in data) { + var regionVal = data; + if (typeof regionVal !== 'object') continue; + if (regionVal.err) { + results.push({ + status: 3, + message: regionVal.err.message || 'Error', + region: region + }); + } else if (regionVal.data && regionVal.data.length) { + // It's an array, loop + regionVal.data.forEach(function(regionData) { + var validated = evaluateConditions(regionData, input.conditions); + var parsedResource = parse(regionData, resourcePath); + if (typeof parsedResource !== 'string') parsedResource = null; + + results.push({ + status: validated.status, + resource: parsedResource, + message: validated.message, + region: region + }); + }); + } else { + for (var resourceName in regionVal) { + var resourceObj = regionVal[resourceName]; + if (resourceObj.err) { + results.push({ + status: 3, + resource: resourceName, + message: resourceObj.err.message || 'Error', + region: region + }); + } else if (!resourceObj.data) { + results.push({ + status: 3, + resource: resourceName, + message: 'No data returned', + region: region + }); + } else { + var validated = evaluateConditions(resourceObj.data, input.conditions); + var parsedResource = parse(resourceObj.data, resourcePath); + if (typeof parsedResource !== 'string') parsedResource = null; + + results.push({ + status: validated.status, + resource: parsedResource ? parsedResource : resourceName, + message: validated.message, + region: region + }); + } + } + } + } + + callback(null, results, data); +}; + +module.exports = asl; \ No newline at end of file diff --git a/helpers/asl/asl-1.js b/helpers/asl/asl-1.js new file mode 100644 index 000000000..67569135b --- /dev/null +++ b/helpers/asl/asl-1.js @@ -0,0 +1,1151 @@ +var parse = function(obj, path, region, cloud, accountId, resourceId) { + // Enhanced path splitting: ensure [*] is always its own segment + if (typeof path === 'string') { + // Split on . but keep [*] as its own segment + // Example: networkAcls.ipRules[*].value => ['networkAcls', 'ipRules', '[*]', 'value'] + path = path + .replace(/\[\*\]/g, '.[$*].') // temporarily mark wildcards + .split('.') + .filter(Boolean) + .map(seg => seg === '[$*]' ? '[*]' : seg); // restore wildcard + } + + if (Array.isArray(path) && path.length) { + var localPath = path.shift(); + // Handle array wildcard syntax [*] + if (localPath === '[*]') { + if (Array.isArray(obj)) { + var results = []; + obj.forEach(function(item) { + var pathCopy = path.slice(); + var result = parse(item, pathCopy, region, cloud, accountId, resourceId); + if (Array.isArray(result)) { + results = results.concat(result); + } else if (result !== 'not set') { + results.push(result); + } + }); + return results.length > 0 ? results : 'not set'; + } else { + return 'not set'; + } + } + if (obj && Object.prototype.hasOwnProperty.call(obj, localPath)) { + return parse(obj[localPath], path, region, cloud, accountId, resourceId); + } else { + return 'not set'; + } + } else if (Array.isArray(path) && path.length === 0) { + return obj; + } else if (!Array.isArray(obj) && path && path.length) { + if (obj[path] || typeof obj[path] === 'boolean') return obj[path]; + else { + if (cloud==='aws' && path.startsWith('arn:aws')) { + const template_string = path; + const placeholders = template_string.match(/{([^{}]+)}/g); + let extracted_values = []; + if (placeholders) { + extracted_values = placeholders.map(placeholder => { + const key = placeholder.slice(1, -1); + if (key === 'value') return [obj][0]; + else return obj[key]; + }); + } + // Replace other variables + let converted_string = template_string + .replace(/\{region\}/g, region) + .replace(/\{cloudAccount\}/g, accountId) + .replace(/\{resourceId\}/g, resourceId); + placeholders.forEach((placeholder, index) => { + if (index === placeholders.length - 1) { + converted_string = converted_string.replace(placeholder, extracted_values.pop()); + } else { + converted_string = converted_string.replace(placeholder, extracted_values.shift()); + } + }); + path = converted_string; + return path; + } else return 'not set'; + } + } else if (Array.isArray(obj)) { + return obj; + } else { + return obj; + } +}; + +var inCidr = function(ip, cidr) { + if (!ip || !cidr || typeof ip !== 'string' || typeof cidr !== 'string') { + return { result: false, error: 'Malformed IP' }; + } + + ip = ip.trim(); + cidr = cidr.trim(); + + var isIpv6Cidr = cidr.includes(':'); + var isIpv6Ip = ip.includes(':'); + + if (isIpv6Cidr && !isIpv6Ip) { + return { result: false, error: 'Cannot check IPv4 address against IPv6 CIDR' }; + } + if (!isIpv6Cidr && isIpv6Ip) { + return { result: false, error: 'Cannot check IPv6 address against IPv4 CIDR' }; + } + + if (isIpv6Cidr && isIpv6Ip) { + return inCidrIPv6(ip, cidr); + } else { + return inCidrIPv4(ip, cidr); + } +}; + +var inCidrIPv4 = function(ip, cidr) { + var cidrMatch = cidr.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/); + if (!cidrMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrIp = cidrMatch[1]; + var prefixLength = parseInt(cidrMatch[2]); + + var cidrParts = cidrIp.split('.').map(function(part) { return parseInt(part); }); + if (cidrParts.some(function(part) { return isNaN(part) || part < 0 || part > 255; }) || prefixLength < 0 || prefixLength > 32) { + return { result: false, error: 'Malformed IP' }; + } + + var ipMatch = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/); + if (!ipMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var ipParts = ipMatch.slice(1, 5).map(function(part) { return parseInt(part); }); + if (ipParts.some(function(part) { return isNaN(part) || part < 0 || part > 255; })) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrInt = ((cidrParts[0] << 24) + (cidrParts[1] << 16) + (cidrParts[2] << 8) + cidrParts[3]) >>> 0; + var ipInt = ((ipParts[0] << 24) + (ipParts[1] << 16) + (ipParts[2] << 8) + ipParts[3]) >>> 0; + + var mask = (0xFFFFFFFF << (32 - prefixLength)) >>> 0; + var networkInt = (cidrInt & mask) >>> 0; + var broadcastInt = (networkInt | (0xFFFFFFFF >>> prefixLength)) >>> 0; + + var isInRange = ipInt >= networkInt && ipInt <= broadcastInt; + + var result = { + result: isInRange, + error: null, + message: isInRange ? 'IP in range' : 'IP not in range' + }; + + return result; +}; + +var inCidrIPv6 = function(ip, cidr) { + var cidrMatch = cidr.match(/^([0-9a-fA-F:]+)\/(\d{1,3})$/); + if (!cidrMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrIp = cidrMatch[1]; + var prefixLength = parseInt(cidrMatch[2]); + + if (prefixLength < 0 || prefixLength > 128) { + return { result: false, error: 'Malformed IP' }; + } + + var ipv6Pattern = /^[0-9a-fA-F:]+$/; + if (!ipv6Pattern.test(ip) || !ipv6Pattern.test(cidrIp)) { + return { result: false, error: 'Malformed IP' }; + } + + try { + var expandedCidr = expandIPv6Simple(cidrIp); + var expandedIp = expandIPv6Simple(ip); + + if (!expandedCidr || !expandedIp) { + return { result: false, error: 'Malformed IP' }; + } + + var prefixChars = Math.floor(prefixLength / 4); + var cidrPrefix = expandedCidr.substring(0, prefixChars); + var ipPrefix = expandedIp.substring(0, prefixChars); + + var isInRange = ipPrefix === cidrPrefix; + + var result = { + result: isInRange, + error: null, + message: isInRange ? 'IP in range' : 'IP not in range' + }; + + return result; + } catch (e) { + return { result: false, error: 'Malformed IP' }; + } +}; + +var expandIPv6Simple = function(ip) { + try { + // Handle :: notation (simplified) + if (ip.includes('::')) { + var parts = ip.split('::'); + if (parts.length > 2) return null; + + var left = parts[0] ? parts[0].split(':') : []; + var right = parts[1] ? parts[1].split(':') : []; + + var totalParts = left.length + right.length; + var missingParts = 8 - totalParts; + + if (missingParts < 0) return null; + + var expanded = left.concat(Array(missingParts).fill('0000')).concat(right); + return expanded.map(function(part) { return part.padStart(4, '0'); }).join(''); + } else { + var ipParts = ip.split(':'); + if (ipParts.length !== 8) return null; + return ipParts.map(function(part) { return part.padStart(4, '0'); }).join(''); + } + } catch (e) { + return null; + } +}; + +var transformToIpRange = function(val) { + if (typeof val !== 'string') { + return { error: 'Value must be a string for IPRANGE transformation' }; + } + + var trimmedVal = val.trim(); + + var ipv4CidrPattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/; + var ipv4SinglePattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + var ipv6CidrPattern = /^([0-9a-fA-F:]+)\/(\d{1,3})$/; + var ipv6SinglePattern = /^[0-9a-fA-F:]+$/; + + var isValidFormat = ipv4CidrPattern.test(trimmedVal) || + ipv4SinglePattern.test(trimmedVal) || + ipv6CidrPattern.test(trimmedVal) || + ipv6SinglePattern.test(trimmedVal); + + if (!isValidFormat) { + return { error: 'Value must be a valid IP or CIDR format (e.g., "192.168.1.100" or "192.168.1.0/24")' }; + } + + var processedVal = trimmedVal; + if (ipv4SinglePattern.test(trimmedVal)) { + processedVal = trimmedVal + '/32'; + } else if (ipv6SinglePattern.test(trimmedVal) && !trimmedVal.includes('/')) { + processedVal = trimmedVal + '/128'; + } + + var result = { + type: 'iprange', + original: val, + cidr: processedVal + }; + + return result; +}; + +var transform = function(val, transformation) { + if (transformation == 'DATE') { + return new Date(val); + } else if (transformation == 'INTEGER') { + return parseInt(val); + } else if (transformation == 'STRING') { + return val.toString(); + } else if (transformation == 'DAYSFROM') { + // Return the number of days between the date and now + var now = new Date(); + var then = new Date(val); + var timeDiff = then.getTime() - now.getTime(); + var diff = (Math.round(timeDiff / (1000 * 3600 * 24))); + return diff; + } else if (transformation == 'COUNT') { + return val.length; + } else if (transformation == 'EACH') { + return val; + } else if (transformation == 'TOLOWERCASE') { + return val.toLowerCase(); + } else if (transformation == 'IPRANGE') { + return transformToIpRange(val); + } else { + return val; + } +}; + +var compositeResult = function(inputResultsArr, resource, region, results, logical) { + let failingResults = []; + let passingResults = []; + + // No results to process, exit early + if (!inputResultsArr || !inputResultsArr.length) { + results.push({ + status: 2, + resource: resource, + message: 'No results to evaluate', + region: region + }); + return; + } + + // If only one result, always use its status and message + if (inputResultsArr.length === 1) { + results.push({ + status: inputResultsArr[0].status, + resource: resource, + message: inputResultsArr[0].message, + region: region + }); + return; + } + + inputResultsArr.forEach(localResult => { + if (localResult.status === 2) { + failingResults.push(localResult.message); + } + if (localResult.status === 0) { + passingResults.push(localResult.message); + } + }); + + if (!logical) { + // Default behavior: if any resource fails, overall result is FAIL + if (failingResults && failingResults.length) { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } + } else if (logical === 'AND') { + if (failingResults && failingResults.length) { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } + } else { + if (passingResults && passingResults.length) { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } + } +}; + +var runValidation = function(obj, condition, inputResultsArr, nestedResultArr, region, cloud, accountId, resourceId) { + let message = []; + let conditionResult = 0; // Initialize conditionResult at function level + + // Extract the values for the conditions + if (condition.property) { + let property; + if (Array.isArray(condition.property)) { + if (condition.property.length === 1) { + property = condition.property[0]; + } else if (condition.property.length > 1) { + property = condition.property.slice(0); + } + } else { + property = condition.property; + } + + // Handle AliasTarget special cases + if (typeof property === 'string' && property.includes('AliasTarget')) { + const propertyParts = property.split('.'); + const aliasProperty = propertyParts.length > 1 ? propertyParts[1] : null; + + if (obj && obj.AliasTarget) { + if (aliasProperty && obj.AliasTarget[aliasProperty] !== undefined) { + condition.parsed = obj.AliasTarget[aliasProperty]; + } else if (!aliasProperty) { + condition.parsed = obj.AliasTarget; + } else { + condition.parsed = 'not set'; + } + } else { + condition.parsed = 'not set'; + } + } else { + const parseResult = parse(obj, condition.property, region, cloud, accountId, resourceId); + condition.parsed = parseResult; + } + + // Normalize: if property is wildcard and parse returned 'not set', treat as ['not set'] + if ((Array.isArray(condition.property) ? condition.property.join('.') : condition.property).includes('[*]') && condition.parsed === 'not set') { + condition.parsed = ['not set']; + } + + // Transform the property if required (except for IPRANGE which transforms the value) + if (condition.transform && condition.transform !== 'IPRANGE') { + try { + condition.parsed = transform(condition.parsed, condition.transform); + } catch (e) { + conditionResult = 2; + message.push(`${property}: unable to perform transformation`); + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + } + + if (condition.parsed === 'not set'){ + conditionResult = 2; + message.push(`${condition.property}: not set to any value`); + } else if ((typeof condition.parsed !== 'boolean' && !condition.parsed) && !Array.isArray(condition.parsed)){ + conditionResult = 2; + message.push(`${property}: not set to any value`); + } + + // Compare the property with the operator + if (condition.op) { + let userRegex; + if (condition.op === 'MATCHES' || condition.op === 'NOTMATCHES') { + userRegex = new RegExp(condition.value); + } + + // Handle arrays returned by parse function (from wildcard paths) + if (Array.isArray(condition.parsed)) { + let anyMatch = false; + let anyNotSet = false; + let allNotSet = true; + let arrayMessages = []; + condition.parsed.forEach(function(item, index) { + let itemMatch = false; + if (item === 'not set') { + arrayMessages.push(`Item ${index}: not set`); + anyNotSet = true; + } else { + allNotSet = false; + } + if (condition.op && item !== 'not set') { + if (condition.op == 'EQ') { + itemMatch = (item == condition.value); + } else if (condition.op == 'NE') { + itemMatch = (item !== condition.value); + } else if (condition.op == 'CONTAINS') { + if (condition.transform == 'IPRANGE') { + var valueRange = transformToIpRange(condition.value); + if (valueRange.error) { + arrayMessages.push('Item ' + index + ': ' + valueRange.error); + itemMatch = false; + } else { + var cidrResult = inCidr(condition.value, item); + if (cidrResult.error) { + arrayMessages.push('Item ' + index + ': ' + cidrResult.error); + itemMatch = false; + } else { + itemMatch = cidrResult.result; + var resultMsg = cidrResult.result ? 'allows access from ' + condition.value : 'does not allow access from ' + condition.value; + arrayMessages.push('Item ' + index + ': ' + item + ' ' + resultMsg); + } + } + } else { + itemMatch = (item && item.includes && item.includes(condition.value)); + } + } else if (condition.op == 'MATCHES') { + let userRegex = RegExp(condition.value); + itemMatch = userRegex.test(item); + } else if (condition.op == 'EXISTS') { + itemMatch = (item !== 'not set'); + } else if (condition.op == 'ISTRUE') { + itemMatch = !!item; + } else if (condition.op == 'ISFALSE') { + itemMatch = !item; + } else if (condition.op == 'ISEMPTY') { + if (item === 'not set') { + itemMatch = false; + arrayMessages.push(`Item ${index}: not set`); + } else if (typeof item === 'boolean' || typeof item === 'number') { + itemMatch = false; + arrayMessages.push(`Item ${index}: is of type ${typeof item}, which cannot be empty`); + } else { + itemMatch = (item === '' || (Array.isArray(item) && item.length === 0) || + (typeof item === 'object' && item !== null && Object.keys(item).length === 0)); + } + } + } + if (itemMatch) { + arrayMessages.push(`Item ${index}: ${item} matched condition`); + anyMatch = true; + } else if (item !== 'not set') { + arrayMessages.push(`Item ${index}: ${item} did not match condition`); + } + }); + + if (condition.parsed.length === 0 || allNotSet) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if array is empty or all items are not set (property missing everywhere) + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else if (anyMatch) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 0, // PASS if any item matches and at least one is set + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else if (anyNotSet) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if any item is not set + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if none match and all are set + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } + } + if (condition.transform && condition.transform == 'EACH' && condition) { + if (condition.op == 'CONTAINS') { + var stringifiedCondition = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { + var key = condition.value.split(/:(?!.*:)/)[0]; + var value = condition.value.split(/:(?!.*:)/)[1]; + + if (stringifiedCondition.includes(key) && stringifiedCondition.includes(value)){ + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + conditionResult = 0; + } else { + message.push(`${condition.value} not found in ${stringifiedCondition}`); + conditionResult = 2; + } + } else if (stringifiedCondition && stringifiedCondition.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + conditionResult = 0; + } else if (stringifiedCondition && stringifiedCondition.length){ + message.push(`${condition.value} not found in ${stringifiedCondition}`); + conditionResult = 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + conditionResult = 2; + } + } else if (condition.op == 'NOTCONTAINS') { + var conditionStringified = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { + + var conditionKey = condition.value.split(/:(?!.*:)/)[0]; + var conditionValue = condition.value.split(/:(?!.*:)/)[1]; + + if (conditionStringified.includes(conditionKey) && !conditionStringified.includes(conditionValue)){ + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else { + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } + } else if (conditionStringified && !conditionStringified.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else if (conditionStringified && conditionStringified.length){ + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } else { + // Recurse into the same function + var subProcessed = []; + if (!condition.parsed.length) { + conditionResult = 2; + message.push(`${property}: is not iterable using EACH transformation`); + } else { + condition.parsed.forEach(function(parsed) { + subProcessed.push(runValidation(parsed, condition, inputResultsArr, null, region, cloud, accountId, resourceId)); + }); + subProcessed.forEach(function(sub) { + if (sub.status) conditionResult = sub.status; + if (sub.message) message.push(sub.message); + }); + } + } + } else if (condition.op == 'EQ') { + if (condition.parsed == condition.value) { + message.push(`${property}: ${condition.parsed} matched: ${condition.value}`); + conditionResult = 0; + } else { + // Check if we're comparing an object to a string - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && typeof condition.value === 'string') { + message.push(`${property}: is an object but compared to string "${condition.value}". Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${property}: ${condition.parsed} did not match: ${condition.value}`); + } + conditionResult = 2; + } + } else if (condition.op == 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal > comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was greater than: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not greater than: ${condition.value}`); + } + } else if (condition.op == 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal < comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was less than: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not less than: ${condition.value}`); + } + } else if (condition.op == 'NE') { + if (condition.parsed !== condition.value) { + message.push(`${property}: ${condition.parsed} is not: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + // Check if we're comparing an object to a string - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && typeof condition.value === 'string') { + message.push(`${property}: is an object but compared to string "${condition.value}". Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${property}: ${condition.parsed} is: ${condition.value}`); + } + } + } else if (condition.op == 'MATCHES') { + if (userRegex.test(condition.parsed)) { + message.push(`${property}: ${condition.parsed} matches the regex: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} does not match the regex: ${condition.value}`); + } + } else if (condition.op == 'NOTMATCHES') { + if (!userRegex.test(condition.parsed)) { + message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} matches the regex : ${condition.value}`); + } + } else if (condition.op == 'EXISTS') { + if (condition.parsed !== 'not set') { + message.push(`${property}: set to ${condition.parsed}`); + conditionResult = 0; + } else { + message.push(`${property}: ${condition.parsed}`); + conditionResult = 2; + } + } else if (condition.op == 'ISTRUE') { + if (typeof condition.parsed == 'boolean' && condition.parsed) { + message.push(`${property} is true`); + conditionResult = 0; + } else if (typeof condition.parsed == 'boolean' && !condition.parsed) { + conditionResult = 2; + message.push(`${property} is false`); + } else { + message.push(`${property} is not a boolean value`); + conditionResult = 2; + } + } else if (condition.op == 'ISFALSE') { + if (typeof condition.parsed == 'boolean' && !condition.parsed) { + message.push(`${property} is false`); + conditionResult = 0; + } else if (typeof condition.parsed == 'boolean' && condition.parsed) { + conditionResult = 2; + message.push(`${property} is true`); + } else { + message.push(`${property} is not a boolean value`); + conditionResult = 2; + } + } else if (condition.op == 'ISEMPTY') { + if (condition.parsed === 'not set') { + message.push(`${property} is not set`); + conditionResult = 2; + } else if (typeof condition.parsed === 'boolean' || typeof condition.parsed === 'number') { + message.push(`${property} is of type ${typeof condition.parsed}, which cannot be empty`); + conditionResult = 2; + } else if (condition.parsed === '' || + (Array.isArray(condition.parsed) && condition.parsed.length === 0) || + (typeof condition.parsed === 'object' && condition.parsed !== null && Object.keys(condition.parsed).length === 0)) { + message.push(`${property} is empty`); + conditionResult = 0; + } else { + message.push(`${property} is not empty`); + conditionResult = 2; + } + } else if (condition.op == 'CONTAINS' && condition.transform == 'IPRANGE') { + if (typeof condition.parsed !== 'string') { + message.push(property + ': IPRANGE requires property to be an IP address string, got ' + typeof condition.parsed); + conditionResult = 2; + } else { + var valueRange = transformToIpRange(condition.value); + if (valueRange.error) { + message.push(property + ': ' + valueRange.error); + conditionResult = 2; + } else { + var cidrResult = inCidr(condition.value, condition.parsed); + + if (cidrResult.error) { + message.push(property + ': ' + cidrResult.error); + conditionResult = 2; + } else if (cidrResult.result) { + message.push(property + ': ' + cidrResult.message + ' (' + condition.parsed + ' allows access from ' + condition.value + ')'); + conditionResult = 0; + } else { + message.push(property + ': ' + cidrResult.message + ' (' + condition.parsed + ' does not allow access from ' + condition.value + ')'); + conditionResult = 2; + } + } + } + } else if (condition.op == 'CONTAINS') { + if (condition.parsed && condition.parsed.length && condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${condition.parsed}`); + conditionResult = 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} not found in ${condition.parsed}`); + conditionResult = 2; + } else { + // Check if we're trying to use CONTAINS on an object - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && !Array.isArray(condition.parsed)) { + message.push(`${property}: is an object, not a string or array. CONTAINS operation requires a string or array. Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + } + conditionResult = 2; + } + } else if (condition.op == 'NOTCONTAINS') { + if (condition.parsed && condition.parsed.length && !condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${condition.parsed}`); + conditionResult = 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} found in ${condition.parsed}`); + conditionResult = 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + conditionResult = 2; + } + } + } + } + + if (!message.length) { + message = ['The resource matched all required conditions']; + } + + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; +}; + +var runConditions = function(input, data, results, resourcePath, resourceName, region, cloud, accountId) { + let parsedResource = resourceName; + let inputResultsArr = []; + let logical; + let localInput = JSON.parse(JSON.stringify(input)); + + // to check if top level * matches. ex: Instances[*] should be + // present in each condition if not its impossible to compare resources + + localInput.conditions.forEach(condition => { + logical = condition.logical; + + // Special handling for ResourceRecordSets[*].AliasTarget.* properties + if (condition.property && condition.property.includes('ResourceRecordSets[*].AliasTarget')) { + let foundMatch = false; + let matchResults = []; + let nonMatchResults = []; + + if (data && data.ResourceRecordSets && Array.isArray(data.ResourceRecordSets)) { + // Directly access ResourceRecordSets if it exists at the top level + for (let i = 0; i < data.ResourceRecordSets.length; i++) { + let record = data.ResourceRecordSets[i]; + if (record && record.AliasTarget) { + // Extract just the AliasTarget part of the property path + const aliasProperty = condition.property.split('AliasTarget.')[1]; + + if (aliasProperty && record.AliasTarget[aliasProperty]) { + let propValue = record.AliasTarget[aliasProperty]; + let result = 2; // Default to fail + let message = ''; + + // Perform the actual comparison + if (condition.op === 'CONTAINS' && propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTCONTAINS' && !propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} not found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'EQ' && propValue === condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} matched: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NE' && propValue !== condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} is not: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal > comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was greater than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal < comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was less than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISTRUE') { + if (typeof propValue === 'boolean' && propValue === true) { + result = 0; + message = `${aliasProperty} is true`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'true' || propValue === '1')) { + result = 0; + message = `${aliasProperty} is true (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not true`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISFALSE') { + if (typeof propValue === 'boolean' && propValue === false) { + result = 0; + message = `${aliasProperty} is false`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'false' || propValue === '0')) { + result = 0; + message = `${aliasProperty} is false (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not false`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'EXISTS') { + result = 0; + message = `${aliasProperty}: set to ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'MATCHES' && new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTMATCHES' && !new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + if (condition.op === 'CONTAINS') { + message = `${condition.value} not found in ${propValue}`; + } else if (condition.op === 'NOTCONTAINS') { + message = `${condition.value} found in ${propValue}`; + } else if (condition.op === 'EQ') { + message = `${aliasProperty}: ${propValue} did not match: ${condition.value}`; + } else if (condition.op === 'NE') { + message = `${aliasProperty}: ${propValue} is: ${condition.value}`; + } else if (condition.op === 'GT') { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + } else if (condition.op === 'LT') { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + } else if (condition.op === 'ISTRUE') { + message = `${aliasProperty} is not true`; + } else if (condition.op === 'ISFALSE') { + message = `${aliasProperty} is not false`; + } else if (condition.op === 'MATCHES') { + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + } else if (condition.op === 'NOTMATCHES') { + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + } + + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (!aliasProperty) { + // Handle the entire AliasTarget object + matchResults.push({ + status: 0, + message: `AliasTarget: exists for record ${record.Name}`, + resource: record.Name || resourceName + }); + foundMatch = true; + } + } + } + } + + // After checking all records, add the appropriate results to inputResultsArr + if (foundMatch) { + // If any record matched, add all matching results + matchResults.forEach(result => { + inputResultsArr.push({ + status: result.status, + message: result.message + }); + parsedResource = result.resource; + }); + } else { + // If no records matched, add a failure result + if (nonMatchResults.length > 0) { + // Use the first non-matching result as the representative failure + inputResultsArr.push({ + status: 2, + message: nonMatchResults[0].message + }); + parsedResource = nonMatchResults[0].resource; + } else { + // No records with AliasTarget found + inputResultsArr.push({ + status: 2, + message: `No matching records with AliasTarget.${condition.property.split('AliasTarget.')[1] || ''} found` + }); + } + } + } else if (condition.property && condition.property.includes('[*]')) { + // For wildcard properties, parse once and validate the result + const parseResult = parse(data, condition.property, region, cloud, accountId, resourceName); + condition.parsed = parseResult; + condition.validated = runValidation(data, condition, inputResultsArr, null, region, cloud, accountId, resourceName); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName); + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + } else { + // For non-wildcard properties, use the same logic as wildcard + condition.validated = runValidation(data, condition, inputResultsArr, null, region, cloud, accountId, resourceName); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName); + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + } + }); + + compositeResult(inputResultsArr, parsedResource, region, results, logical); +}; + +var asl = function(source, input, resourceMap, cloud, accountId, callback) { + if (!source || !input) return callback('No source or input provided'); + if (!input.apis || !input.apis[0]) return callback('No APIs provided for input'); + if (!input.conditions || !input.conditions.length) return callback('No conditions provided for input'); + let service = input.conditions[0].service; + var subService = (input.conditions[0].subservice) ? input.conditions[0].subservice : null; + let api = input.conditions[0].api; + let resourcePath; + if (resourceMap && + resourceMap[service] && + resourceMap[service][api]) { + resourcePath = resourceMap[service][api]; + } + + if (!source[service]) return callback(`Source data did not contain service: ${service}`); + if (subService && !source[service][subService]) return callback(`Source data did not contain service: ${service}:${subService}`); + if (subService && !source[service][subService][api]) return callback(`Source data did not contain API: ${api}`); + if (!subService && !source[service][api]) return callback(`Source data did not contain API: ${api}`); + + var results = []; + let data = subService ? source[service][subService][api] : source[service][api]; + + for (let region in data) { + let regionVal = data[region]; + if (typeof regionVal !== 'object') continue; + if (regionVal.err) { + results.push({ + status: 3, + message: regionVal.err.message || 'Error', + region: region + }); + } else if (regionVal.data && regionVal.data.length) { + regionVal.data.forEach(function(regionData) { + var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; + runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); + }); + } else if (regionVal.data && Object.keys(regionVal.data).length) { + runConditions(input, regionVal.data, results, resourcePath, '', region, cloud, accountId); + } else { + if (!Object.keys(regionVal).length || (regionVal.data && (!regionVal.data.length || !Object.keys(regionVal.data).length))) { + results.push({ + status: 0, + message: 'No resources found in this region', + region: region + }); + } else { + for (let resourceName in regionVal) { + let resourceObj = regionVal[resourceName]; + if (resourceObj.err || !resourceObj.data) { + results.push({ + status: 3, + resource: resourceName, + message: resourceObj.err.message || 'Error', + region: region + }); + } else { + if (resourceObj.data && resourceObj.data.length){ + resourceObj.data.forEach(function(regionData) { + var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; + runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); + }); + } else { + runConditions(input, resourceObj.data, results, resourcePath, resourceName, region, cloud, accountId); + } + } + } + } + } + } + + callback(null, results, data); +}; + +module.exports = asl; diff --git a/helpers/asl/asl-old.js b/helpers/asl/asl-old.js new file mode 100644 index 000000000..835392bd0 --- /dev/null +++ b/helpers/asl/asl-old.js @@ -0,0 +1,937 @@ +var parse = function(obj, path, region, cloud, accountId, resourceId) { + //(Array.isArray(obj)) return [obj]; + if (typeof path == 'string' && path.includes('.')) path = path.split('.'); + if (Array.isArray(path) && path.length && typeof obj === 'object') { + var localPath = path.shift(); + if (localPath.includes('[*]')){ + localPath = localPath.split('[')[0]; + if (obj[localPath] && obj[localPath].length && obj[localPath].length === 1) { + if (!path || !path.length) { + return [obj[localPath][0], path]; + } else if (path.length === 1){ + return [obj[localPath],path[0]]; + //return parse(obj[localPath][0], path[0]); + } + } + if (path.length && path.join('.').includes('[*]')) { + return parse(obj[localPath], path); + } else if (!obj[localPath] || !obj[localPath].length) { + return ['not set']; + } + return [obj[localPath], path]; + } + if (obj[localPath] || typeof obj[localPath] === 'boolean') { + return parse(obj[localPath], path); + } else return ['not set']; + } else if (!Array.isArray(obj) && path && path.length) { + if (obj[path] || typeof obj[path] === 'boolean') return [obj[path]]; + else { + if (cloud==='aws' && path.startsWith('arn:aws')) { + const template_string = path; + const placeholders = template_string.match(/{([^{}]+)}/g); + let extracted_values = []; + if (placeholders) { + extracted_values = placeholders.map(placeholder => { + const key = placeholder.slice(1, -1); + if (key === 'value') return [obj][0]; + else return obj[key]; + }); + } + // Replace other variables + let converted_string = template_string + .replace(/\{region\}/g, region) + .replace(/\{cloudAccount\}/g, accountId) + .replace(/\{resourceId\}/g, resourceId); + placeholders.forEach((placeholder, index) => { + if (index === placeholders.length - 1) { + converted_string = converted_string.replace(placeholder, extracted_values.pop()); + } else { + converted_string = converted_string.replace(placeholder, extracted_values.shift()); + } + }); + path = converted_string; + return [path]; + } else return ['not set']; + } + } else if (Array.isArray(obj)) { + return [obj]; + } else { + return [obj]; + } +}; +var transform = function(val, transformation) { + if (transformation == 'DATE') { + return new Date(val); + } else if (transformation == 'INTEGER') { + return parseInt(val); + } else if (transformation == 'STRING') { + return val.toString(); + } else if (transformation == 'DAYSFROM') { + // Return the number of days between the date and now + var now = new Date(); + var then = new Date(val); + var timeDiff = then.getTime() - now.getTime(); + var diff = (Math.round(timeDiff / (1000 * 3600 * 24))); + return diff; + } else if (transformation == 'COUNT') { + return val.length; + } else if (transformation == 'EACH') { + return val; + } else if (transformation == 'TOLOWERCASE') { + return val.toLowerCase(); + } else { + return val; + } +}; + +var compositeResult = function(inputResultsArr, resource, region, results, logical) { + let failingResults = []; + let passingResults = []; + + // No results to process, exit early + if (!inputResultsArr || !inputResultsArr.length) { + results.push({ + status: 2, + resource: resource, + message: 'No results to evaluate', + region: region + }); + return; + } + + inputResultsArr.forEach(localResult => { + if (localResult.status === 2) { + failingResults.push(localResult.message); + } + + if (localResult.status === 0) { + passingResults.push(localResult.message); + } + }); + + if (!logical) { + results.push({ + status: inputResultsArr[0].status, + resource: resource, + message: inputResultsArr[0].message, + region: region + }); + } else if (logical === 'AND') { + if (failingResults && failingResults.length) { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } + } else { + if (passingResults && passingResults.length) { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } + } +}; + +var validate = function(condition, conditionResult, inputResultsArr, message, property, parsed) { + if (Array.isArray(property)){ + property = property[property.length-1]; + } + + // Special case for AliasTarget properties + if (property && property.includes('AliasTarget') && parsed && typeof parsed === 'object') { + // Handle the AliasTarget object which has HostedZoneId, DNSName, and EvaluateTargetHealth + if (condition.property && condition.property.includes('AliasTarget')) { + // Extract the specific AliasTarget sub-property if specified + const aliasProperty = condition.property.split('.')[1]; // Get the part after AliasTarget. + if (aliasProperty && parsed.AliasTarget && parsed.AliasTarget[aliasProperty]) { + condition.parsed = parsed.AliasTarget[aliasProperty]; + } else if (!aliasProperty && parsed.AliasTarget) { + condition.parsed = parsed.AliasTarget; + } + } + } else if (parsed && typeof parsed === 'object' && parsed[property]) { + condition.parsed = parsed[property]; + } + + if (condition.transform) { + try { + condition.parsed = transform(condition.parsed, condition.transform); + } catch (e) { + conditionResult = 2; + message.push(`${property}: unable to perform transformation`); + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + } + + // Compare the property with the operator + if (condition.op) { + let userRegex; + if (condition.op === 'MATCHES' || condition.op === 'NOTMATCHES') { + userRegex = new RegExp(condition.value); + } + if (condition.transform && condition.transform == 'EACH' && condition) { + if (condition.op == 'CONTAINS') { + var stringifiedCondition = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { + var key = condition.value.split(/:(?!.*:)/)[0]; + var value = condition.value.split(/:(?!.*:)/)[1]; + + if (stringifiedCondition.includes(key) && stringifiedCondition.includes(value)){ + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + return 0; + } else { + message.push(`${condition.value} not found in ${stringifiedCondition}`); + return 2; + } + } else if (stringifiedCondition && stringifiedCondition.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + return 0; + } else if (stringifiedCondition && stringifiedCondition.length){ + message.push(`${condition.value} not found in ${stringifiedCondition}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } else if (condition.op == 'NOTCONTAINS') { + var conditionStringified = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { + + var conditionKey = condition.value.split(/:(?!.*:)/)[0]; + var conditionValue = condition.value.split(/:(?!.*:)/)[1]; + + if (conditionStringified.includes(conditionKey) && !conditionStringified.includes(conditionValue)){ + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else { + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } + } else if (conditionStringified && !conditionStringified.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else if (conditionStringified && conditionStringified.length){ + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } else { + // Recurse into the same function + var subProcessed = []; + if (!condition.parsed.length) { + conditionResult = 2; + message.push(`${property}: is not iterable using EACH transformation`); + } else { + condition.parsed.forEach(function(parsed) { + subProcessed.push(runValidation(parsed, condition, inputResultsArr)); + }); + subProcessed.forEach(function(sub) { + if (sub.status) conditionResult = sub.status; + if (sub.message) message.push(sub.message); + }); + } + } + } else if (condition.op == 'EQ') { + if (condition.parsed == condition.value) { + message.push(`${property}: ${condition.parsed} matched: ${condition.value}`); + return 0; + } else { + message.push(`${property}: ${condition.parsed} did not match: ${condition.value}`); + return 2; + } + } else if (condition.op == 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal > comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was greater than: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not greater than: ${condition.value}`); + return 2; + } + } else if (condition.op == 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal < comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was less than: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not less than: ${condition.value}`); + return 2; + } + } else if (condition.op == 'NE') { + if (condition.parsed !== condition.value) { + message.push(`${property}: ${condition.parsed} is not: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} is: ${condition.value}`); + return 2; + } + } else if (condition.op == 'MATCHES') { + if (userRegex.test(condition.parsed)) { + message.push(`${property}: ${condition.parsed} matches the regex: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} does not match the regex: ${condition.value}`); + return 2; + } + } else if (condition.op == 'NOTMATCHES') { + if (!userRegex.test(condition.parsed)) { + message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} matches the regex : ${condition.value}`); + return 2; + } + } else if (condition.op == 'EXISTS') { + if (condition.parsed !== 'not set') { + message.push(`${property}: set to ${condition.parsed}`); + return 0; + } else { + message.push(`${property}: ${condition.parsed}`); + return 2; + } + } else if (condition.op == 'ISTRUE') { + if (typeof condition.parsed == 'boolean' && condition.parsed) { + message.push(`${property} is true`); + return 0; + } else if (typeof condition.parsed == 'boolean' && !condition.parsed) { + conditionResult = 2; + message.push(`${property} is false`); + return 2; + } else { + message.push(`${property} is not a boolean value`); + return 2; + } + } else if (condition.op == 'ISFALSE') { + if (typeof condition.parsed == 'boolean' && !condition.parsed) { + message.push(`${property} is false`); + return 0; + } else if (typeof condition.parsed == 'boolean' && condition.parsed) { + conditionResult = 2; + message.push(`${property} is true`); + return 2; + } else { + message.push(`${property} is not a boolean value`); + return 2; + } + } else if (condition.op == 'CONTAINS') { + if (condition.parsed && condition.parsed.length && condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${condition.parsed}`); + return 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} not found in ${condition.parsed}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } else if (condition.op == 'NOTCONTAINS') { + if (condition.parsed && condition.parsed.length && !condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${condition.parsed}`); + return 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} found in ${condition.parsed}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } + return conditionResult; + } +}; + +var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { + let result = 0; + let message = []; + + // Extract the values for the conditions + if (condition.property) { + + let conditionResult = 0; + let property; + if (Array.isArray(condition.property)) { + if (condition.property.length === 1) { + property = condition.property[0]; + } else if (condition.property.length > 1) { + property = condition.property.slice(0); + } + } else { + property = condition.property; + } + + // Handle AliasTarget special cases + let isAliasTargetProperty = false; + if (typeof property === 'string' && property.includes('AliasTarget')) { + isAliasTargetProperty = true; + const propertyParts = property.split('.'); + const aliasProperty = propertyParts.length > 1 ? propertyParts[1] : null; + + if (obj && obj.AliasTarget) { + if (aliasProperty && obj.AliasTarget[aliasProperty] !== undefined) { + condition.parsed = obj.AliasTarget[aliasProperty]; + } else if (!aliasProperty) { + condition.parsed = obj.AliasTarget; + } else { + condition.parsed = 'not set'; + } + } else { + condition.parsed = 'not set'; + } + } else { + condition.parsed = parse(obj, condition.property)[0]; + } + + if ((typeof condition.parsed !== 'boolean' && !condition.parsed) || condition.parsed === 'not set'){ + conditionResult = 2; + message.push(`${property}: not set to any value`); + + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + + if (property.includes('[*]') && !isAliasTargetProperty) { + if (Array.isArray(condition.parsed)) { + if (!Array.isArray(nestedResultArr)) nestedResultArr = []; + let propertyArr = property.split('.'); + propertyArr.shift(); + property = propertyArr.join('.'); + condition.property = property; + if (condition.op !== 'CONTAINS' || condition.op !== 'NOTCONTAINS') { + condition.parsed.forEach(parsed => { + if (property.includes('[*]')) { + runValidation(parsed, condition, inputResultsArr, nestedResultArr); + } else { + let localConditionResult = validate(condition, conditionResult, inputResultsArr, message, property, parsed); + nestedResultArr.push(localConditionResult); + } + }); + } else { + runValidation(condition.parsed, condition, inputResultsArr, nestedResultArr); + } + // NestedCompositeResult + if (nestedResultArr && nestedResultArr.length) { + if (!condition.nested) condition.nested = 'ONE'; + let resultObj; + if ((condition.nested.toUpperCase() === 'ONE' && nestedResultArr.indexOf(0) > -1) || (condition.nested.toUpperCase() === 'ALL' && nestedResultArr.indexOf(2) === 0)) { + resultObj = { + status: 0, + message: message.join(', ') + }; + } else { + resultObj = { + status: 2, + message: message.join(', ') + }; + } + + inputResultsArr.push(resultObj); + return resultObj; + } + } else { + if (!Array.isArray(nestedResultArr)) nestedResultArr = []; + let propertyArr = property.split('.'); + propertyArr.shift(); + property = propertyArr.join('.'); + let localConditionResult = validate(condition, conditionResult, inputResultsArr, message, condition.property, condition.parsed); + + let resultObj = { + status: localConditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + } else { + // Transform the property if required + conditionResult = validate(condition, conditionResult, inputResultsArr, message, property, obj); + if (conditionResult) result = conditionResult; + } + } + + if (!message.length) { + message = ['The resource matched all required conditions']; + } + + let resultObj = { + status: result, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; +}; + +var runConditions = function(input, data, results, resourcePath, resourceName, region, cloud, accountId) { + let dataToValidate; + let newPath; + let newData; + let validated; + let parsedResource = resourceName; + + let inputResultsArr = []; + let logical; + let localInput = JSON.parse(JSON.stringify(input)); + + // to check if top level * matches. ex: Instances[*] should be + // present in each condition if not its impossible to compare resources + let resourceConditionArr = []; + + localInput.conditions.forEach(condition => { + logical = condition.logical; + var conditionPropArr = condition.property.split('.'); + + // Special handling for ResourceRecordSets[*].AliasTarget.* properties + if (condition.property && condition.property.includes('ResourceRecordSets[*].AliasTarget')) { + let foundMatch = false; + let matchResults = []; + let nonMatchResults = []; + + if (data && data.ResourceRecordSets && Array.isArray(data.ResourceRecordSets)) { + // Directly access ResourceRecordSets if it exists at the top level + for (let i = 0; i < data.ResourceRecordSets.length; i++) { + let record = data.ResourceRecordSets[i]; + if (record && record.AliasTarget) { + // Extract just the AliasTarget part of the property path + const aliasProperty = condition.property.split('AliasTarget.')[1]; + + if (aliasProperty && record.AliasTarget[aliasProperty]) { + let propValue = record.AliasTarget[aliasProperty]; + let result = 2; // Default to fail + let message = ''; + + // Perform the actual comparison + if (condition.op === 'CONTAINS' && propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTCONTAINS' && !propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} not found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'EQ' && propValue === condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} matched: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NE' && propValue !== condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} is not: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal > comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was greater than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal < comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was less than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISTRUE') { + if (typeof propValue === 'boolean' && propValue === true) { + result = 0; + message = `${aliasProperty} is true`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'true' || propValue === '1')) { + result = 0; + message = `${aliasProperty} is true (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not true`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISFALSE') { + if (typeof propValue === 'boolean' && propValue === false) { + result = 0; + message = `${aliasProperty} is false`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'false' || propValue === '0')) { + result = 0; + message = `${aliasProperty} is false (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not false`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'EXISTS') { + result = 0; + message = `${aliasProperty}: set to ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'MATCHES' && new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTMATCHES' && !new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + if (condition.op === 'CONTAINS') { + message = `${condition.value} not found in ${propValue}`; + } else if (condition.op === 'NOTCONTAINS') { + message = `${condition.value} found in ${propValue}`; + } else if (condition.op === 'EQ') { + message = `${aliasProperty}: ${propValue} did not match: ${condition.value}`; + } else if (condition.op === 'NE') { + message = `${aliasProperty}: ${propValue} is: ${condition.value}`; + } else if (condition.op === 'GT') { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + } else if (condition.op === 'LT') { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + } else if (condition.op === 'ISTRUE') { + message = `${aliasProperty} is not true`; + } else if (condition.op === 'ISFALSE') { + message = `${aliasProperty} is not false`; + } else if (condition.op === 'MATCHES') { + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + } else if (condition.op === 'NOTMATCHES') { + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + } + + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (!aliasProperty) { + // Handle the entire AliasTarget object + matchResults.push({ + status: 0, + message: `AliasTarget: exists for record ${record.Name}`, + resource: record.Name || resourceName + }); + foundMatch = true; + } + } + } + } + + // After checking all records, add the appropriate results to inputResultsArr + if (foundMatch) { + // If any record matched, add all matching results + matchResults.forEach(result => { + inputResultsArr.push({ + status: result.status, + message: result.message + }); + parsedResource = result.resource; + }); + } else { + // If no records matched, add a failure result + if (nonMatchResults.length > 0) { + // Use the first non-matching result as the representative failure + inputResultsArr.push({ + status: 2, + message: nonMatchResults[0].message + }); + parsedResource = nonMatchResults[0].resource; + } else { + // No records with AliasTarget found + inputResultsArr.push({ + status: 2, + message: `No matching records with AliasTarget.${condition.property.split('AliasTarget.')[1] || ''} found` + }); + } + } + } else if (condition.property && condition.property.includes('[*]')) { + if (conditionPropArr.length > 1 && conditionPropArr[1].includes('[*]')) { + resourceConditionArr.push(conditionPropArr[0]); + var firstProperty = conditionPropArr.shift(); + dataToValidate = parse(data, firstProperty.split('[*]')[0])[0]; + condition.property = conditionPropArr.join('.'); + if (dataToValidate && dataToValidate.length) { + dataToValidate.forEach(newData => { + condition.validated = runValidation(newData, condition, inputResultsArr); + parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + }); + } else { + condition.validated = runValidation([], condition, inputResultsArr); + parsedResource = parse([], resourcePath, region, cloud, accountId, resourceName)[0]; + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + } + // result per resource + } else { + dataToValidate = parse(data, condition.property); + newPath = dataToValidate[1]; + newData = dataToValidate[0]; + if (newPath && newData && newData.length){ + newData.forEach(dataElm =>{ + if (newPath) condition.property = JSON.parse(JSON.stringify(newPath)); + condition.validated = runValidation(dataElm, condition, inputResultsArr); + // Use the Name property as resource if available (common in Route53) + parsedResource = dataElm.Name || parse(dataElm, resourcePath, region, cloud, accountId, resourceName)[0]; + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + }); + } else if (newPath && !newData.length) { + condition.property = JSON.parse(JSON.stringify(newPath)); + condition.validated = runValidation(newData, condition, inputResultsArr); + parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; + if (parsedResource === 'not set' || typeof parsedResource !== 'string') parsedResource = resourceName; + } else if (!newPath) { + // no path returned. means it has fully parsed and got the value. + // save the value + newPath = JSON.parse(JSON.stringify(condition.property)); + if (condition.property.includes('.')){ + condition.property = condition.property.split('.')[condition.property.split('.').length -1 ]; + } + condition.validated = runValidation(newData, condition, inputResultsArr); + condition.property = JSON.parse(JSON.stringify(newPath)); + parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; + if (parsedResource === 'not set' || typeof parsedResource !== 'string') parsedResource = resourceName; + } + } + } else { + dataToValidate = parse(data, condition.property); + if (dataToValidate.length === 1) { + validated = runValidation(data, condition, inputResultsArr); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName)[0]; + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + } else { + newPath = dataToValidate[1]; + newData = dataToValidate[0]; + condition.property = newPath; + newData.forEach(element =>{ + condition.validated = runValidation(element, condition, inputResultsArr); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName)[0]; + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = null; + + results.push({ + status: validated.status, + resource: parsedResource ? parsedResource : resourceName, + message: validated.message, + region: region + }); + }); + } + } + }); + + compositeResult(inputResultsArr, parsedResource, region, results, logical); +}; + +var asl = function(source, input, resourceMap, cloud, accountId, callback) { + if (!source || !input) return callback('No source or input provided'); + if (!input.apis || !input.apis[0]) return callback('No APIs provided for input'); + if (!input.conditions || !input.conditions.length) return callback('No conditions provided for input'); + let service = input.conditions[0].service; + var subService = (input.conditions[0].subservice) ? input.conditions[0].subservice : null; + let api = input.conditions[0].api; + let resourcePath; + if (resourceMap && + resourceMap[service] && + resourceMap[service][api]) { + resourcePath = resourceMap[service][api]; + } + + if (!source[service]) return callback(`Source data did not contain service: ${service}`); + if (subService && !source[service][subService]) return callback(`Source data did not contain service: ${service}:${subService}`); + if (subService && !source[service][subService][api]) return callback(`Source data did not contain API: ${api}`); + if (!subService && !source[service][api]) return callback(`Source data did not contain API: ${api}`); + + var results = []; + let data = subService ? source[service][subService][api] : source[service][api]; + + for (let region in data) { + let regionVal = data[region]; + if (typeof regionVal !== 'object') continue; + if (regionVal.err) { + results.push({ + status: 3, + message: regionVal.err.message || 'Error', + region: region + }); + } else if (regionVal.data && regionVal.data.length) { + regionVal.data.forEach(function(regionData) { + var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; + runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); + }); + } else if (regionVal.data && Object.keys(regionVal.data).length) { + runConditions(input, regionVal.data, results, resourcePath, '', region, cloud, accountId); + } else { + if (!Object.keys(regionVal).length || (regionVal.data && (!regionVal.data.length || !Object.keys(regionVal.data).length))) { + results.push({ + status: 0, + message: 'No resources found in this region', + region: region + }); + } else { + for (let resourceName in regionVal) { + let resourceObj = regionVal[resourceName]; + if (resourceObj.err || !resourceObj.data) { + results.push({ + status: 3, + resource: resourceName, + message: resourceObj.err.message || 'Error', + region: region + }); + } else { + if (resourceObj.data && resourceObj.data.length){ + resourceObj.data.forEach(function(regionData) { + var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; + runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); + }); + } else { + runConditions(input, resourceObj.data, results, resourcePath, resourceName, region, cloud, accountId); + } + } + } + } + } + } + + callback(null, results, data); +}; + +module.exports = asl; diff --git a/helpers/asl/config.json b/helpers/asl/config.json new file mode 100644 index 000000000..2e0c80c3d --- /dev/null +++ b/helpers/asl/config.json @@ -0,0 +1,3 @@ +{ + "current_version": 1 +} \ No newline at end of file diff --git a/helpers/aws/api.js b/helpers/aws/api.js new file mode 100644 index 000000000..c56c6bae7 --- /dev/null +++ b/helpers/aws/api.js @@ -0,0 +1,3252 @@ + +var globalServices = [ + 'S3', + 'IAM', + 'CloudFront', + 'Route53', + 'Route53Domains', + 'WAFRegional', + 'WAF' +]; + +var integrationSendLast = [ + 'EC2', 'IAM' +]; + +/* + enabled: send integration is enable or not + isSingleSource: whether resource is single source or not + +----------Bridge Side Data---------- + BridgeServiceName: it should be the api service name which we are storing in json file in s3 collection bucket. + BridgeCall: it should be the api call which we are storing in json file in s3 collection bucket. + BridgePluginCategoryName: it should be equivalent to Plugin Category Name. + BridgeProvider: it should be the cloud provider + Eg. 'aws', 'Azure', 'Google' + + BridgeArnIdentifier: it should be the key of the arn field data which we are storing in json file in s3 collection bucket. + Eg. 'TrailARN' + + BridgeIdTemplate: this should be the template for creating the resource id. + supported values: name, region, cloudAccount, project, id + Eg. "arn:aws:cloudtrail:{region}:{cloudAccount}:trail/{name}" + + Note: If there is an arn identifier then no need to pass the arn template otherwise we have to pass the template. + + BridgeResourceType: this should be type of the resource, fetch it from the arn. + Eg. 'trail' + + BridgeResourceNameIdentifier: it should be the key of resource name/id data which we are storing in json file in s3 collection bucket. + Eg. 'Name' or 'Id' + + Note: if there is no name then we have to pass the id. + + BridgeExecutionService: it should be equivalent to service name which we are sending from executor in payload data. + BridgeCollectionService: it should be equivalent to service name which we are sending from collector in payload data. + DataIdentifier: it should be the parent key field of data which we want to collect in json file in s3 collection bucket. + +----------Processor Side Data---------- +These fields should be according to the user and product manager, what they want to show in Inventory UI. + InvAsset: 'CloudTrail' + InvService: 'CloudTrail' + InvResourceCategory: 'cloud_resources' + Note: For specific category add the category name otherwise it should be 'cloud_resource' + + InvResourceType: 'CloudTrail' + If you need that your resource type to be two words with capital letter only on first letter of the word (for example: Key Vaults), you should supply the resource type with a space delimiter. + If you need that your resource type to be two words and the the first word should be in capital letters (for example: CDN Profiles), you should supply the resource type with snake case delimiter + + Take the reference from the below map +*/ + +// Note: In Below service map add only single source resources. +// and service name should be plugin category. + +var serviceMap = { + 'CloudTrail': + { + enabled: true, isSingleSource: true, InvAsset: 'CloudTrail', InvService: 'CloudTrail', + InvResourceCategory: 'cloud_resources', InvResourceType: 'CloudTrail', BridgeServiceName: 'cloudtrail', + BridgePluginCategoryName: 'CloudTrail', BridgeProvider: 'aws', BridgeCall: 'describeTrails', + BridgeArnIdentifier: 'TrailARN', BridgeIdTemplate: '', BridgeResourceType: 'trail', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'CloudTrail', + BridgeCollectionService: 'cloudtrail', DataIdentifier: 'data', + }, + 'Athena': + { + enabled: true, isSingleSource: true, InvAsset: 'workgroup', InvService: 'athena', + InvResourceCategory: 'database', InvResourceType: 'athena_workgroup', BridgeServiceName: 'athena', + BridgePluginCategoryName: 'Athena', BridgeProvider: 'aws', BridgeCall: 'listWorkGroups', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:athena:{region}:{cloudAccount}:workgroup/{name}', BridgeResourceType: 'workgroup', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Athena', + BridgeCollectionService: 'athena', DataIdentifier: 'data', + }, + 'Timestream': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'timestreamwrite', + InvResourceCategory: 'database', InvResourceType: 'timestreamwrite_instance', BridgeServiceName: 'timestreamwrite', + BridgePluginCategoryName: 'Timestream', BridgeProvider: 'aws', BridgeCall: 'listDatabases', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'database', + BridgeResourceNameIdentifier: 'DatabaseName', BridgeExecutionService: 'Timestream', + BridgeCollectionService: 'timestreamwrite', DataIdentifier: 'data', + }, + 'Redshift': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'redshift', + InvResourceCategory: 'database', InvResourceType: 'redshift_cluster', BridgeServiceName: 'redshift', + BridgePluginCategoryName: 'Redshift', BridgeProvider: 'aws', BridgeCall: 'describeClusters', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:redshift:{region}:{cloudAccount}:cluster:{name}', + BridgeResourceType: 'cluster', BridgeResourceNameIdentifier: 'ClusterIdentifier', + BridgeExecutionService: 'Redshift', BridgeCollectionService: 'redshift', DataIdentifier: 'data', + }, + 'DocumentDB': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'docdb', + InvResourceCategory: 'database', InvResourceType: 'documentdb_cluster', BridgeServiceName: 'docdb', + BridgePluginCategoryName: 'DocumentDB', BridgeProvider: 'aws', BridgeCall: 'describeDBClusters', + BridgeArnIdentifier: 'DBClusterArn', BridgeIdTemplate: '', BridgeResourceType: 'cluster', + BridgeResourceNameIdentifier: 'DBClusterIdentifier', BridgeExecutionService: 'DocumentDB', + BridgeCollectionService: 'docdb', DataIdentifier: 'data', + }, + 'Neptune': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'neptune', + InvResourceCategory: 'database', InvResourceType: 'neptune_cluster', BridgeServiceName: 'neptune', + BridgePluginCategoryName: 'Neptune', BridgeProvider: 'aws', BridgeCall: 'describeDBClusters', + BridgeArnIdentifier: 'DBClusterArn', BridgeIdTemplate: '', BridgeResourceType: 'cluster', + BridgeResourceNameIdentifier: 'DBClusterIdentifier', BridgeExecutionService: 'Neptune', + BridgeCollectionService: 'neptune', DataIdentifier: 'data', + }, + 'ElastiCache': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'elasticache', + InvResourceCategory: 'database', InvResourceType: 'elasticache_cluster', BridgeServiceName: 'elasticache', + BridgePluginCategoryName: 'ElastiCache', BridgeProvider: 'aws', BridgeCall: 'describeCacheClusters', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'cluster', + BridgeResourceNameIdentifier: 'CacheClusterId', BridgeExecutionService: 'ElastiCache', + BridgeCollectionService: 'elasticache', DataIdentifier: 'data', + }, + 'MemoryDB': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'memorydb', + InvResourceCategory: 'database', InvResourceType: 'memorydb_cluster', BridgeServiceName: 'memorydb', + BridgePluginCategoryName: 'MemoryDB', BridgeProvider: 'aws', BridgeCall: 'describeClusters', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'cluster', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'MemoryDB', + BridgeCollectionService: 'memorydb', DataIdentifier: 'data', + }, + 'ES': + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'elasticsearch', + InvResourceCategory: 'database', InvResourceType: 'elasticsearch_domain', BridgeServiceName: 'es', + BridgePluginCategoryName: 'ES', BridgeProvider: 'aws', BridgeCall: 'describeElasticsearchDomain', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'domain', + BridgeResourceNameIdentifier: 'DomainName', BridgeExecutionService: 'ES', + BridgeCollectionService: 'es', DataIdentifier: 'DomainStatus', + }, + 'DynamoDB': + { + enabled: true, isSingleSource: true, InvAsset: 'table', InvService: 'dynamodb', + InvResourceCategory: 'database', InvResourceType: 'dynamodb_table', BridgeServiceName: 'dynamodb', + BridgePluginCategoryName: 'DynamoDB', BridgeProvider: 'aws', BridgeCall: 'describeTable', + BridgeArnIdentifier: 'TableArn', BridgeIdTemplate: '', BridgeResourceType: 'table', + BridgeResourceNameIdentifier: 'TableName', BridgeExecutionService: 'DynamoDB', + BridgeCollectionService: 'dynamodb', DataIdentifier: 'Table', + }, + 'Backup': + { + enabled: true, isSingleSource: true, InvAsset: 'vault', InvService: 'backup', + InvResourceCategory: 'cloud_resources', InvResourceType: 'backup_vault', BridgeServiceName: 'backup', + BridgePluginCategoryName: 'Backup', BridgeProvider: 'aws', BridgeCall: 'listBackupVaults', + BridgeArnIdentifier: 'BackupVaultArn', BridgeIdTemplate: '', BridgeResourceType: 'backup-vault', + BridgeResourceNameIdentifier: 'BackupVaultName', BridgeExecutionService: 'Backup', + BridgeCollectionService: 'backup', DataIdentifier: 'data', + }, + 'EFS': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'efs', + InvResourceCategory: 'cloud_resources', InvResourceType: 'efs_instance', BridgeServiceName: 'efs', + BridgePluginCategoryName: 'EFS', BridgeProvider: 'aws', BridgeCall: 'describeFileSystems', + BridgeArnIdentifier: 'FileSystemArn', BridgeIdTemplate: '', BridgeResourceType: 'file-system', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'EFS', + BridgeCollectionService: 'efs', DataIdentifier: 'data', + }, + 'Glacier': + { + enabled: true, isSingleSource: true, InvAsset: 's3', InvService: 'glacier', + InvResourceCategory: 'storage', InvResourceType: 's3_glacier', BridgeServiceName: 'glacier', + BridgePluginCategoryName: 'Glacier', BridgeProvider: 'aws', BridgeCall: 'listVaults', + BridgeArnIdentifier: 'VaultARN', BridgeIdTemplate: '', BridgeResourceType: 'vaults', + BridgeResourceNameIdentifier: 'VaultName', BridgeExecutionService: 'Glacier', + BridgeCollectionService: 'glacier', DataIdentifier: 'data', + }, + 'KMS': + { + enabled: true, isSingleSource: true, InvAsset: 'key', InvService: 'kms', + InvResourceCategory: 'cloud_resources', InvResourceType: 'kms_key', BridgeServiceName: 'kms', + BridgePluginCategoryName: 'KMS', BridgeProvider: 'aws', BridgeCall: 'describeKey', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'key', + BridgeResourceNameIdentifier: 'KeyId', BridgeExecutionService: 'KMS', + BridgeCollectionService: 'kms', DataIdentifier: 'KeyMetadata', + }, + 'Secrets Manager': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'secretsmanager', + InvResourceCategory: 'cloud_resources', InvResourceType: 'secretsmanager_instance', + BridgeServiceName: 'secretsmanager', BridgePluginCategoryName: 'Secrets Manager', BridgeProvider: 'aws', + BridgeCall: 'listSecrets', BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'secret', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Secrets Manager', + BridgeCollectionService: 'secretsmanager', DataIdentifier: 'data', + }, + 'CloudWatchLogs': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'cloudwatchlogs', + InvResourceCategory: 'cloud_resources', InvResourceType: 'cloudwatchlogs_instance', + BridgeServiceName: 'cloudwatchlogs', BridgePluginCategoryName: 'CloudWatchLogs', BridgeProvider: 'aws', + BridgeCall: 'describeLogGroups', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'log-group', + BridgeResourceNameIdentifier: 'logGroupName', BridgeExecutionService: 'CloudWatchLogs', + BridgeCollectionService: 'cloudwatchlogs', DataIdentifier: 'data', + }, + + 'EventBridge': + { + enabled: true, isSingleSource: true, InvAsset: 'bus', InvService: 'eventbridge', + InvResourceCategory: 'cloud_resources', InvResourceType: 'eventbridge_bus', + BridgeServiceName: 'eventbridge', BridgePluginCategoryName: 'EventBridge', BridgeProvider: 'aws', BridgeCall: 'listEventBuses', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'event-bus', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'EventBridge', + BridgeCollectionService: 'eventbridge', DataIdentifier: 'data', + }, + 'ECR': + { + enabled: true, isSingleSource: true, InvAsset: 'registry', InvService: 'ecr', + InvResourceCategory: 'cloud_resources', InvResourceType: 'ecr_repository', + BridgeServiceName: 'ecr', BridgePluginCategoryName: 'ECR', BridgeProvider: 'aws', BridgeCall: 'describeRepositories', + BridgeArnIdentifier: 'repositoryArn', BridgeIdTemplate: '', BridgeResourceType: 'repository', + BridgeResourceNameIdentifier:'repositoryName' , BridgeExecutionService: 'ECR', + BridgeCollectionService: 'ecr', DataIdentifier: 'data', + }, + 'App Mesh': + { + enabled: true, isSingleSource: true, InvAsset: 'mesh', InvService: 'appmesh', + InvResourceCategory: 'cloud_resources', InvResourceType: 'app_mesh', + BridgeServiceName: 'appmesh', BridgePluginCategoryName: 'App Mesh', BridgeProvider: 'aws', + BridgeCall: 'listMeshes', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'mesh', + BridgeResourceNameIdentifier: 'meshName', BridgeExecutionService: 'App Mesh', + BridgeCollectionService: 'appmesh', DataIdentifier: 'data', + }, + 'App Runner': + { + enabled: true, isSingleSource: true, InvAsset: 'service', InvService: 'apprunner', + InvResourceCategory: 'cloud_resources', InvResourceType: 'app_runner', + BridgeServiceName: 'apprunner', BridgePluginCategoryName: 'App Runner', BridgeProvider: 'aws', + BridgeCall: 'listServices', BridgeArnIdentifier: 'ServiceArn', BridgeIdTemplate: '', BridgeResourceType: 'service', + BridgeResourceNameIdentifier: 'ServiceName', BridgeExecutionService: 'App Runner', + BridgeCollectionService: 'apprunner', DataIdentifier: 'data', + }, + 'AutoScaling': + { + enabled: true, isSingleSource: true, InvAsset: 'group', InvService: 'autoscaling', + InvResourceCategory: 'cloud_resources', InvResourceType: 'autoscaling_group', + BridgeServiceName: 'autoscaling', BridgePluginCategoryName: 'AutoScaling', BridgeProvider: 'aws', + BridgeCall: 'describeAutoScalingGroups', BridgeArnIdentifier: 'AutoScalingGroupARN', BridgeIdTemplate: '', + BridgeResourceType: 'autoScalingGroup', BridgeResourceNameIdentifier: 'AutoScalingGroupName', + BridgeExecutionService: 'AutoScaling', BridgeCollectionService: 'autoscaling', DataIdentifier: 'data', + }, + 'IAM': [ + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'accessanalyzer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'access_analyzer', + BridgeServiceName: 'accessanalyzer', BridgePluginCategoryName: 'IAM', BridgeProvider: 'aws', + BridgeCall: 'listAnalyzers', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', + BridgeResourceType: 'analyzer', BridgeResourceNameIdentifier: 'name', + BridgeExecutionService: 'IAM', BridgeCollectionService: 'accessanalyzer', DataIdentifier: 'data', + } + ], + 'EMR': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'emr', + InvResourceCategory: 'cloud_resources', InvResourceType: 'emr_cluster', + BridgeServiceName: 'emr', BridgePluginCategoryName: 'EMR', BridgeProvider: 'aws', + BridgeCall: 'listClusters', BridgeArnIdentifier: 'ClusterArn', BridgeIdTemplate: '', + BridgeResourceType: 'cluster', BridgeResourceNameIdentifier: 'Name', + BridgeExecutionService: 'EMR', BridgeCollectionService: 'emr', DataIdentifier: 'data', + }, + 'CodeArtifact': + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'codeArtifact', + InvResourceCategory: 'cloud_resources', InvResourceType: 'codeArtifact_domain', + BridgeServiceName: 'codeartifact', BridgePluginCategoryName: 'CodeArtifact', BridgeProvider: 'aws', + BridgeCall: 'listDomains', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'domain', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CodeArtifact', + BridgeCollectionService: 'codeartifact', DataIdentifier: 'data', + }, + 'CodePipeline': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'codePipeline', + InvResourceCategory: 'cloud_resources', InvResourceType: 'codePipeline', + BridgeServiceName: 'codepipeline', BridgePluginCategoryName: 'CodePipeline', BridgeProvider: 'aws', + BridgeCall: 'getPipeline', BridgeArnIdentifier: '', BridgeResourceType: '', + BridgeIdTemplate: 'arn:aws:codepipeline:{region}:{cloudAccount}:{name}', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CodePipeline', + BridgeCollectionService: 'codepipeline', DataIdentifier: 'pipeline', + }, + 'CodeStar': + { + enabled: true, isSingleSource: true, InvAsset: 'project', InvService: 'codeStar', + InvResourceCategory: 'cloud_resources', InvResourceType: 'codeStar_project', + BridgeServiceName: 'codestar', BridgePluginCategoryName: 'CodeStar', BridgeProvider: 'aws', + BridgeCall: 'describeProject', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'project', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CodeStar', + BridgeCollectionService: 'codestar', DataIdentifier: 'data', + }, + 'Connect': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'connect', + InvResourceCategory: 'cloud_resources', InvResourceType: 'connect', + BridgeServiceName: 'connect', BridgePluginCategoryName: 'Connect', BridgeProvider: 'aws', + BridgeCall: 'listInstances', BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'instance', + BridgeResourceNameIdentifier: 'InstanceAlias', BridgeExecutionService: 'Connect', + BridgeCollectionService: 'connect', DataIdentifier: 'data', + }, + 'DMS': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'dms', + InvResourceCategory: 'cloud_resources', InvResourceType: 'dms', + BridgeServiceName: 'dms', BridgePluginCategoryName: 'DMS', BridgeProvider: 'aws', + BridgeCall: 'describeReplicationInstances', BridgeArnIdentifier: 'ReplicationInstanceArn', + BridgeIdTemplate: '', BridgeResourceType: 'rep', BridgeResourceNameIdentifier: 'ReplicationInstanceIdentifier', + BridgeExecutionService: 'DMS', BridgeCollectionService: 'dms', DataIdentifier: 'data', + }, + 'CloudFormation': + { + enabled: true, isSingleSource: true, InvAsset: 'stack', InvService: 'cloudformation', + InvResourceCategory: 'cloud_resources', InvResourceType: 'cloudformation_stack', + BridgeServiceName: 'cloudformation', BridgePluginCategoryName: 'CloudFormation', BridgeProvider: 'aws', + BridgeCall: 'listStacks', BridgeArnIdentifier: 'StackId', BridgeIdTemplate: '', BridgeResourceType: 'stack', + BridgeResourceNameIdentifier: 'StackName', BridgeExecutionService: 'CloudFormation', + BridgeCollectionService: 'cloudformation', DataIdentifier: 'data', + }, + 'CodeBuild': + { + enabled: true, isSingleSource: true, InvAsset: 'project', InvService: 'codeBuild', + InvResourceCategory: 'cloud_resources', InvResourceType: 'CodeBuild_project', + BridgeServiceName: 'codebuild', BridgePluginCategoryName: 'CodeBuild', BridgeProvider: 'aws', + BridgeCall: 'batchGetProjects', BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'project', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CodeBuild', + BridgeCollectionService: 'codebuild', DataIdentifier: 'projects', + }, + 'CloudFront': + { + enabled: true, isSingleSource: true, InvAsset: 'distribution', InvService: 'cloudFront', + InvResourceCategory: 'cloud_resources', InvResourceType: 'CloudFront_distribution', + BridgeServiceName: 'cloudfront', BridgePluginCategoryName: 'CloudFront', BridgeProvider: 'aws', + BridgeCall: 'listDistributions', BridgeArnIdentifier: 'ARN', BridgeArnTemplate: '', BridgeResourceType: 'distribution', + BridgeResourceNameIdentifier: 'DomainName', BridgeExecutionService: 'CloudFront', + BridgeCollectionService: 'cloudfront', DataIdentifier: 'data', + }, + 'SSM': + { + enabled: true, isSingleSource: true, InvAsset: 'parameter', InvService: 'SSM', + InvResourceCategory: 'cloud_resources', InvResourceType: 'ssm_parameter', + BridgeProvider: 'aws', BridgeServiceName: 'ssm', BridgePluginCategoryName: 'SSM', + BridgeCall: 'describeParameters', BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:ssm:{region}:{cloudAccount}:parameter/{name}', + BridgeResourceType: 'parameter', BridgeResourceNameIdentifier: '', BridgeExecutionService: 'SSM', + BridgeCollectionService: 'ssm', DataIdentifier: 'data', + }, + 'SNS': + { + enabled: true, isSingleSource: true, InvAsset: 'topic', InvService: 'sns', + InvResourceCategory: 'cloud_resources', InvResourceType: 'sns_topic', + BridgeProvider: 'aws', BridgeServiceName: 'sns', BridgePluginCategoryName: 'SNS', + BridgeCall: 'getTopicAttributes', BridgeArnIdentifier: 'TopicArn', BridgeIdTemplate: '', + BridgeResourceType: 'sns', BridgeResourceNameIdentifier: '', BridgeExecutionService: 'SNS', + BridgeCollectionService: 'sns', DataIdentifier: 'Attributes', + }, + 'Route53': + { + enabled: true, isSingleSource: true, InvAsset: 'hostedzone', InvService: 'route53', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Route53 hostedzone', BridgeProvider: 'aws', + BridgeServiceName: 'route53', BridgePluginCategoryName: 'Route53', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:route53:::{id}', + BridgeResourceType: 'hostedzone', BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Route53', + BridgeCollectionService: 'route53', BridgeCall: 'listHostedZones', DataIdentifier: 'data', + }, + 'Proton': + { + enabled: true, isSingleSource: true, InvAsset: 'template', InvService: 'proton', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Proton Template', + BridgeProvider: 'aws', BridgeServiceName: 'proton', BridgePluginCategoryName: 'Proton', + BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', + BridgeResourceType: 'proton', BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Proton', + BridgeCollectionService: 'proton', BridgeCall: 'listEnvironmentTemplates', DataIdentifier: 'data', + }, + 'Organizations': + { + enabled: true, isSingleSource: true, InvAsset: 'organization', InvService: 'organizations', + InvResourceCategory: 'cloud_resources', InvResourceType: 'organization', + BridgeProvider: 'aws', BridgeServiceName: 'organizations', BridgePluginCategoryName: 'Organizations', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', + BridgeResourceType: 'organization', BridgeResourceNameIdentifier: 'Id', BridgeExecutionService: 'Organizations', + BridgeCollectionService: 'organizations', BridgeCall: 'describeOrganization', DataIdentifier: 'data', + }, + 'MWAA': + { + enabled: true, isSingleSource: true, InvAsset: 'environment', InvService: 'mwaa', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Apache Airflow', + BridgeProvider: 'aws', BridgeServiceName: 'mwaa', BridgePluginCategoryName: 'MWAA', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', + BridgeResourceType: 'environment', BridgeResourceNameIdentifier: '', BridgeExecutionService: 'MWAA', + BridgeCollectionService: 'mwaa', BridgeCall: 'getEnvironment', DataIdentifier: 'Environment', + }, + 'MSK': + { + enabled: true, isSingleSource: true, InvAsset: 'cluster', InvService: 'kafka', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Kafka Cluster', + BridgeProvider: 'aws', BridgeServiceName: 'kafka', BridgePluginCategoryName: 'MSK', + BridgeArnIdentifier: 'ClusterArn', BridgeIdTemplate: '', + BridgeResourceType: 'cluster', BridgeResourceNameIdentifier: 'ClusterName', BridgeExecutionService: 'MSK', + BridgeCollectionService: 'kafka', BridgeCall: 'listClusters', DataIdentifier: 'data', + }, + 'MQ': + { + enabled: true, isSingleSource: true, InvAsset: 'broker', InvService: 'mq', + InvResourceCategory: 'cloud_resources', InvResourceType: 'broker', + BridgeProvider: 'aws', BridgeServiceName: 'mq', BridgePluginCategoryName: 'MQ', + BridgeArnIdentifier: 'BrokerArn', BridgeIdTemplate: '', + BridgeResourceType: 'broker', BridgeResourceNameIdentifier: 'BrokerName', BridgeExecutionService: 'MQ', + BridgeCollectionService: 'mq', BridgeCall: 'describeBroker', DataIdentifier: 'data', + }, + 'Managed Blockchain': + { + enabled: true, isSingleSource: true, InvAsset: 'network', InvService: 'managedBlockchain', + InvResourceCategory: 'cloud_resources', InvResourceType: 'blockchain network', BridgeProvider: 'aws', + BridgeServiceName: 'managedblockchain', BridgePluginCategoryName: 'Managed Blockchain', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', + BridgeResourceType: 'managedblockchain', BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Managed Blockchain', + BridgeCollectionService: 'managedblockchain', BridgeCall: 'listNetworks', DataIdentifier: 'data', + }, + 'Location': + { + enabled: true, isSingleSource: true, InvAsset: 'tracker', InvService: 'location', + InvResourceCategory: 'cloud_resources', InvResourceType: 'location tracker', + BridgeProvider: 'aws', BridgeServiceName: 'location', BridgePluginCategoryName: 'Location', + BridgeArnIdentifier: 'TrackerArn', BridgeIdTemplate: '', + BridgeResourceType: 'geo', BridgeResourceNameIdentifier: 'TrackerName', BridgeExecutionService: 'Location', + BridgeCollectionService: 'location', BridgeCall: 'describeTracker', DataIdentifier: 'data', + }, + 'Kinesis Video Streams': + { + enabled: true, isSingleSource: true, InvAsset: 'stream', InvService: 'kinesisVideo', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Kinesis Video Stream', + BridgeProvider: 'aws', BridgeServiceName: 'kinesisvideo', BridgePluginCategoryName: 'Kinesis Video Streams', + BridgeArnIdentifier: 'StreamARN', BridgeIdTemplate: '', BridgeResourceType: 'stream', + BridgeResourceNameIdentifier: 'StreamName', BridgeExecutionService: 'Kinesis Video Streams', + BridgeCollectionService: 'kinesisvideo', BridgeCall: 'listStreams', DataIdentifier: 'data', + }, + 'Kinesis': + { + enabled: true, isSingleSource: true, InvAsset: 'stream', InvService: 'kinesis', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Kinesis Stream', + BridgeProvider: 'aws', BridgeServiceName: 'kinesis', BridgePluginCategoryName: 'Kinesis', + BridgeArnIdentifier: 'StreamARN', BridgeIdTemplate: '', BridgeResourceType: 'stream', + BridgeResourceNameIdentifier: 'StreamName', BridgeExecutionService: 'Kinesis', + BridgeCollectionService: 'kinesis', BridgeCall: 'describeStream', DataIdentifier: 'StreamDescription', + }, + 'ElasticBeanstalk': + { + enabled: true, isSingleSource: true, InvAsset: 'elasticBeanstalk', InvService: 'elasticBeanstalk', + InvResourceCategory: 'cloud_resources', InvResourceType: 'elasticbeanstalk', + BridgeProvider: 'aws', BridgeServiceName: 'elasticbeanstalk', BridgePluginCategoryName: 'ElasticBeanstalk', + BridgeArnIdentifier: 'EnvironmentArn', BridgeIdTemplate: '', BridgeResourceType: 'environment', + BridgeResourceNameIdentifier: 'EnvironmentName', BridgeExecutionService: 'ElasticBeanstalk', + BridgeCollectionService: 'elasticbeanstalk', BridgeCall: 'describeEnvironments', DataIdentifier: 'data', + }, + 'ELBv2': + { + enabled: true, isSingleSource: true, InvAsset: 'loadbalancer', InvService: 'elbv2', + InvResourceCategory: 'cloud_resources', InvResourceType: 'loadbalancer', + BridgeProvider: 'aws', BridgeServiceName: 'elbv2', BridgePluginCategoryName: 'ELBv2', + BridgeArnIdentifier: 'LoadBalancerArn', BridgeIdTemplate: '', BridgeResourceType: 'loadbalancer', + BridgeResourceNameIdentifier: 'LoadBalancerName', BridgeExecutionService: 'ELBv2', + BridgeCollectionService: 'elbv2', BridgeCall: 'describeLoadBalancers', DataIdentifier: 'data', + }, + 'AI & ML': [ + { + enabled: true, isSingleSource: true, InvAsset: 'models', InvService: 'bedrock', + InvResourceCategory: 'ai&ml', InvResourceType: 'Bedrock Model', + BridgeProvider: 'aws', BridgeServiceName: 'bedrock', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'modelArn', BridgeIdTemplate: '', BridgeResourceType: 'custom-model', + BridgeResourceNameIdentifier: 'modelName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'bedrock', BridgeCall: 'getCustomModel', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'comprehend', InvService: 'comprehend', + InvResourceCategory: 'ai&ml', InvResourceType: 'Comprehend', + BridgeProvider: 'aws', BridgeServiceName: 'comprehend', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'JobId', BridgeIdTemplate: '', BridgeResourceType: 'comprehend', + BridgeResourceNameIdentifier: 'jobName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'comprehend', BridgeCall: 'listEntitiesDetectionJobs', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'forecastDataset', InvService: 'forecast', + InvResourceCategory: 'ai&ml', InvResourceType: 'Forecast Dataset', + BridgeProvider: 'aws', BridgeServiceName: 'forecastservice', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'DatasetArn', BridgeIdTemplate: '', BridgeResourceType: 'dataset', + BridgeResourceNameIdentifier: 'DatasetName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'forecastservice', BridgeCall: 'listDatasets', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'translateJobs', InvService: 'translate', + InvResourceCategory: 'ai&ml', InvResourceType: 'Translate Job', BridgeServiceName: 'translate', + BridgePluginCategoryName: 'AI & ML', BridgeProvider: 'aws', BridgeCall: 'listTextTranslationJobs', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:translate:{region}:{cloudAccount}:job/{name}', + BridgeResourceType: 'job', BridgeResourceNameIdentifier: 'JobName', + BridgeExecutionService: 'AI & ML', BridgeCollectionService: 'translate', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'datastores', InvService: 'healthlake', + InvResourceCategory: 'ai&ml', InvResourceType: 'Healthlake Datastore', + BridgeProvider: 'aws', BridgeServiceName: 'healthlake', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'DatastoreArn', BridgeIdTemplate: '', BridgeResourceType: 'datastore', + BridgeResourceNameIdentifier: 'DatastoreId', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'healthlake', BridgeCall: 'listFHIRDatastores', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'kendra', + InvResourceCategory: 'ai&ml', InvResourceType: 'Kendra Instance', BridgeServiceName: 'kendra', + BridgePluginCategoryName: 'AI & ML', BridgeProvider: 'aws', BridgeCall: 'listIndices', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:kendra:{region}:{cloudAccount}:index/{name}', + BridgeResourceType: 'index', BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'kendra', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'bot', InvService: 'lex', + InvResourceCategory: 'ai&ml', InvResourceType: 'Lex BotAlias', + BridgeProvider: 'aws', BridgeServiceName: 'lexmodelsv2', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:lex:{region}:{cloudAccount}:bot-alias/{id}', + BridgeResourceType: 'bot-alias', BridgeResourceNameIdentifier: 'botAliasName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'lexmodelsv2', BridgeCall: 'describeBotAlias', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'project', InvService: 'lookoutVision', + InvResourceCategory: 'ai&ml', InvResourceType: 'Lookout Vision', + BridgeProvider: 'aws', BridgeServiceName: 'lookoutvision', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'ProjectArn', BridgeIdTemplate: '', + BridgeResourceType: 'project', BridgeResourceNameIdentifier: 'ProjectName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'lookoutvision', BridgeCall: 'listProjects', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'dataset', InvService: 'lookoutEquipment', + InvResourceCategory: 'ai&ml', InvResourceType: 'Lookout Equipment', + BridgeProvider: 'aws', BridgeServiceName: 'lookoutequipment', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'DatasetArn', BridgeIdTemplate: '', + BridgeResourceType: 'dataset', BridgeResourceNameIdentifier: 'DatasetName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'lookoutequipment', BridgeCall: 'listDatasets', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'metrics', InvService: 'lookoutMetrics', + InvResourceCategory: 'ai&ml', InvResourceType: 'Lookout Metrics', + BridgeProvider: 'aws', BridgeServiceName: 'lookoutmetrics', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'AnomalyDetectorArn', BridgeIdTemplate: '', + BridgeResourceType: 'AnomalyDetector', BridgeResourceNameIdentifier: 'AnomalyDetectorName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'lookoutmetrics', BridgeCall: 'listAnomalyDetectors', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'sageMaker', + InvResourceCategory: 'ai&ml', InvResourceType: 'Sagemaker Instance', + BridgeProvider: 'aws', BridgeServiceName: 'sagemaker', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: 'NotebookInstanceArn', BridgeIdTemplate: '', BridgeResourceType: 'notebook-instance', + BridgeResourceNameIdentifier: 'NotebookInstanceName', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'sagemaker', BridgeCall: 'describeNotebookInstance', DataIdentifier: 'data', + }, + ], + 'Guard Duty': + { + enabled: true, isSingleSource: true, InvAsset: 'detector', InvService: 'guardduty', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Guardduty Detector', + BridgeProvider: 'aws', BridgeServiceName: 'guardduty', BridgePluginCategoryName: 'GuardDuty', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:guardduty:{region}:{cloudAccount}:detector/{id}', BridgeResourceType: 'detector', + BridgeResourceNameIdentifier: 'detectorId', BridgeExecutionService: 'GuardDuty', + BridgeCollectionService: 'guardduty', BridgeCall: 'getDetector', DataIdentifier: 'data', + }, + 'WorkSpaces': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'workspaces', + InvResourceCategory: 'cloud_resources', InvResourceType: 'WorkSpace Instance', + BridgeProvider: 'aws', BridgeServiceName: 'workspaces', BridgePluginCategoryName: 'WorkSpaces', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:workspaces:{region}:{cloudAccount}:workspace/{name}', BridgeResourceType: 'workspace', + BridgeResourceNameIdentifier: 'WorkspaceId', BridgeExecutionService: 'WorkSpaces', + BridgeCollectionService: 'workspaces', BridgeCall: 'describeWorkspaces', DataIdentifier: 'data', + }, + 'Transfer': + { + enabled: true, isSingleSource: true, InvAsset: 'server', InvService: 'transfer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Transfer Server', + BridgeProvider: 'aws', BridgeServiceName: 'transfer', BridgePluginCategoryName: 'Transfer', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'server', + BridgeResourceNameIdentifier: 'ServerId', BridgeExecutionService: 'Transfer', + BridgeCollectionService: 'transfer', BridgeCall: 'listServers', DataIdentifier: 'data', + }, + 'AppFlow': + { + enabled: true, isSingleSource: true, InvAsset: 'flow', InvService: 'appflow', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Appflow', + BridgeProvider: 'aws', BridgeServiceName: 'appflow', BridgePluginCategoryName: 'AppFlow', + BridgeArnIdentifier: 'flowArn', BridgeIdTemplate: '', BridgeResourceType: 'flow', + BridgeResourceNameIdentifier: 'flowName', BridgeExecutionService: 'AppFlow', + BridgeCollectionService: 'appflow', BridgeCall: 'listFlows', DataIdentifier: 'data', + }, + 'Cognito': + { + enabled: true, isSingleSource: true, InvAsset: 'userpool', InvService: 'cognitoidentityserviceprovider', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Cognito Userpool', + BridgeProvider: 'aws', BridgeServiceName: 'cognitoidentityserviceprovider', BridgePluginCategoryName: 'Cognito', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:cognito-idp:{region}:{cloudAccount}:userpool/{id}', BridgeResourceType: 'userpool', + BridgeResourceNameIdentifier: 'Id', BridgeExecutionService: 'Cognito', + BridgeCollectionService: 'cognitoidentityserviceprovider', BridgeCall: 'listUserPools', DataIdentifier: 'data', + }, + 'WAF': + { + enabled: true, isSingleSource: true, InvAsset: 'webacl', InvService: 'wafv2', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Web ACL', + BridgeProvider: 'aws', BridgeServiceName: 'wafv2', BridgePluginCategoryName: 'WAF', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'webacl', + BridgeResourceNameIdentifier: 'Id', BridgeExecutionService: 'WAF', + BridgeCollectionService: 'wafv2', BridgeCall: 'listWebACLs', DataIdentifier: 'data', + }, + 'Glue': + { + enabled: true, isSingleSource: true, InvAsset: 'glue', InvService: 'glue', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Glue SecurityConfigurations', + BridgeProvider: 'aws', BridgeServiceName: 'glue', BridgePluginCategoryName: 'Glue', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:glue:{region}:{cloudAccount}:/securityConfiguration/{name}', BridgeResourceType: 'securityConfiguration', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Glue', + BridgeCollectionService: 'glue', BridgeCall: 'getSecurityConfigurations', DataIdentifier: 'data', + }, + 'ConfigService': + { + enabled: true, isSingleSource: true, InvAsset: 'configservice', InvService: 'configservice', + InvResourceCategory: 'cloud_resources', InvResourceType: 'ConfigService', + BridgeProvider: 'aws', BridgeServiceName: 'configservice', BridgePluginCategoryName: 'ConfigService', + BridgeArnIdentifier: 'ConfigRuleArn', BridgeIdTemplate: '', BridgeResourceType: 'config-rule', + BridgeResourceNameIdentifier: 'ConfigRuleName', BridgeExecutionService: 'ConfigService', + BridgeCollectionService: 'configservice', BridgeCall: 'describeConfigRules', DataIdentifier: 'data', + }, + 'Firehose': + { + enabled: true, isSingleSource: true, InvAsset: 'firehose', InvService: 'firehose', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Firehose', + BridgeProvider: 'aws', BridgeServiceName: 'firehose', BridgePluginCategoryName: 'Firehose', + BridgeArnIdentifier: 'DeliveryStreamARN', BridgeIdTemplate: '', BridgeResourceType: 'deliverystream', + BridgeResourceNameIdentifier: 'DeliveryStreamName', BridgeExecutionService: 'Firehose', + BridgeCollectionService: 'firehose', BridgeCall: 'describeDeliveryStream', DataIdentifier: 'DeliveryStreamDescription', + }, + 'SES': + { + enabled: true, isSingleSource: true, InvAsset: 'ses', InvService: 'SES', + InvResourceCategory: 'cloud_resource', InvResourceType: 'ses_emails', + BridgeProvider: 'aws', BridgeServiceName: 'ses', BridgePluginCategoryName: 'SES', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:ses:{region}:{cloudAccount}:identity/{name}', BridgeResourceType: 'identity', + BridgeResourceNameIdentifier: 'identityName', BridgeExecutionService: 'SES', + BridgeCollectionService: 'ses', BridgeCall: 'getIdentityDkimAttributes', DataIdentifier: 'DkimAttributes', + }, + 'FSx': + { + enabled: true, isSingleSource: true, InvAsset: 'filesystem', InvService: 'fsx', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Fsx Filesystem', + BridgeProvider: 'aws', BridgeServiceName: 'fsx', BridgePluginCategoryName: 'FSx', + BridgeArnIdentifier: 'ResourceARN', BridgeIdTemplate: '', BridgeResourceType: 'file-system', + BridgeResourceNameIdentifier: 'FileSystemId', BridgeExecutionService: 'FSx', + BridgeCollectionService: 'fsx', BridgeCall: 'describeFileSystems', DataIdentifier: 'data', + }, + 'OpenSearch': [ + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'opensearch', + InvResourceCategory: 'database', InvResourceType: 'OpenSearch Domain', + BridgeProvider: 'aws', BridgeServiceName: 'opensearch', BridgePluginCategoryName: 'OpenSearch', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'domain', + BridgeResourceNameIdentifier: 'DomainName', BridgeExecutionService: 'OpenSearch', + BridgeCollectionService: 'opensearch', BridgeCall: 'describeDomain', DataIdentifier: 'DomainStatus', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'collection', InvService: 'opensearch', + InvResourceCategory: 'database', InvResourceType: 'OpenSearch Serverless', + BridgeProvider: 'aws', BridgeServiceName: 'opensearchserverless', BridgePluginCategoryName: 'OpenSearch', + BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'collection', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'OpenSearch', + BridgeCollectionService: 'opensearchserverless', BridgeCall: 'listCollections', DataIdentifier: 'data', + }, + ], +}; + +var calls = { + AccessAnalyzer: { + listAnalyzers: { + property: 'analyzers', + paginate: 'NextToken' + } + }, + ACM: { + listCertificates: { + property: 'CertificateSummaryList', + paginate: 'NextToken' + } + }, + APIGateway: { + getRestApis: { + property: 'items', + paginate: 'NextToken' + }, + getDomainNames: { + property: 'items', + paginate: 'NextToken' + } + }, + ApiGatewayV2: { + getApis: { + property: 'Items', + paginate: 'NextToken' + }, + }, + AppConfig: { + listApplications: { + property: 'Items', + paginate: 'NextToken' + } + }, + AppMesh: { + listMeshes: { + property: 'meshes', + paginate: 'nextToken' + } + }, + AppRunner: { + listServices: { + property: 'ServiceSummaryList', + paginate: 'NextToken' + } + }, + Appflow: { + listFlows: { + property: 'flows', + paginate: 'nextToken' + } + }, + Athena: { + listWorkGroups: { + property: 'WorkGroups', + paginate: 'NextToken', + params: { + MaxResults: 50 + } + } + }, + AuditManager: { + getSettings: { + property: 'settings', + params: { + attribute: 'ALL' + } + } + }, + AutoScaling: { + describeAutoScalingGroups: { + property: 'AutoScalingGroups', + paginate: 'NextToken', + params: { + MaxRecords: 100 + } + }, + describeLaunchConfigurations: { + property: 'LaunchConfigurations', + paginate: 'NextToken', + params: { + MaxRecords: 100 + } + } + }, + Backup: { + listBackupVaults: { + property: 'BackupVaultList', + paginate: 'NextToken', + }, + describeRegionSettings: { + property: 'ResourceTypeOptInPreference', + }, + listBackupPlans: { + property: 'BackupPlansList', + paginate: 'NextToken' + } + }, + Bedrock:{ + listCustomModels:{ + property: 'modelSummaries', + paginate: 'NextToken', + }, + listModelCustomizationJobs:{ + property: 'modelCustomizationJobSummaries', + paginate: 'NextToken', + }, + getModelInvocationLoggingConfiguration: { + property: 'loggingConfig', + paginate: 'NextToken' + } + }, + CloudFormation: { + listStacks: { + property: 'StackSummaries', + params: { + 'StackStatusFilter': [ + 'CREATE_IN_PROGRESS', + 'CREATE_COMPLETE', + 'ROLLBACK_IN_PROGRESS', + 'ROLLBACK_FAILED', + 'ROLLBACK_COMPLETE', + 'DELETE_FAILED', + 'UPDATE_IN_PROGRESS', + 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_COMPLETE', + 'UPDATE_ROLLBACK_IN_PROGRESS', + 'UPDATE_ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_ROLLBACK_COMPLETE', + 'REVIEW_IN_PROGRESS', + 'IMPORT_IN_PROGRESS', + 'IMPORT_COMPLETE', + 'IMPORT_ROLLBACK_IN_PROGRESS', + 'IMPORT_ROLLBACK_FAILED', + 'IMPORT_ROLLBACK_COMPLETE', + ] + } + }, + describeStacks: { + property: 'Stacks', + paginate: 'NextToken', + } + }, + CloudFront: { + // TODO: Pagination is using an older format + listDistributions: { + property: 'DistributionList', + secondProperty: 'Items' + } + }, + CloudTrail: { + describeTrails: { + property: 'trailList' + } + }, + CloudWatch: { + describeAlarms: { + property: 'MetricAlarms', + paginate: 'NextToken' + } + }, + CloudWatchLogs: { + describeLogGroups: { + property: 'logGroups', + paginate: 'nextToken', + params: { + limit: 50 + }, + rateLimit: 500 + }, + describeMetricFilters: { + property: 'metricFilters', + paginate: 'nextToken', + params: { + limit: 50 // The max available + } + } + }, + CodeArtifact: { + listDomains: { + property: 'domains', + paginate: 'nextToken' + } + }, + CodeStar: { + listProjects: { + property: 'projects', + paginate: 'nextToken' + } + }, + CodeBuild: { + listProjects: { + property: 'projects', + paginate: 'nextToken' + } + }, + CognitoIdentityServiceProvider: { + listUserPools: { + property: 'UserPools', + paginate: 'NextToken', + params: { + MaxResults: 60 + } + } + }, + CodePipeline: { + listPipelines: { + property: 'pipelines', + paginate: 'nextToken' + } + }, + ComputeOptimizer: { + getRecommendationSummaries : { + property: 'recommendationSummaries', + paginate: 'nextToken' + } + }, + Comprehend: { + listEntitiesDetectionJobs: { + property: 'EntitiesDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listDocumentClassificationJobs: { + property: 'DocumentClassificationJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listDominantLanguageDetectionJobs: { + property: 'DominantLanguageDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listKeyPhrasesDetectionJobs: { + property: 'KeyPhrasesDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listSentimentDetectionJobs: { + property: 'SentimentDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listTopicsDetectionJobs: { + property: 'TopicsDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listFlywheels:{ + property: 'FlywheelSummaryList', + paginate: 'nextToken' + } + }, + Connect: { + listInstances: { + property: 'InstanceSummaryList', + paginate: 'NextToken' + } + }, + ConfigService: { + describeConfigurationRecorders: { + property: 'ConfigurationRecorders' + }, + describeConfigurationRecorderStatus: { + property: 'ConfigurationRecordersStatus' + }, + describeConfigRules: { + property: 'ConfigRules', + paginate: 'NextToken' + }, + describeDeliveryChannels: { + property: 'DeliveryChannels' + }, + getDiscoveredResourceCounts: { + property: 'resourceCounts', + paginate: 'NextToken' + } + }, + CustomerProfiles: { + listDomains: { + property: 'Items', + paginate: 'NextToken', + } + }, + DataBrew: { + listJobs: { + property: 'Jobs', + paginate: 'NextToken' + } + }, + DevOpsGuru: { + listNotificationChannels: { + property: 'Channels', + paginate: 'NextToken' + } + }, + DirectConnect: { + describeDirectConnectGateways: { + property: 'directConnectGateways', + paginate: 'nextToken' + } + }, + DirectoryService: { + describeDirectories: { + property: 'DirectoryDescriptions', + paginate: 'NextToken' + } + }, + DLM: { + getLifecyclePolicies: { + property: 'Policies' + } + }, + DMS: { + describeReplicationInstances: { + property: 'ReplicationInstances', + paginate: 'Marker' + } + }, + DocDB: { + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker', + params: { + Filters: [ + { + Name: 'engine', + Values: [ + 'docdb' + ] + } + ] + } + } + }, + DynamoDB: { + listTables: { + property: 'TableNames', + paginate: 'LastEvaluatedTableName', + paginateReqProp: 'ExclusiveStartTableName' + } + }, + DAX: { + describeClusters: { + property: 'Clusters', + paginate: 'NextToken' + } + }, + TimestreamWrite: { + listDatabases: { + property: 'Databases', + paginate: 'NextToken' + } + }, + EC2: { + describeAccountAttributes: { + property: 'AccountAttributes' + }, + describeSubnets: { + property: 'Subnets', + paginate: 'NextToken' + }, + describeAddresses: { + property: 'Addresses' + }, + describeVolumes: { + property: 'Volumes' + }, + describeSnapshots: { + // This call must be overridden because the + // default call retrieves every snapshot + // available, including public ones + override: true + }, + describeInstances: { + property: 'Reservations', + paginate: 'NextToken', + params: { + MaxResults: 1000, + Filters: [ + { + Name: 'instance-state-name', + Values: [ + 'pending', + 'running', + 'stopping', + 'stopped' + ] + } + ] + } + }, + describeSecurityGroups: { + property: 'SecurityGroups' + }, + describeVpcs: { + property: 'Vpcs', + paginate: 'NextToken' + }, + describeFlowLogs: { + // TODO: override bc flowlogs are not available in all regions? + property: 'FlowLogs' + }, + describeImages: { + property: 'Images', + params: { + Owners: [ + 'self' + ], + Filters: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeInternetGateways: { + property: 'InternetGateways' + }, + describeEgressOnlyInternetGateways: { + property: 'EgressOnlyInternetGateways' + }, + describeNatGateways: { + property: 'NatGateways', + paginate: 'NextToken', + params: { + Filter: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeVpcPeeringConnections: { + property: 'VpcPeeringConnections', + paginate: 'NextToken', + params: { + Filters: [ + { + Name: 'status-code', + Values: [ + 'pending-acceptance', + 'provisioning', + 'active' + ] + } + ] + } + }, + describeVpnGateways: { + property: 'VpnGateways', + params: { + Filters: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeVpcEndpointServices: { + property: 'ServiceDetails', + paginate: 'NextToken' + }, + describeVpcEndpoints: { + property: 'VpcEndpoints', + paginate: 'NextToken' + }, + describeRouteTables: { + property: 'RouteTables', + paginate: 'NextToken' + }, + describeTags: { + property: 'Tags', + paginate: 'NextToken', + }, + describeNetworkInterfaces: { + property: 'NetworkInterfaces', + paginate: 'NextToken', + }, + getEbsEncryptionByDefault: { + property: 'EbsEncryptionByDefault' + }, + getEbsDefaultKmsKeyId: { + property: 'KmsKeyId' + }, + describeVpnConnections: { + property: 'VpnConnections', + paginate: 'NextToken' + }, + describeNetworkAcls: { + property: 'NetworkAcls', + paginate: 'NextToken', + }, + describeLaunchTemplates: { + property: 'LaunchTemplates', + paginate: 'NextToken', + } + }, + ElastiCache: { + describeCacheClusters: { + property: 'CacheClusters', + paginate: 'Marker' + }, + describeReservedCacheNodes: { + property: 'ReservedCacheNodes', + paginate: 'Marker' + } + }, + ECR: { + describeRepositories: { + property: 'repositories', + paginate: 'nextToken', + params: { + maxResults: 1000 + } + }, + describeRegistry: {} + }, + ECRPUBLIC: { + describeRegistries: { + property: 'registries', + paginate: 'nextToken', + params: { + maxResults: 1000 + } + } + }, + EFS: { + describeFileSystems: { + property: 'FileSystems', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + EKS: { + listClusters: { + property: 'clusters', + paginate: 'nextToken' + } + }, + ECS: { + listClusters: { + property: 'clusterArns', + paginate: 'nextToken' + } + }, + ElasticBeanstalk: { + describeEnvironments: { + property: 'Environments', + paginate: 'NextToken' + } + }, + ELB: { + describeLoadBalancers: { + property: 'LoadBalancerDescriptions', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + ELBv2: { + describeLoadBalancers: { + property: 'LoadBalancers', + paginate: 'NextMarker', + reliesOnService: 'ec2', + reliesOnCall: 'describeVpcs', + paginateReqProp: 'Marker' + }, + describeTargetGroups: { + property: 'TargetGroups', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + }, + describeTargetHealth: { + property: 'TargetGroups', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + EMR: { + listClusters: { + property: 'Clusters', + paginate: 'Marker', + params: { + ClusterStates: [ + 'RUNNING','WAITING' + ] + } + } + }, + ES: { + listDomainNames: { + property: 'DomainNames', + } + }, + OpenSearch: { + listDomainNames: { + property: 'DomainNames', + } + }, + EventBridge: { + listEventBuses: { + property: 'EventBuses', + paginate: 'NextToken', + params:{ + Limit: 100, + } + }, + listRules: { + property: 'Rules', + paginate: 'NextToken', + } + }, + Finspace: { + listEnvironments: { + property: 'environments', + paginate: 'nextToken' + } + }, + ForecastService: { + listDatasets: { + property: 'Datasets', + paginate: 'NextToken' + }, + listForecastExportJobs: { + property: 'ForecastExportJobs', + paginate: 'NextToken' + } + }, + FSx: { + describeFileSystems: { + property: 'FileSystems', + paginate: 'NextToken' + } + }, + FraudDetector: { + getDetectors: { + property: 'detectors', + paginate: 'nextToken' + }, + getKMSEncryptionKey: { + property: 'kmsKey' + } + }, + Glue: { + getDataCatalogEncryptionSettings: { + property: 'DataCatalogEncryptionSettings', + }, + getSecurityConfigurations: { + property: 'SecurityConfigurations', + paginate: 'NextMarker' + } + }, + Glacier: { + listVaults: { + paginate: 'Marker', + property: 'VaultList', + params: { + accountId: '-', + limit: '50' + }, + } + }, + HealthLake: { + listFHIRDatastores: { + property: 'DatastorePropertiesList', + paginate: 'NextToken' + } + }, + Imagebuilder: { + listContainerRecipes: { + property: 'containerRecipeSummaryList', + paginate: 'nextToken' + }, + listComponents: { + property: 'componentVersionList', + paginate: 'nextToken' + }, + listImagePipelines: { + property: 'imagePipelineList', + paginate: 'nextToken' + }, + listImageRecipes: { + property: 'imageRecipeSummaryList', + paginate: 'nextToken' + }, + listInfrastructureConfigurations: { + property: 'infrastructureConfigurationSummaryList', + paginate: 'nextToken' + } + }, + IAM: { + listServerCertificates: { + property: 'ServerCertificateMetadataList', + paginate: 'Marker' + }, + listGroups: { + property: 'Groups', + paginate: 'Marker' + }, + listUsers: { + property: 'Users', + paginate: 'Marker', + }, + listRoles: { + property: 'Roles', + override: true + }, + listPolicies: { + property: 'Policies', + paginate: 'Marker', + params: { + OnlyAttached: true // Making this false will effect IAM Support Policy plugin + } + }, + listVirtualMFADevices: { + property: 'VirtualMFADevices', + paginate: 'Marker' + }, + getAccountPasswordPolicy: { + property: 'PasswordPolicy' + }, + getAccountSummary: { + property: 'SummaryMap' + }, + generateCredentialReport: { + override: true + } + }, + IoTSiteWise: { + describeDefaultEncryptionConfiguration: { + }, + }, + Kinesis: { + listStreams: { + property: 'StreamNames' + } + }, + KinesisVideo: { + listStreams: { + property: 'StreamInfoList', + paginate: 'NextToken', + }, + }, + Firehose: { + listDeliveryStreams: { + property: 'DeliveryStreamNames' + } + }, + GuardDuty: { + listDetectors: { + property: 'DetectorIds', + paginate: 'NextToken', + } + }, + Kendra: { + listIndices: { + property: 'IndexConfigurationSummaryItems', + paginate: 'NextToken' + } + }, + KMS: { + listKeys: { + property: 'Keys', + paginate: 'NextMarker', + paginateReqProp: 'Marker', + params: { + Limit: 1000 + } + }, + listAliases: { + property: 'Aliases', + paginate: 'NextMarker', + paginateReqProp: 'Marker', + params: { + Limit: 100 + } + } + }, + Kafka: { + listClusters: { + property: 'ClusterInfoList', + paginate: 'NextToken' + } + }, + Lambda: { + listFunctions: { + property: 'Functions', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + LookoutEquipment: { + listDatasets: { + property: 'DatasetSummaries', + paginate: 'NextToken' + } + }, + Location: { + listTrackers: { + property: 'Entries', + paginate: 'NextToken', + }, + listGeofenceCollections: { + property: 'Entries', + paginate: 'NextToken', + } + }, + LookoutVision: { + listProjects: { + property: 'Projects', + paginate: 'NextToken' + } + }, + LexModelsV2: { + listBots: { + property: 'botSummaries', + paginate: 'nextToken' + } + }, + LookoutMetrics: { + listAnomalyDetectors: { + property: 'AnomalyDetectorSummaryList', + paginate: 'NextToken' + } + }, + MemoryDB: { + describeClusters: { + property:'Clusters', + paginate:'NextToken' + } + }, + ManagedBlockchain: { + listNetworks: { + property: 'Networks', + paginate: 'NextToken' + } + }, + MQ: { + listBrokers:{ + property:'BrokerSummaries', + paginate:'NextToken' + } + }, + MWAA: { + listEnvironments: { + property: 'Environments', + paginate: 'NextToken' + } + }, + Neptune: { + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker' + }, + describeDBInstances: { + property: 'DBInstances', + paginate: 'Marker' + } + }, + Organizations: { + describeOrganization: { + property: 'Organization', + }, + listHandshakesForAccount: { + property: 'Handshakes', + }, + listAccounts: { + property: 'Accounts', + paginate: 'NextToken' + } + }, + Proton: { + listEnvironmentTemplates: { + property: 'templates', + paginate: 'nextToken' + } + }, + RDS: { + describeDBInstances: { + property: 'DBInstances', + paginate: 'Marker' + }, + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker' + }, + describeDBEngineVersions: { + property: 'DBEngineVersions', + paginate: 'Marker', + default: true + }, + describeDBSnapshots: { + property: 'DBSnapshots', + paginate: 'Marker' + }, + describeDBParameterGroups: { + property: 'DBParameterGroups', + paginate: 'Marker' + }, + describeDBClusterSnapshots: { + property: 'DBClusterSnapshots', + paginate: 'Marker' + } + }, + Redshift: { + describeClusters: { + property: 'Clusters', + paginate: 'Marker' + }, + describeClusterParameterGroups: { + property: 'ParameterGroups', + paginate: 'Marker' + }, + describeReservedNodes: { + property: 'ReservedNodes', + paginate: 'Marker' + } + }, + ResourceGroupsTaggingAPI: { + getTagKeys: { + property: 'TagKeys', + paginate: 'PaginationToken' + }, + getResources: { + property: 'ResourceTagMappingList', + paginate: 'PaginationToken' + } + }, + Route53: { + listHostedZones: { + property: 'HostedZones', + paginate: 'NextPageMarker', + paginateReqProp: 'Marker' + }, + }, + Route53Domains: { + listDomains: { + property: 'Domains', + paginate: 'NextPageMarker', + paginateReqProp: 'Marker' + } + }, + S3: { + listBuckets: { + property: 'Buckets' + } + }, + SecurityHub: { + describeHub: { + property:'', + paginate: 'NextToken' + }, + getFindings: { + property: 'Findings', + paginate: 'NextToken', + override: true + } + }, + SageMaker: { + listNotebookInstances: { + property: 'NotebookInstances', + paginate: 'NextToken' + } + }, + SecretsManager: { + listSecrets: { + property: 'SecretList', + paginate: 'NextToken' + } + }, + ServiceQuotas: { + listServiceQuotas: { + property: 'Quotas', + paginate: 'NextToken', + params: { + ServiceCode: 'ec2' + }, + } + }, + SES: { + listIdentities: { + property: 'Identities', + paginate: 'NextToken', + params: { + IdentityType: 'Domain', // TODO: maybe don't filter these? + MaxItems: 1000 + }, + rateLimit: 1000 // ms to rate limit between regions + }, + describeActiveReceiptRuleSet: { + } + }, + Shield: { + describeSubscription: { + property: 'Subscription' + }, + describeEmergencyContactSettings: { + property: 'EmergencyContactList' + }, + listProtections: { + property: 'Protections' + } + }, + SNS: { + listTopics: { + property: 'Topics', + paginate: 'NextToken' + }, + listSubscriptions: { + property: 'Subscriptions', + paginate: 'NextToken' + }, + }, + SQS: { + listQueues: { + property: 'QueueUrls' + } + }, + SSM: { + describeInstanceInformation: { + property: 'InstanceInformationList', + params: { + MaxResults: 50 + }, + paginate: 'NextToken' + }, + describeParameters: { + property: 'Parameters', + override: true + }, + listAssociations: { + property: 'Associations', + paginate: 'NextToken' + }, + getServiceSetting: { + property: 'ServiceSetting', + paginate: 'NextToken', + params: { + SettingId: '/ssm/documents/console/public-sharing-permission' + } + }, + describeSessions: { + property: 'Sessions', + paginate: 'NextToken', + params: { + State: 'Active' + } + }, + }, + STS: { + getCallerIdentity: { + property: 'Account' + } + }, + OpenSearchServerless: { + listCollections : { + paginate: 'NextToken', + property: 'collectionSummaries' + }, + listEncryptionSecurityPolicies:{ + override: true, + }, + listNetworkSecurityPolicies: { + override: true, + } + }, + Support: { + describeTrustedAdvisorChecks: { + property: 'checks', + params: { language: 'en' }, + }, + }, + Transfer: { + listServers: { + property: 'Servers', + paginate: 'NextToken', + params: { + MaxResults: 1000 + } + } + }, + Translate: { + listTextTranslationJobs: { + property: 'TextTranslationJobPropertiesList', + paginate: 'NextToken' + } + }, + VoiceID: { + listDomains: { + property: 'DomainSummaries', + paginate: 'NextToken' + } + }, + WAFRegional: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker' + } + }, + WAFV2: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker', + params: { + Scope: 'REGIONAL' + } + } + }, + WAF: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker' + } + }, + WorkSpaces: { + describeWorkspaces: { + property: 'Workspaces', + paginate: 'NextToken' + }, + describeWorkspaceDirectories:{ + property: 'Directories', + paginate: 'NextToken' + }, + describeIpGroups:{ + property: 'Result', + paginate: 'NextToken' + }, + describeWorkspacesConnectionStatus: { + property: 'WorkspacesConnectionStatus', + paginate: 'NextToken' + } + }, + Wisdom: { + listAssistants: { + property: 'assistantSummaries', + paginate: 'NextToken' + } + }, + XRay: { + getEncryptionConfig: { + property: 'EncryptionConfig' + } + } +}; + +var postcalls = [ + { + MemoryDB: { + sendIntegration: serviceMap['MemoryDB'] + }, + Translate: { + sendIntegration: serviceMap['AI & ML'][3] + }, + HealthLake: { + sendIntegration: serviceMap['AI & ML'][4] + }, + Neptune: { + sendIntegration:serviceMap['Neptune'] + }, + TimestreamWrite: { + sendIntegration: serviceMap['Timestream'] + }, + EFS: { + describeFileSystemPolicy: { + reliesOnService: 'EFS', + reliesOnCall: 'describeFileSystems', + filterKey: 'FileSystemId', + filterValue: 'FileSystemId' + }, + sendIntegration: serviceMap['EFS'] + }, + EventBridge: { + sendIntegration: serviceMap['EventBridge'] + }, + CloudWatchLogs: { + sendIntegration: serviceMap['CloudWatchLogs'] + }, + CodeArtifact: { + getDomainPermissionsPolicy: { + reliesOnService: 'codeartifact', + reliesOnCall: 'listDomains', + filterKey: 'domain', + filterValue: 'name' + }, + sendIntegration: serviceMap['CodeArtifact'] + }, + ComputeOptimizer: { + sendIntegration: serviceMap['Compute Optimizer'] + }, + DevOpsGuru: { + sendIntegration: serviceMap['DevOpsGuru'] + }, + DMS: { + sendIntegration: serviceMap['DMS'] + }, + KinesisVideo: { + sendIntegration: serviceMap['Kinesis Video Streams'] + }, + SSM : { + sendIntegration: serviceMap['SSM'] + }, + Organizations:{ + sendIntegration: serviceMap['Organizations'] + }, + Kafka: { + sendIntegration: serviceMap['MSK'] + }, + IoTSiteWise: { + sendIntegration: serviceMap['IoT SiteWise'] + }, + Workspaces: { + sendIntegration: serviceMap['WorkSpaces'] + }, + Transfer: { + sendIntegration: serviceMap['Transfer'] + }, + Glue: { + sendIntegration: serviceMap['Glue'], + }, + SecurityHub: { + sendIntegration: serviceMap['SecurityHub'] + }, + FSx:{ + sendIntegration: serviceMap['FSx'] + }, + + ACM: { + describeCertificate: { + reliesOnService: 'acm', + reliesOnCall: 'listCertificates', + filterKey: 'CertificateArn', + filterValue: 'CertificateArn' + }, + sendIntegration: { + enabled: true + }, + }, + AccessAnalyzer: { + listFindings: { + reliesOnService: 'accessanalyzer', + reliesOnCall: 'listAnalyzers', + override: true + }, + sendIntegration: serviceMap['IAM'][0] + }, + APIGateway: { + getStages: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + }, + getResources: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + }, + getAuthorizers:{ + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + }, + getRequestValidators: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + } + }, + ApiGatewayV2: { + getStages: { + reliesOnService: 'apigatewayv2', + reliesOnCall: 'getApis', + filterKey: 'ApiId', + filterValue: 'ApiId' + }, + getAuthorizers: { + reliesOnService: 'apigatewayv2', + reliesOnCall: 'getApis', + filterKey: 'ApiId', + filterValue: 'ApiId' + } + }, + AppConfig: { + listConfigurationProfiles: { + reliesOnService: 'appconfig', + reliesOnCall: 'listApplications', + filterKey: 'ApplicationId', + filterValue: 'Id' + } + }, + AppMesh: { + listVirtualGateways: { + reliesOnService: 'appmesh', + reliesOnCall: 'listMeshes', + filterKey: 'meshName', + filterValue: 'meshName' + }, + describeMesh: { + reliesOnService: 'appmesh', + reliesOnCall: 'listMeshes', + filterKey: 'meshName', + filterValue: 'meshName' + }, + sendIntegration: serviceMap['App Mesh'] + }, + AppRunner: { + describeService: { + reliesOnService: 'apprunner', + reliesOnCall: 'listServices', + filterKey: 'ServiceArn', + filterValue: 'ServiceArn' + }, + sendIntegration: serviceMap['App Runner'] + }, + Appflow: { + describeFlow: { + reliesOnService: 'appflow', + reliesOnCall: 'listFlows', + filterKey: 'flowName', + filterValue: 'flowName' + }, + sendIntegration: serviceMap['AppFlow'] + }, + Athena: { + getWorkGroup: { + reliesOnService: 'athena', + reliesOnCall: 'listWorkGroups', + filterKey: 'WorkGroup', + filterValue: 'Name' + }, + sendIntegration: serviceMap['Athena'] + }, + AutoScaling: { + describeNotificationConfigurations: { + reliesOnService: 'autoscaling', + reliesOnCall: 'describeAutoScalingGroups', + override: true + }, + describeLaunchConfigurations: { + reliesOnService: 'autoscaling', + reliesOnCall: 'describeAutoScalingGroups', + override: true + }, + sendIntegration: serviceMap['AutoScaling'] + }, + Backup: { + getBackupVaultNotifications: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupVaults', + filterKey: 'BackupVaultName', + filterValue: 'BackupVaultName', + }, + getBackupVaultAccessPolicy: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupVaults', + filterKey: 'BackupVaultName', + filterValue: 'BackupVaultName', + }, + getBackupPlan: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupPlans', + filterKey: 'BackupPlanId', + filterValue: 'BackupPlanId', + }, + sendIntegration: serviceMap['Backup'] + }, + Bedrock:{ + getCustomModel: { + reliesOnService: 'bedrock', + reliesOnCall: 'listCustomModels', + filterKey: 'modelIdentifier', + filterValue: 'modelName', + }, + getModelCustomizationJob: { + reliesOnService: 'bedrock', + reliesOnCall: 'listModelCustomizationJobs', + filterKey: 'jobIdentifier', + filterValue: 'jobArn', + }, + sendIntegration: serviceMap['AI & ML'][0] + }, + CloudFormation: { + describeStackEvents: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName', + rateLimit: 100 // ms to rate limit between stacks + }, + describeStacks: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName', + rateLimit: 100 // ms to rate limit between stacks + }, + getTemplate: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName' + }, + sendIntegration: serviceMap['CloudFormation'] + }, + CloudFront: { + getDistribution: { + reliesOnService: 'cloudfront', + reliesOnCall: 'listDistributions', + override: true + }, + sendIntegration: serviceMap['CloudFront'] + }, + CloudTrail: { + getTrailStatus: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + filterKey: 'Name', + filterValue: 'TrailARN' + }, + listTags: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + override: true + }, + getEventSelectors: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + filterKey: 'TrailName', + filterValue: 'TrailARN' + }, + sendIntegration: serviceMap['CloudTrail'] + }, + Comprehend: { + describeFlywheel: { + reliesOnService: 'comprehend', + reliesOnCall: 'listFlywheels', + filterKey: 'FlywheelArn', + filterValue: 'FlywheelArn' + }, + sendIntegration: serviceMap['AI & ML'][1] + }, + Imagebuilder: { + getContainerRecipe: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listContainerRecipes', + filterKey: 'containerRecipeArn', + filterValue: 'arn' + }, + getComponent: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listComponents', + filterKey: 'componentBuildVersionArn', + filterValue: 'arn' + }, + getInfrastructureConfiguration: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listInfrastructureConfigurations', + filterKey: 'infrastructureConfigurationArn', + filterValue: 'arn' + }, + getImageRecipe: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listImageRecipes', + filterKey: 'imageRecipeArn', + filterValue: 'arn' + } + }, + CloudWatch: { + getEsMetricStatistics: { + reliesOnService: 'opensearch', + reliesOnCall: 'listDomainNames', + override: true, + }, + getEcMetricStatistics: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + override: true, + }, + getredshiftMetricStatistics: { + reliesOnService: 'redshift', + reliesOnCall: 'describeClusters', + override: true, + }, + getEc2MetricStatistics: { + reliesOnService: 'ec2', + reliesOnCall: 'describeInstances', + override: true, + }, + getRdsMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + }, + getRdsWriteIOPSMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + }, + getRdsReadIOPSMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + } + }, + ConfigService: { + getComplianceDetailsByConfigRule: { + reliesOnService: 'configservice', + reliesOnCall: 'describeConfigRules', + filterKey: 'ConfigRuleName', + filterValue: 'ConfigRuleName' + }, + sendIntegration: serviceMap['ConfigService'] + }, + CodeStar: { + describeProject: { + reliesOnService: 'codestar', + reliesOnCall: 'listProjects', + filterKey: 'id', + filterValue: 'projectId' + }, + }, + CustomerProfiles: { + getDomain: { + reliesOnService: 'customerprofiles', + reliesOnCall: 'listDomains', + filterKey: 'DomainName', + filterValue: 'DomainName' + } + }, + CodeBuild: { + batchGetProjects: { + reliesOnService: 'codebuild', + reliesOnCall: 'listProjects', + override: true + }, + getResourcePolicy: { + reliesOnService: 'codebuild', + reliesOnCall: 'batchGetProjects', + filterKey: 'resourceArn', + filterValue: 'arn' + }, + sendIntegration: serviceMap['CodeBuild'] + }, + CodePipeline: { + getPipeline: { + reliesOnService: 'codepipeline', + reliesOnCall: 'listPipelines', + filterKey: 'name', + filterValue: 'name' + }, + sendIntegration: serviceMap['CodePipeline'] + }, + Connect: { + listInstanceCallRecordingStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceMediaStreamStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceChatTranscriptStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceExportedReportStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + instanceAttachmentStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + sendIntegration: serviceMap['Connect'] + }, + DocDB: { + listTagsForResource: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'ResourceName', + filterValue: 'DBClusterArn' + }, + describeDBClusterParameters: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'DBClusterParameterGroupName', + filterValue: 'DBClusterParameterGroup' + }, + sendIntegration: serviceMap['DocumentDB'] + }, + DynamoDB: { + describeTable: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + }, + describeContinuousBackups: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + }, + listBackups: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + }, + sendIntegration: serviceMap['DynamoDB'] + }, + ElastiCache: { + describeReplicationGroups: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + filterKey: 'ReplicationGroupId', + filterValue: 'ReplicationGroupId' + }, + describeCacheSubnetGroups: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + override: true + }, + sendIntegration: serviceMap['ElastiCache'] + }, + ES: { + describeElasticsearchDomain: { + reliesOnService: 'es', + reliesOnCall: 'listDomainNames', + filterKey: 'DomainName', + filterValue: 'DomainName' + }, + sendIntegration: serviceMap['ES'] + }, + OpenSearch: { + describeDomain: { + reliesOnService: 'opensearch', + reliesOnCall: 'listDomainNames', + filterKey: 'DomainName', + filterValue: 'DomainName' + }, + sendIntegration: serviceMap['OpenSearch'][0] + }, + S3: { + getBucketLogging: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketVersioning: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketAcl: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketPolicy: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketPolicyStatus: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override:true + }, + getBucketEncryption: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketTagging: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketLocation: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getPublicAccessBlock: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketWebsite: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getObjectLockConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketLifecycleConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketAccelerateConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + headBucket: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + listObjects: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + sendIntegration: { + enabled: true + } + }, + CognitoIdentityServiceProvider: { + describeUserPool: { + reliesOnService: 'cognitoidentityserviceprovider', + reliesOnCall: 'listUserPools', + filterKey: 'UserPoolId', + filterValue: 'Id' + }, + sendIntegration: serviceMap['Cognito'] + }, + EC2: { + describeSubnets: { + reliesOnService: 'ec2', + reliesOnCall: 'describeVpcs', + override: true + }, + describeSnapshotAttribute: { + reliesOnService: 'ec2', + reliesOnCall: 'describeSnapshots', + override: true + }, + describeVpcEndpointServicePermissions: { + reliesOnService: 'ec2', + reliesOnCall: 'describeVpcEndpointServices', + filterKey: 'ServiceId', + filterValue: 'ServiceId' + }, + describeLaunchTemplateVersions: { + reliesOnService: 'ec2', + reliesOnCall: 'describeLaunchTemplates', + filterKey: 'LaunchTemplateId', + filterValue: 'LaunchTemplateId' + }, + sendIntegration: { + sendLast: true, + enabled: true, + integrationReliesOn: { + serviceName: ['ELBv2', 'IAM'] + } + } + }, + ECR: { + getRepositoryPolicy: { + reliesOnService: 'ecr', + reliesOnCall: 'describeRepositories', + filterKey: 'repositoryName', + filterValue: 'repositoryName' + }, + listTagsForResource:{ + reliesOnService: 'ecr', + reliesOnCall: 'describeRepositories', + filterKey: 'resourceArn', + filterValue: 'repositoryArn' + }, + sendIntegration: serviceMap['ECR'] + }, + ECRPUBLIC: { + describeRepositories: { + reliesOnService: 'ecr', + reliesOnCall: 'describeRegistries', + filterKey: 'registryId', + filterValue: 'registryId' + } + }, + EKS: { + describeCluster: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + }, + listNodegroups: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + } + }, + ECS: { + describeCluster: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + }, + listContainerInstances: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + }, + listServices: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + } + }, + ElasticBeanstalk: { + describeConfigurationSettings: { + reliesOnService: 'elasticbeanstalk', + reliesOnCall: 'describeEnvironments', + override: true + }, + sendIntegration: serviceMap['ElasticBeanstalk'] + }, + ELB: { + describeLoadBalancerPolicies: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeLoadBalancerAttributes: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTags: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeInstanceHealth:{ + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + + } + }, + ELBv2: { + describeTargetHealth: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeTargetGroups', + filterKey: 'TargetGroupArn', + filterValue: 'TargetGroupArn' + }, + describeLoadBalancerAttributes: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeListeners: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTargetGroups: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTargetGroupAttributes: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeTargetGroups', + filterKey: 'TargetGroupArn', + filterValue: 'TargetGroupArn' + }, + describeTags: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + sendIntegration: serviceMap['ELBv2'], + }, + EMR: { + describeCluster: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + filterKey: 'ClusterId', + filterValue: 'Id' + }, + listInstanceGroups: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + filterKey: 'ClusterId', + filterValue: 'Id' + }, + sendIntegration: serviceMap['EMR'] + }, + DLM: { + getLifecyclePolicy: { + reliesOnService: 'dlm', + reliesOnCall: 'getLifecyclePolicies', + filterKey: 'PolicyId', + filterValue: 'PolicyId' + } + }, + ForecastService: { + describeDataset: { + reliesOnService: 'forecastservice', + reliesOnCall: 'listDatasets', + filterKey: 'DatasetArn', + filterValue: 'DatasetArn' + }, + sendIntegration: serviceMap['AI & ML'][2] + }, + Glacier: { + getVaultAccessPolicy: { + reliesOnService: 'glacier', + reliesOnCall: 'listVaults', + filterKey: 'vaultName', + filterValue: 'VaultName' + }, + sendIntegration: serviceMap['Glacier'] + }, + IAM: { + getGroup: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName' + }, + listAttachedUserPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName' + }, + listAttachedGroupPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName' + }, + listAttachedRolePolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + filterKey: 'RoleName', + filterValue: 'RoleName' + }, + listUserPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName' + }, + listGroupPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName' + }, + listRolePolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + filterKey: 'RoleName', + filterValue: 'RoleName' + }, + listSSHPublicKeys: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName' + }, + listMFADevices: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName' + }, + listGroupsForUser: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 100 + }, + getInstanceProfile: { + reliesOnService: 'ec2', + reliesOnCall: 'describeInstances', + override: true + }, + sendIntegration: { + enabled: true, + sendLast: true + } + }, + Kendra: { + describeIndex: { + reliesOnService: 'kendra', + reliesOnCall: 'listIndices', + filterKey: 'Id', + filterValue: 'Id' + }, + sendIntegration: serviceMap['AI & ML'][5] + }, + Kinesis: { + describeStream: { + reliesOnService: 'kinesis', + reliesOnCall: 'listStreams', + override: true + }, + getResourcePolicy: { + reliesOnService: 'kinesis', + reliesOnCall: 'describeStream', + filterKey: 'ResourceARN', + filterValue: 'StreamARN' + }, + sendIntegration: serviceMap['Kinesis'] + }, + Firehose: { + describeDeliveryStream: { + reliesOnService: 'firehose', + reliesOnCall: 'listDeliveryStreams', + override: true + }, + sendIntegration: serviceMap['Firehose'], + }, + KMS: { + describeKey: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + getKeyRotationStatus: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + getKeyPolicy: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + override: true + }, + listResourceTags: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + listGrants: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + override: true + }, + sendIntegration: serviceMap['KMS'] + }, + Lambda: { + getPolicy: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + rateLimit: 100, // it's not documented but experimental 10/second works. + }, + getFunction: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + listTags: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'Resource', + filterValue: 'FunctionArn' + }, + getFunctionUrlConfig :{ + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + getFunctionConfiguration: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName' + }, + getFunctionCodeSigningConfig : { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + sendIntegration: { + enabled: true + } + }, + LookoutEquipment: { + describeDataset: { + reliesOnService: 'lookoutequipment', + reliesOnCall: 'listDatasets', + filterKey: 'DatasetName', + filterValue: 'DatasetName' + }, + sendIntegration: serviceMap['AI & ML'][8] + }, + Location: { + describeTracker: { + reliesOnService: 'location', + reliesOnCall: 'listTrackers', + filterKey: 'TrackerName', + filterValue: 'TrackerName' + }, + describeGeofenceCollection: { + reliesOnService: 'location', + reliesOnCall: 'listGeofenceCollections', + filterKey: 'CollectionName', + filterValue: 'CollectionName' + }, + sendIntegration: serviceMap['Location'] + }, + LookoutVision: { + listModels: { + reliesOnService: 'lookoutvision', + reliesOnCall: 'listProjects', + filterKey: 'ProjectName', + filterValue: 'ProjectName' + } + }, + LexModelsV2: { + listBotAliases: { + reliesOnService: 'lexmodelsv2', + reliesOnCall: 'listBots', + filterKey: 'botId', + filterValue: 'botId' + }, + describeResourcePolicy: { + reliesOnService: 'lexmodelsv2', + reliesOnCall: 'describeBotAlias', + filterKey: 'resourceArn', + filterValue: 'botAliasId' + } + }, + ManagedBlockchain: { + listMembers: { + reliesOnService: 'managedblockchain', + reliesOnCall: 'listNetworks', + filterKey: 'NetworkId', + filterValue: 'Id' + } + }, + MQ: { + describeBroker: { + reliesOnService: 'mq', + reliesOnCall: 'listBrokers', + filterKey: 'BrokerId', + filterValue: 'BrokerId' + }, + sendIntegration: serviceMap['MQ'] + }, + LookoutMetrics: { + describeAnomalyDetector: { + reliesOnService: 'lookoutmetrics', + reliesOnCall: 'listAnomalyDetectors', + filterKey: 'AnomalyDetectorArn', + filterValue: 'AnomalyDetectorArn' + }, + sendIntegration: serviceMap['AI & ML'][9] + }, + MWAA: { + getEnvironment: { + reliesOnService: 'mwaa', + reliesOnCall: 'listEnvironments', + override: true + }, + sendIntegration: serviceMap['MWAA'] + }, + Proton: { + getEnvironmentTemplate: { + reliesOnService: 'proton', + reliesOnCall: 'listEnvironmentTemplates', + filterKey: 'name', + filterValue: 'name' + }, + sendIntegration: serviceMap['Proton'] + }, + RDS: { + describeDBParameters: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBParameterGroups', + override: true + }, + describeDBSnapshotAttributes: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBSnapshots', + filterKey: 'DBSnapshotIdentifier', + filterValue: 'DBSnapshotIdentifier' + }, + sendIntegration: { + enabled: true + } + }, + Route53: { + listResourceRecordSets: { + reliesOnService: 'route53', + reliesOnCall: 'listHostedZones', + filterKey: 'HostedZoneId', + filterValue: 'Id' + }, + sendIntegration: serviceMap['Route53'] + }, + Route53Domains: { + getDomainDetail: { + reliesOnService: 'route53domains', + reliesOnCall: 'listDomains', + filterKey: 'DomainName', + filterValue: 'DomainName' + } + }, + S3Control: { + getPublicAccessBlock: { + reliesOnService: 'sts', + reliesOnCall: 'getCallerIdentity', + override: true + } + }, + Redshift: { + describeClusterParameters: { + reliesOnService: 'redshift', + reliesOnCall: 'describeClusterParameterGroups', + filterKey: 'ParameterGroupName', + filterValue: 'ParameterGroupName' + }, + sendIntegration: serviceMap['Redshift'] + }, + SageMaker: { + describeNotebookInstance: { + reliesOnService: 'sagemaker', + reliesOnCall: 'listNotebookInstances', + filterKey: 'NotebookInstanceName', + filterValue: 'NotebookInstanceName' + }, + sendIntegration: serviceMap['AI & ML'][10] + }, + SecretsManager: { + describeSecret: { + reliesOnService: 'secretsmanager', + reliesOnCall: 'listSecrets', + filterKey: 'SecretId', + filterValue: 'ARN', + }, + getResourcePolicy: { + reliesOnService: 'secretsmanager', + reliesOnCall: 'listSecrets', + filterKey: 'SecretId', + filterValue: 'ARN', + }, + sendIntegration: serviceMap['Secrets Manager'] + }, + SES: { + getIdentityDkimAttributes: { + reliesOnService: 'ses', + reliesOnCall: 'listIdentities', + override: true, + rateLimit: 1000 + }, + sendIntegration: serviceMap['SES'] + }, + SNS: { + getTopicAttributes: { + reliesOnService: 'sns', + reliesOnCall: 'listTopics', + filterKey: 'TopicArn', + filterValue: 'TopicArn' + }, + sendIntegration: serviceMap['SNS'] + }, + SQS: { + getQueueAttributes: { + reliesOnService: 'sqs', + reliesOnCall: 'listQueues', + override: true + } + }, + Support: { + describeTrustedAdvisorCheckResult: { + reliesOnService: 'support', + reliesOnCall: 'describeTrustedAdvisorChecks', + filterKey: 'checkId', + filterValue: 'id' + }, + }, + WAFRegional: { + listResourcesForWebACL: { + reliesOnService: 'wafregional', + reliesOnCall: 'listWebACLs', + override: true + } + }, + WAFV2: { + listResourcesForWebACL: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + override: true + }, + getLoggingConfiguration: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + filterKey: 'ResourceArn', + filterValue: 'ARN' + }, + getWebACLForCognitoUserPool: { + reliesOnService: 'cognitoidentityserviceprovider', + reliesOnCall: 'listUserPools', + override: true + }, + getWebACL: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + override: true + }, + sendIntegration: serviceMap['WAF'] + }, + GuardDuty: { + getDetector: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + getMasterAccount: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + listFindings: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + listPublishingDestinations: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + sendIntegration: serviceMap['Guard Duty'], + }, + }, + { + APIGateway: { + getClientCertificate: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + override: true + }, + getIntegration: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + override: true + }, + sendIntegration: { + enabled: true + } + }, + AppMesh: { + describeVirtualGateway: { + reliesOnService: 'appmesh', + reliesOnCall: 'listMeshes', + override: true + } + }, + EMR: { + describeSecurityConfiguration: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + override: true + } + }, + IAM: { + getPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listPolicies', + filterKey: 'PolicyArn', + filterValue: 'Arn' + }, + getRole: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + override: true + }, + getUser: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName' + }, + sendIntegration: { + enabled: true, + sendLast: true + } + }, + EKS:{ + describeNodegroups: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + }, + sendIntegration: { + enabled: true + } + }, + ECS: { + describeContainerInstances: { + override:true + }, + listTasks: { + reliesOnService: 'ecs', + override:true, + reliesOnCall: 'listClusters' + }, + describeServices: { + override:true + } + }, + LookoutVision: { + describeModel: { + reliesOnService: 'lookoutvision', + reliesOnCall: 'listProjects', + override: true + }, + sendIntegration: serviceMap['AI & ML'][7] + }, + GuardDuty: { + getFindings: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + describePublishingDestination: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + }, + LexModelsV2:{ + describeBotAlias: { + reliesOnService: 'lexmodelsv2', + reliesOnCall: 'listBots', + override: true, + }, + sendIntegration: serviceMap['AI & ML'][6] + }, + ManagedBlockchain: { + getMember: { + reliesOnService: 'managedblockchain', + reliesOnCall: 'listNetworks', + override: true + }, + sendIntegration: serviceMap['Managed Blockchain'] + } + }, + { + IAM: { + getPolicyVersion: { + reliesOnService: 'iam', + reliesOnCall: 'listPolicies', + override: true + }, + getUserPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + override: true + }, + getGroupPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + override: true + }, + getRolePolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + override: true + }, + }, + ECS: { + describeTasks: { + override:true + }, + sendIntegration: { + enabled: true + } + }, + OpenSearchServerless: { + getEncryptionSecurityPolicy: { + reliesOnService: 'opensearchserverless', + reliesOnCall: 'listEncryptionSecurityPolicies', + override: true + }, + getNetworkSecurityPolicy: { + reliesOnService: 'opensearchserverless', + reliesOnCall: 'listNetworkSecurityPolicies', + override: true + }, + sendIntegration: serviceMap['OpenSearch'][1] + } + } +]; + +module.exports = { + globalServices: globalServices, + serviceMap: serviceMap, + calls: calls, + postcalls: postcalls, + integrationSendLast: integrationSendLast +}; diff --git a/helpers/aws/api_multipart.js b/helpers/aws/api_multipart.js new file mode 100644 index 000000000..b4576bf39 --- /dev/null +++ b/helpers/aws/api_multipart.js @@ -0,0 +1,2419 @@ +var globalServices = [ + 'S3', + 'IAM', + 'CloudFront', + 'Route53', + 'Route53Domains', + 'WAFRegional', + 'WAF' +]; + +var integrationSendLast = [ + 'EC2', 'IAM' +]; + +var calls = [ + { + AccessAnalyzer: { + listAnalyzers: { + property: 'analyzers', + paginate: 'NextToken' + } + }, + ACM: { + listCertificates: { + property: 'CertificateSummaryList', + paginate: 'NextToken' + } + }, + APIGateway: { + getRestApis: { + property: 'items', + paginate: 'NextToken' + }, + getDomainNames: { + property: 'items', + paginate: 'NextToken' + } + }, + AppConfig: { + listApplications: { + property: 'Items', + paginate: 'NextToken' + } + }, + OpenSearchServerless: { + listCollections : { + paginate: 'NextToken', + property: 'collectionSummaries' + }, + listNetworkSecurityPolicies: { + override: true, + }, + listEncryptionSecurityPolicies:{ + override: true, + } + }, + AppMesh: { + listMeshes: { + property: 'meshes', + paginate: 'nextToken' + } + }, + AppRunner: { + listServices: { + property: 'ServiceSummaryList', + paginate: 'NextToken' + } + }, + Appflow: { + listFlows: { + property: 'flows', + paginate: 'nextToken' + } + }, + Athena: { + listWorkGroups: { + property: 'WorkGroups', + paginate: 'NextToken', + params: { + MaxResults: 50 + } + } + }, + AuditManager: { + getSettings: { + property: 'settings', + params: { + attribute: 'ALL' + } + } + }, + AutoScaling: { + describeAutoScalingGroups: { + property: 'AutoScalingGroups', + paginate: 'NextToken', + params: { + MaxRecords: 100 + } + }, + describeLaunchConfigurations: { + property: 'LaunchConfigurations', + paginate: 'NextToken', + params: { + MaxRecords: 100 + } + } + }, + Backup: { + listBackupVaults: { + property: 'BackupVaultList', + paginate: 'NextToken', + }, + describeRegionSettings: { + property: 'ResourceTypeOptInPreference', + }, + listBackupPlans: { + property: 'BackupPlansList', + paginate: 'NextToken' + } + }, + Bedrock:{ + listCustomModels:{ + property: 'modelSummaries', + paginate: 'NextToken', + }, + listModelCustomizationJobs:{ + property: 'modelCustomizationJobSummaries"', + paginate: 'NextToken', + }, + getModelInvocationLoggingConfiguration: { + property: 'loggingConfig', + paginate: 'NextToken' + } + }, + CloudFormation: { + listStacks: { + property: 'StackSummaries', + params: { + 'StackStatusFilter': [ + 'CREATE_IN_PROGRESS', + 'CREATE_COMPLETE', + 'ROLLBACK_IN_PROGRESS', + 'ROLLBACK_FAILED', + 'ROLLBACK_COMPLETE', + 'DELETE_FAILED', + 'UPDATE_IN_PROGRESS', + 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_COMPLETE', + 'UPDATE_ROLLBACK_IN_PROGRESS', + 'UPDATE_ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_ROLLBACK_COMPLETE', + 'REVIEW_IN_PROGRESS', + 'IMPORT_IN_PROGRESS', + 'IMPORT_COMPLETE', + 'IMPORT_ROLLBACK_IN_PROGRESS', + 'IMPORT_ROLLBACK_FAILED', + 'IMPORT_ROLLBACK_COMPLETE', + ] + } + }, + }, + CloudFront: { + // TODO: Pagination is using an older format + listDistributions: { + property: 'DistributionList', + secondProperty: 'Items' + } + }, + CloudTrail: { + describeTrails: { + property: 'trailList' + } + }, + CloudWatch: { + describeAlarms: { + property: 'MetricAlarms', + paginate: 'NextToken' + } + }, + CloudWatchLogs: { + describeLogGroups: { + property: 'logGroups', + paginate: 'nextToken', + params: { + limit: 50 + }, + rateLimit: 500 + }, + describeMetricFilters: { + property: 'metricFilters', + paginate: 'nextToken', + params: { + limit: 50 // The max available + } + } + }, + CodeArtifact: { + listDomains: { + property: 'domains', + paginate: 'nextToken' + } + }, + CodeStar: { + listProjects: { + property: 'projects', + paginate: 'nextToken' + } + }, + CodeBuild: { + listProjects: { + property: 'projects', + paginate: 'nextToken' + } + }, + CodePipeline: { + listPipelines: { + property: 'pipelines', + paginate: 'nextToken' + } + }, + Comprehend: { + listEntitiesDetectionJobs: { + property: 'EntitiesDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listDocumentClassificationJobs: { + property: 'DocumentClassificationJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listDominantLanguageDetectionJobs: { + property: 'DominantLanguageDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listKeyPhrasesDetectionJobs: { + property: 'KeyPhrasesDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listSentimentDetectionJobs: { + property: 'SentimentDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + }, + listTopicsDetectionJobs: { + property: 'TopicsDetectionJobPropertiesList', + paginate: 'NextToken', + params: { + MaxResults: 100 + } + } + }, + Connect: { + listInstances: { + property: 'InstanceSummaryList', + paginate: 'NextToken' + } + }, + ConfigService: { + describeConfigurationRecorders: { + property: 'ConfigurationRecorders' + }, + describeConfigurationRecorderStatus: { + property: 'ConfigurationRecordersStatus' + }, + describeConfigRules: { + property: 'ConfigRules', + paginate: 'NextToken' + }, + describeDeliveryChannels: { + property: 'DeliveryChannels' + }, + getDiscoveredResourceCounts: { + property: 'resourceCounts', + paginate: 'NextToken' + } + }, + CustomerProfiles: { + listDomains: { + property: 'Items', + paginate: 'NextToken' + } + }, + DataBrew: { + listJobs: { + property: 'Jobs', + paginate: 'NextToken' + } + }, + DevOpsGuru: { + listNotificationChannels: { + property: 'Channels', + paginate: 'NextToken' + } + }, + DirectConnect: { + describeDirectConnectGateways: { + property: 'directConnectGateways', + paginate: 'nextToken' + } + }, + DirectoryService: { + describeDirectories: { + property: 'DirectoryDescriptions', + paginate: 'NextToken' + } + }, + }, + { + DLM: { + getLifecyclePolicies: { + property: 'Policies' + } + }, + DMS: { + describeReplicationInstances: { + property: 'ReplicationInstances', + paginate: 'Marker' + } + }, + DocDB: { + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker', + params: { + Filters: [ + { + Name: 'engine', + Values: [ + 'docdb' + ] + } + ] + } + } + }, + DynamoDB: { + listTables: { + property: 'TableNames', + paginate: 'LastEvaluatedTableName', + paginateReqProp: 'ExclusiveStartTableName' + } + }, + DAX: { + describeClusters: { + property: 'Clusters', + paginate: 'NextToken' + } + }, + TimestreamWrite: { + listDatabases: { + property: 'Databases', + paginate: 'NextToken' + } + }, + EC2: { + describeAccountAttributes: { + property: 'AccountAttributes' + }, + describeSubnets: { + property: 'Subnets', + paginate: 'NextToken' + }, + describeAddresses: { + property: 'Addresses' + }, + describeVolumes: { + property: 'Volumes' + }, + describeSnapshots: { + // This call must be overridden because the + // default call retrieves every snapshot + // available, including public ones + override: true + }, + describeInstances: { + property: 'Reservations', + paginate: 'NextToken', + params: { + MaxResults: 1000, + Filters: [ + { + Name: 'instance-state-name', + Values: [ + 'pending', + 'running', + 'stopping', + 'stopped' + ] + } + ] + } + }, + describeSecurityGroups: { + property: 'SecurityGroups' + }, + describeVpcs: { + property: 'Vpcs', + paginate: 'NextToken' + }, + describeFlowLogs: { + // TODO: override bc flowlogs are not available in all regions? + property: 'FlowLogs' + }, + describeImages: { + property: 'Images', + params: { + Owners: [ + 'self' + ], + Filters: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeInternetGateways: { + property: 'InternetGateways' + }, + describeEgressOnlyInternetGateways: { + property: 'EgressOnlyInternetGateways' + }, + describeNatGateways: { + property: 'NatGateways', + paginate: 'NextToken', + params: { + Filter: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeVpcPeeringConnections: { + property: 'VpcPeeringConnections', + paginate: 'NextToken', + params: { + Filters: [ + { + Name: 'status-code', + Values: [ + 'pending-acceptance', + 'provisioning', + 'active' + ] + } + ] + } + }, + describeVpnGateways: { + property: 'VpnGateways', + params: { + Filters: [ + { + Name: 'state', + Values: [ + 'available' + ] + } + ] + } + }, + describeVpcEndpointServices: { + property: 'ServiceDetails', + paginate: 'NextToken' + }, + describeVpcEndpoints: { + property: 'VpcEndpoints', + paginate: 'NextToken' + }, + describeRouteTables: { + property: 'RouteTables', + paginate: 'NextToken' + }, + describeTags: { + property: 'Tags', + paginate: 'NextToken', + }, + describeNetworkInterfaces: { + property: 'NetworkInterfaces', + paginate: 'NextToken', + }, + getEbsEncryptionByDefault: { + property: 'EbsEncryptionByDefault' + }, + getEbsDefaultKmsKeyId: { + property: 'KmsKeyId' + }, + describeVpnConnections: { + property: 'VpnConnections', + paginate: 'NextToken' + }, + describeNetworkAcls: { + property: 'NetworkAcls', + paginate: 'NextToken', + }, + describeLaunchTemplates: { + property: 'LaunchTemplates', + paginate: 'NextToken', + } + }, + ElastiCache: { + describeCacheClusters: { + property: 'CacheClusters', + paginate: 'Marker' + }, + describeReservedCacheNodes: { + property: 'ReservedCacheNodes', + paginate: 'Marker' + } + }, + ECR: { + describeRepositories: { + property: 'repositories', + paginate: 'nextToken', + params: { + maxResults: 1000 + } + }, + describeRegistry: {} + }, + ECRPUBLIC: { + describeRegistries: { + property: 'registries', + paginate: 'nextToken', + params: { + maxResults: 1000 + } + } + }, + EFS: { + describeFileSystems: { + property: 'FileSystems', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + EKS: { + listClusters: { + property: 'clusters', + paginate: 'nextToken' + } + }, + ECS: { + listClusters: { + property: 'clusterArns', + paginate: 'nextToken' + } + }, + ElasticBeanstalk: { + describeEnvironments: { + property: 'Environments', + paginate: 'NextToken' + } + }, + ELB: { + describeLoadBalancers: { + property: 'LoadBalancerDescriptions', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + EMR: { + listClusters: { + property: 'Clusters', + paginate: 'Marker', + params: { + ClusterStates: [ + 'RUNNING' + ] + } + } + }, + ES: { + listDomainNames: { + property: 'DomainNames', + } + }, + EventBridge: { + listEventBuses: { + property: 'EventBuses', + paginate: 'NextToken', + params: { + Limit: 100, + } + }, + listRules: { + property: 'Rules', + paginate: 'NextToken', + } + }, + Finspace: { + listEnvironments: { + property: 'environments', + paginate: 'nextToken' + } + }, + ForecastService: { + listDatasets: { + property: 'Datasets', + paginate: 'NextToken' + }, + listForecastExportJobs: { + property: 'ForecastExportJobs', + paginate: 'NextToken' + } + }, + FSx: { + describeFileSystems: { + property: 'FileSystems', + paginate: 'NextToken' + } + }, + FraudDetector: { + getDetectors: { + property: 'detectors', + paginate: 'nextToken' + }, + getKMSEncryptionKey: { + property: 'kmsKey' + } + }, + Glue: { + getDataCatalogEncryptionSettings: { + property: 'DataCatalogEncryptionSettings', + }, + getSecurityConfigurations: { + property: 'SecurityConfigurations', + paginate: 'NextMarker' + } + }, + Glacier: { + listVaults: { + paginate: 'Marker', + property: 'VaultList', + params: { + accountId: '-', + limit: '50' + }, + }, + }, + HealthLake: { + listFHIRDatastores: { + property: 'DatastorePropertiesList', + paginate: 'NextToken' + }, + }, + }, + { + IAM: { + listServerCertificates: { + property: 'ServerCertificateMetadataList', + paginate: 'Marker' + }, + listGroups: { + property: 'Groups', + paginate: 'Marker' + }, + listUsers: { + property: 'Users', + paginate: 'Marker' + }, + listRoles: { + property: 'Roles', + override: true + }, + listPolicies: { + property: 'Policies', + paginate: 'Marker', + params: { + OnlyAttached: true // Making this false will effect IAM Support Policy plugin + } + }, + listVirtualMFADevices: { + property: 'VirtualMFADevices', + paginate: 'Marker' + }, + getAccountPasswordPolicy: { + property: 'PasswordPolicy' + }, + getAccountSummary: { + property: 'SummaryMap' + }, + generateCredentialReport: { + override: true + } + }, + IoTSiteWise: { + describeDefaultEncryptionConfiguration: { + } + }, + Kinesis: { + listStreams: { + property: 'StreamNames' + } + }, + KinesisVideo: { + listStreams: { + property: 'StreamInfoList', + paginate: 'NextToken', + } + }, + Firehose: { + listDeliveryStreams: { + property: 'DeliveryStreamNames' + } + }, + GuardDuty: { + listDetectors: { + property: 'DetectorIds', + paginate: 'NextToken', + } + }, + Kafka: { + listClusters: { + property: 'ClusterInfoList', + paginate: 'NextToken' + } + }, + Kendra: { + listIndices: { + property: 'IndexConfigurationSummaryItems', + paginate: 'NextToken' + } + }, + KMS: { + listKeys: { + property: 'Keys', + paginate: 'NextMarker', + paginateReqProp: 'Marker', + params: { + Limit: 1000 + } + }, + listAliases: { + property: 'Aliases', + paginate: 'NextMarker', + paginateReqProp: 'Marker', + params: { + Limit: 100 + } + } + }, + Lambda: { + listFunctions: { + property: 'Functions', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + LookoutEquipment: { + listDatasets: { + property: 'DatasetSummaries', + paginate: 'NextToken' + } + }, + Location: { + listTrackers: { + property: 'Entries', + paginate: 'NextToken', + }, + listGeofenceCollections: { + property: 'Entries', + paginate: 'NextToken', + } + }, + LookoutVision: { + listProjects: { + property: 'Projects', + paginate: 'NextToken' + } + }, + LexModelsV2: { + listBots: { + property: 'botSummaries', + paginate: 'nextToken' + } + }, + LookoutMetrics: { + listAnomalyDetectors: { + property: 'AnomalyDetectorSummaryList', + paginate: 'NextToken' + } + }, + MemoryDB: { + describeClusters: { + property:'Clusters', + paginate:'NextToken' + } + }, + ManagedBlockchain: { + listNetworks: { + property: 'Networks', + paginate: 'NextToken' + } + }, + MQ: { + listBrokers:{ + property:'BrokerSummaries', + paginate:'NextToken' + } + }, + MWAA: { + listEnvironments: { + property: 'Environments', + paginate: 'NextToken' + } + }, + Neptune: { + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker' + }, + describeDBInstances: { + property: 'DBInstances', + paginate: 'Marker' + } + }, + Organizations: { + describeOrganization: { + property: 'Organization', + }, + listHandshakesForAccount: { + property: 'Handshakes', + }, + listAccounts: { + property: 'Accounts', + paginate: 'NextToken' + }, + }, + Proton: { + listEnvironmentTemplates: { + property: 'templates', + paginate: 'nextToken' + } + }, + RDS: { + describeDBInstances: { + property: 'DBInstances', + paginate: 'Marker' + }, + describeDBClusters: { + property: 'DBClusters', + paginate: 'Marker' + }, + describeDBEngineVersions: { + property: 'DBEngineVersions', + paginate: 'Marker', + default: true + }, + describeDBSnapshots: { + property: 'DBSnapshots', + paginate: 'Marker' + }, + describeDBParameterGroups: { + property: 'DBParameterGroups', + paginate: 'Marker' + }, + describeDBClusterSnapshots: { + property: 'DBClusterSnapshots', + paginate: 'Marker' + } + }, + Redshift: { + describeClusters: { + property: 'Clusters', + paginate: 'Marker' + }, + describeClusterParameterGroups: { + property: 'ParameterGroups', + paginate: 'Marker' + }, + describeReservedNodes: { + property: 'ReservedNodes', + paginate: 'Marker' + } + }, + ResourceGroupsTaggingAPI: { + getTagKeys: { + property: 'TagKeys', + paginate: 'PaginationToken' + }, + getResources: { + property: 'ResourceTagMappingList', + paginate: 'PaginationToken' + } + }, + Route53: { + listHostedZones: { + property: 'HostedZones', + paginate: 'NextPageMarker', + paginateReqProp: 'Marker' + }, + }, + Route53Domains: { + listDomains: { + property: 'Domains', + paginate: 'NextPageMarker', + paginateReqProp: 'Marker' + } + }, + S3: { + listBuckets: { + property: 'Buckets' + } + }, + SageMaker: { + listNotebookInstances: { + property: 'NotebookInstances', + paginate: 'NextToken' + } + }, + SecretsManager: { + listSecrets: { + property: 'SecretList', + paginate: 'NextToken' + } + }, + ServiceQuotas: { + listServiceQuotas: { + property: 'Quotas', + paginate: 'NextToken', + params: { + ServiceCode: 'ec2' + }, + } + }, + SES: { + listIdentities: { + property: 'Identities', + paginate: 'NextToken', + params: { + IdentityType: 'Domain', // TODO: maybe don't filter these? + MaxItems: 1000 + }, + rateLimit: 1000 // ms to rate limit between regions + }, + describeActiveReceiptRuleSet: { + } + }, + Shield: { + describeSubscription: { + property: 'Subscription' + }, + describeEmergencyContactSettings: { + property: 'EmergencyContactList' + }, + listProtections: { + property: 'Protections' + } + }, + SNS: { + listTopics: { + property: 'Topics', + paginate: 'NextToken' + }, + listSubscriptions: { + property: 'Subscriptions', + paginate: 'NextToken' + }, + }, + SQS: { + listQueues: { + property: 'QueueUrls' + } + }, + SSM: { + describeInstanceInformation: { + property: 'InstanceInformationList', + params: { + MaxResults: 50 + }, + paginate: 'NextToken' + }, + describeParameters: { + property: 'Parameters', + override: true + }, + listAssociations: { + property: 'Associations', + paginate: 'NextToken' + }, + getServiceSetting: { + property: 'ServiceSetting', + paginate: 'NextToken', + params: { + SettingId: '/ssm/documents/console/public-sharing-permission' + } + }, + describeSessions: { + property: 'Sessions', + paginate: 'NextToken', + params: { + State: 'Active' + } + }, + }, + STS: { + getCallerIdentity: { + property: 'Account' + } + }, + Support: { + describeTrustedAdvisorChecks: { + property: 'checks', + params: {language: 'en'}, + }, + }, + SecurityHub: { + describeHub: { + property: '', + paginate: 'NextToken' + }, + getFindings: { + property: 'Findings', + paginate: 'NextToken', + params: { + MaxResults: 100, + Filters: { + RecordState: [ + { + Comparison: 'EQUALS', + Value: 'ACTIVE' + } + ], + WorkflowStatus: [ + { + Comparison: 'EQUALS', + Value: 'NEW' + } + ] + } + } + } + }, + Transfer: { + listServers: { + property: 'Servers', + paginate: 'NextToken', + params: { + MaxResults: 1000 + } + } + }, + Translate: { + listTextTranslationJobs: { + property: 'TextTranslationJobPropertiesList', + paginate: 'NextToken' + } + }, + VoiceID: { + listDomains: { + property: 'DomainSummaries', + paginate: 'NextToken' + } + }, + WAFRegional: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker' + } + }, + WAFV2: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker', + params: { + Scope: 'REGIONAL' + } + } + }, + WAF: { + listWebACLs: { + property: 'WebACLs', + paginate: 'NextMarker' + } + }, + WorkSpaces: { + describeWorkspaces: { + property: 'Workspaces', + paginate: 'NextToken' + }, + describeWorkspaceDirectories: { + property: 'Directories', + paginate: 'NextToken' + }, + describeIpGroups: { + property: 'Result', + paginate: 'NextToken' + }, + describeWorkspacesConnectionStatus: { + property: 'WorkspacesConnectionStatus', + paginate: 'NextToken' + } + }, + Wisdom: { + listAssistants: { + property: 'assistantSummaries', + paginate: 'NextToken' + } + }, + XRay: { + getEncryptionConfig: { + property: 'EncryptionConfig' + } + } + }, + { + ELBv2: { + describeLoadBalancers: { + property: 'LoadBalancers', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + }, + describeTargetGroups: { + property: 'TargetGroups', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + }, + describeTargetHealth: { + property: 'TargetGroups', + paginate: 'NextMarker', + paginateReqProp: 'Marker' + } + }, + Imagebuilder: { + listContainerRecipes: { + property: 'containerRecipeSummaryList', + paginate: 'nextToken' + }, + listComponents: { + property: 'componentVersionList', + paginate: 'nextToken' + }, + paginatelistImagePipelines: { + property: 'imagePipelineList', + paginate: 'nextToken' + }, + listImageRecipes: { + property: 'imageRecipeSummaryList', + paginate: 'nextToken' + }, + listInfrastructureConfigurations: { + property: 'infrastructureConfigurationSummaryList', + paginate: 'nextToken' + } + }, + CognitoIdentityServiceProvider: { + listUserPools: { + property: 'UserPools', + paginate: 'NextToken', + params: { + MaxResults: 60 + } + }, + }, + } +]; + +var postcalls = [ + { + ACM: { + describeCertificate: { + reliesOnService: 'acm', + reliesOnCall: 'listCertificates', + filterKey: 'CertificateArn', + filterValue: 'CertificateArn' + } + }, + AccessAnalyzer: { + listFindings: { + reliesOnService: 'accessanalyzer', + reliesOnCall: 'listAnalyzers', + override: true + } + }, + APIGateway: { + getStages: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + }, + getResources: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + }, + getRequestValidators: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + filterKey: 'restApiId', + filterValue: 'id' + } + }, + AppConfig: { + listConfigurationProfiles: { + reliesOnService: 'appconfig', + reliesOnCall: 'listApplications', + filterKey: 'ApplicationId', + filterValue: 'Id' + } + }, + AppMesh: { + listVirtualGateways: { + reliesOnService: 'appmesh', + reliesOnCall: 'listMeshes', + filterKey: 'meshName', + filterValue: 'meshName' + }, + describeMesh: { + reliesOnService: 'appmesh', + reliesOnCall: 'listMeshes', + filterKey: 'meshName', + filterValue: 'meshName' + } + }, + AppRunner: { + describeService: { + reliesOnService: 'apprunner', + reliesOnCall: 'listServices', + filterKey: 'ServiceArn', + filterValue: 'ServiceArn' + } + }, + Appflow: { + describeFlow: { + reliesOnService: 'appflow', + reliesOnCall: 'listFlows', + filterKey: 'flowName', + filterValue: 'flowName' + } + }, + Athena: { + getWorkGroup: { + reliesOnService: 'athena', + reliesOnCall: 'listWorkGroups', + filterKey: 'WorkGroup', + filterValue: 'Name' + } + }, + Backup: { + getBackupVaultNotifications: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupVaults', + filterKey: 'BackupVaultName', + filterValue: 'BackupVaultName', + }, + getBackupVaultAccessPolicy: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupVaults', + filterKey: 'BackupVaultName', + filterValue: 'BackupVaultName', + }, + getBackupPlan: { + reliesOnService: 'backup', + reliesOnCall: 'listBackupPlans', + filterKey: 'BackupPlanId', + filterValue: 'BackupPlanId', + } + }, + Bedrock:{ + getCustomModel: { + reliesOnService: 'bedrock', + reliesOnCall: 'listCustomModels', + filterKey: 'modelIdentifier', + filterValue: 'modelName', + }, + getModelCustomizationJob: { + reliesOnService: 'bedrock', + reliesOnCall: 'listModelCustomizationJobs', + filterKey: 'jobIdentifier', + filterValue: 'jobArn', + } + }, + CloudFront: { + getDistribution: { + reliesOnService: 'cloudfront', + reliesOnCall: 'listDistributions', + override: true + } + }, + CloudTrail: { + getTrailStatus: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + filterKey: 'Name', + filterValue: 'TrailARN' + }, + listTags: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + override: true + }, + getEventSelectors: { + reliesOnService: 'cloudtrail', + reliesOnCall: 'describeTrails', + filterKey: 'TrailName', + filterValue: 'TrailARN' + } + }, + CloudWatch: { + getEsMetricStatistics: { + reliesOnService: 'es', + reliesOnCall: 'listDomainNames', + override: true, + }, + getEcMetricStatistics: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + override: true, + }, + getEc2MetricStatistics: { + reliesOnService: 'ec2', + reliesOnCall: 'describeInstances', + override: true, + }, + getredshiftMetricStatistics: { + reliesOnService: 'redshift', + reliesOnCall: 'describeClusters', + override: true, + }, + getRdsMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + }, + getRdsWriteIOPSMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + }, + getRdsReadIOPSMetricStatistics: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBInstances', + override: true, + } + }, + ConfigService: { + getComplianceDetailsByConfigRule: { + reliesOnService: 'configservice', + reliesOnCall: 'describeConfigRules', + filterKey: 'ConfigRuleName', + filterValue: 'ConfigRuleName' + } + }, + CodeStar: { + describeProject: { + reliesOnService: 'codestar', + reliesOnCall: 'listProjects', + filterKey: 'id', + filterValue: 'projectId' + } + }, + CustomerProfiles: { + getDomain: { + reliesOnService: 'customerprofiles', + reliesOnCall: 'listDomains', + filterKey: 'DomainName', + filterValue: 'DomainName' + } + }, + CodeBuild: { + batchGetProjects: { + reliesOnService: 'codebuild', + reliesOnCall: 'listProjects', + override: true + } + }, + CodePipeline: { + getPipeline: { + reliesOnService: 'codepipeline', + reliesOnCall: 'listPipelines', + filterKey: 'name', + filterValue: 'name' + } + }, + Connect: { + listInstanceCallRecordingStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceMediaStreamStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceChatTranscriptStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + listInstanceExportedReportStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + }, + instanceAttachmentStorageConfigs: { + reliesOnService: 'connect', + reliesOnCall: 'listInstances', + override: true + } + }, + DocDB: { + listTagsForResource: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'ResourceName', + filterValue: 'DBClusterArn' + }, + describeDBClusterParameters: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'DBClusterParameterGroupName', + filterValue: 'DBClusterParameterGroup' + }, + }, + DynamoDB: { + describeTable: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + }, + describeContinuousBackups: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + }, + listBackups: { + reliesOnService: 'dynamodb', + reliesOnCall: 'listTables', + override: true + } + }, + ElastiCache: { + describeReplicationGroups: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + filterKey: 'ReplicationGroupId', + filterValue: 'ReplicationGroupId' + }, + describeCacheSubnetGroups: { + reliesOnService: 'elasticache', + reliesOnCall: 'describeCacheClusters', + override: true + }, + }, + ES: { + describeElasticsearchDomain: { + reliesOnService: 'es', + reliesOnCall: 'listDomainNames', + filterKey: 'DomainName', + filterValue: 'DomainName' + } + }, + S3: { + getBucketLogging: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketVersioning: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketAcl: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketPolicy: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketPolicyStatus: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override:true + }, + getBucketEncryption: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketTagging: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketLocation: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getPublicAccessBlock: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketWebsite: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getObjectLockConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketLifecycleConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + deleteRegion: true, + signatureVersion: 'v4', + override: true + }, + getBucketAccelerateConfiguration: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + headBucket: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + listObjects: { + reliesOnService: 's3', + reliesOnCall: 'listBuckets', + filterKey: 'Bucket', + filterValue: 'Name' + }, + sendIntegration: { + enabled: true + } + } + }, + { + EC2: { + describeSubnets: { + reliesOnService: 'ec2', + reliesOnCall: 'describeVpcs', + override: true + }, + describeLaunchTemplateVersions: { + reliesOnService: 'ec2', + reliesOnCall: 'describeLaunchTemplates', + filterKey: 'LaunchTemplateId', + filterValue: 'LaunchTemplateId' + }, + sendIntegration: { + sendLast: true, + enabled: true, + integrationReliesOn: { + serviceName: ['ELBv2', 'IAM'] + } + } + }, + ECR: { + getRepositoryPolicy: { + reliesOnService: 'ecr', + reliesOnCall: 'describeRepositories', + filterKey: 'repositoryName', + filterValue: 'repositoryName' + }, + listTagsForResource:{ + reliesOnService: 'ecr', + reliesOnCall: 'describeRepositories', + filterKey: 'resourceArn', + filterValue: 'repositoryArn' + }, + sendIntegration: { + enabled: true + } + }, + ECRPUBLIC: { + describeRepositories: { + reliesOnService: 'ecr', + reliesOnCall: 'describeRegistries', + filterKey: 'registryId', + filterValue: 'registryId' + } + }, + EKS: { + describeCluster: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + }, + listNodegroups: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + } + }, + ECS: { + describeCluster: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + }, + listContainerInstances: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + }, + listServices: { + reliesOnService: 'ecs', + reliesOnCall: 'listClusters', + override: true + } + }, + ELB: { + describeLoadBalancerPolicies: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTags: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + } + }, + ELBv2: { + describeTargetHealth: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeTargetGroups', + filterKey: 'TargetGroupArn', + filterValue: 'TargetGroupArn' + }, + describeTargetGroupAttributes: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeTargetGroups', + filterKey: 'TargetGroupArn', + filterValue: 'TargetGroupArn' + } + }, + EMR: { + describeCluster: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + filterKey: 'ClusterId', + filterValue: 'Id' + } + }, + DLM: { + getLifecyclePolicy: { + reliesOnService: 'dlm', + reliesOnCall: 'getLifecyclePolicies', + filterKey: 'PolicyId', + filterValue: 'PolicyId' + } + }, + ForecastService: { + describeDataset: { + reliesOnService: 'forecastservice', + reliesOnCall: 'listDatasets', + filterKey: 'DatasetArn', + filterValue: 'DatasetArn' + } + }, + Glacier: { + getVaultAccessPolicy: { + reliesOnService: 'glacier', + reliesOnCall: 'listVaults', + filterKey: 'vaultName', + filterValue: 'VaultName' + } + }, + }, + { + EC2: { + describeSnapshotAttribute: { + reliesOnService: 'ec2', + reliesOnCall: 'describeSnapshots', + override: true + }, + describeVpcEndpointServicePermissions: { + reliesOnService: 'ec2', + reliesOnCall: 'describeVpcEndpointServices', + filterKey: 'ServiceId', + filterValue: 'ServiceId' + }, + }, + ElasticBeanstalk: { + describeConfigurationSettings: { + reliesOnService: 'elasticbeanstalk', + reliesOnCall: 'describeEnvironments', + override: true + } + }, + ELB: { + describeLoadBalancerAttributes: { + reliesOnService: 'elb', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + }, + ELBv2: { + describeLoadBalancerAttributes: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeListeners: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTargetGroups: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + describeTags: { + reliesOnService: 'elbv2', + reliesOnCall: 'describeLoadBalancers', + override: true + }, + sendIntegration: { + enabled: true + }, + }, + EMR: { + listInstanceGroups: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + filterKey: 'ClusterId', + filterValue: 'Id' + } + }, + AutoScaling: { + describeNotificationConfigurations: { + reliesOnService: 'autoscaling', + reliesOnCall: 'describeAutoScalingGroups', + override: true + }, + describeLaunchConfigurations: { + reliesOnService: 'autoscaling', + reliesOnCall: 'describeAutoScalingGroups', + override: true + } + }, + }, + { + CloudFormation: { + describeStackEvents: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName', + rateLimit: 500 // ms to rate limit between stacks + }, + describeStacks: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName', + rateLimit: 500 // ms to rate limit between stacks + }, + getTemplate: { + reliesOnService: 'cloudformation', + reliesOnCall: 'listStacks', + filterKey: 'StackName', + filterValue: 'StackName' + }, + }, + WAFRegional: { + listResourcesForWebACL: { + reliesOnService: 'wafregional', + reliesOnCall: 'listWebACLs', + override: true, + rateLimit: 600 + } + }, + WAFV2: { + listResourcesForWebACL: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + override: true, + rateLimit: 600 + }, + getLoggingConfiguration: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + filterKey: 'ResourceArn', + filterValue: 'ARN' + }, + getWebACLForCognitoUserPool: { + reliesOnService: 'cognitoidentityserviceprovider', + reliesOnCall: 'listUserPools', + override: true + }, + getWebACL: { + reliesOnService: 'wafv2', + reliesOnCall: 'listWebACLs', + override: true + } + }, + ECS: { + describeContainerInstances: { + override:true + }, + listTasks: { + reliesOnService: 'ecs', + reliesOnCall: 'listContainerInstances', + override:true + }, + describeServices: { + override:true + } + }, + }, + { + IAM: { + getGroup: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName', + rateLimit: 200 + }, + listAttachedUserPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 200 + }, + listAttachedGroupPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName', + rateLimit: 200 + }, + listAttachedRolePolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + filterKey: 'RoleName', + filterValue: 'RoleName', + rateLimit: 200 + }, + listUserPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 200 + }, + getInstanceProfile: { + reliesOnService: 'ec2', + reliesOnCall: 'describeInstances', + override: true + }, + listGroupPolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + filterKey: 'GroupName', + filterValue: 'GroupName', + rateLimit: 200 + }, + listSSHPublicKeys: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 200 + }, + listMFADevices: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 200 + }, + listGroupsForUser: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 500 + }, + sendIntegration: { + enabled: true + } + }, + ECS: { + describeTasks: { + override:true + }, + sendIntegration: { + enabled: true + } + } + }, + { + Kendra: { + describeIndex: { + reliesOnService: 'kendra', + reliesOnCall: 'listIndices', + filterKey: 'Id', + filterValue: 'Id' + } + }, + Kinesis: { + describeStream: { + reliesOnService: 'kinesis', + reliesOnCall: 'listStreams', + override: true + } + }, + Firehose: { + describeDeliveryStream: { + reliesOnService: 'firehose', + reliesOnCall: 'listDeliveryStreams', + override: true + } + }, + KMS: { + describeKey: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + getKeyRotationStatus: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + getKeyPolicy: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + override: true + }, + listResourceTags: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + filterKey: 'KeyId', + filterValue: 'KeyId' + }, + listGrants: { + reliesOnService: 'kms', + reliesOnCall: 'listKeys', + override: true + } + }, + Lambda: { + getPolicy: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + rateLimit: 500, // it's not documented but experimental 10/second works. + }, + getFunction: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + listTags: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'Resource', + filterValue: 'FunctionArn' + }, + getFunctionUrlConfig :{ + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + getFunctionConfiguration: { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName' + }, + getFunctionCodeSigningConfig : { + reliesOnService: 'lambda', + reliesOnCall: 'listFunctions', + filterKey: 'FunctionName', + filterValue: 'FunctionName', + }, + sendIntegration: { + enabled: true + } + }, + LookoutEquipment: { + describeDataset: { + reliesOnService: 'lookoutequipment', + reliesOnCall: 'listDatasets', + filterKey: 'DatasetName', + filterValue: 'DatasetName' + } + }, + Location: { + describeTracker: { + reliesOnService: 'location', + reliesOnCall: 'listTrackers', + filterKey: 'TrackerName', + filterValue: 'TrackerName' + }, + describeGeofenceCollection: { + reliesOnService: 'location', + reliesOnCall: 'listGeofenceCollections', + filterKey: 'CollectionName', + filterValue: 'CollectionName' + } + }, + LookoutVision: { + listModels: { + reliesOnService: 'lookoutvision', + reliesOnCall: 'listProjects', + filterKey: 'ProjectName', + filterValue: 'ProjectName' + } + }, + LexModelsV2: { + listBotAliases: { + reliesOnService: 'lexmodelsv2', + reliesOnCall: 'listBots', + filterKey: 'botId', + filterValue: 'botId' + } + }, + ManagedBlockchain: { + listMembers: { + reliesOnService: 'managedblockchain', + reliesOnCall: 'listNetworks', + filterKey: 'NetworkId', + filterValue: 'Id' + } + }, + MQ: { + describeBroker: { + reliesOnService: 'mq', + reliesOnCall: 'listBrokers', + filterKey: 'BrokerId', + filterValue: 'BrokerId' + } + }, + LookoutMetrics: { + describeAnomalyDetector: { + reliesOnService: 'lookoutmetrics', + reliesOnCall: 'listAnomalyDetectors', + filterKey: 'AnomalyDetectorArn', + filterValue: 'AnomalyDetectorArn' + } + }, + MWAA: { + getEnvironment: { + reliesOnService: 'mwaa', + reliesOnCall: 'listEnvironments', + override: true + } + }, + Proton: { + getEnvironmentTemplate: { + reliesOnService: 'proton', + reliesOnCall: 'listEnvironmentTemplates', + filterKey: 'name', + filterValue: 'name' + } + }, + S3Control: { + getPublicAccessBlock: { + reliesOnService: 'sts', + reliesOnCall: 'getCallerIdentity', + override: true + } + }, + Redshift: { + describeClusterParameters: { + reliesOnService: 'redshift', + reliesOnCall: 'describeClusterParameterGroups', + filterKey: 'ParameterGroupName', + filterValue: 'ParameterGroupName' + } + }, + SageMaker: { + describeNotebookInstance: { + reliesOnService: 'sagemaker', + reliesOnCall: 'listNotebookInstances', + filterKey: 'NotebookInstanceName', + filterValue: 'NotebookInstanceName' + } + }, + SecretsManager: { + describeSecret: { + reliesOnService: 'secretsmanager', + reliesOnCall: 'listSecrets', + filterKey: 'SecretId', + filterValue: 'ARN', + } + }, + SES: { + getIdentityDkimAttributes: { + reliesOnService: 'ses', + reliesOnCall: 'listIdentities', + override: true, + rateLimit: 1000 + } + }, + SNS: { + getTopicAttributes: { + reliesOnService: 'sns', + reliesOnCall: 'listTopics', + filterKey: 'TopicArn', + filterValue: 'TopicArn' + } + }, + SQS: { + getQueueAttributes: { + reliesOnService: 'sqs', + reliesOnCall: 'listQueues', + override: true + } + }, + Support: { + describeTrustedAdvisorCheckResult: { + reliesOnService: 'support', + reliesOnCall: 'describeTrustedAdvisorChecks', + filterKey: 'checkId', + filterValue: 'id' + }, + }, + GuardDuty: { + getDetector: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + getMasterAccount: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + listFindings: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + listPublishingDestinations: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + }, + }, + { + APIGateway: { + getClientCertificate: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + override: true + }, + getIntegration: { + reliesOnService: 'apigateway', + reliesOnCall: 'getRestApis', + override: true + }, + sendIntegration: { + enabled: true + } + }, + EMR: { + describeSecurityConfiguration: { + reliesOnService: 'emr', + reliesOnCall: 'listClusters', + override: true + } + }, + IAM: { + getUserPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + override: true, + rateLimit: 500 + }, + getGroupPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listGroups', + override: true, + rateLimit: 500 + }, + getRolePolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + override: true, + rateLimit: 500 + } + }, + EKS: { + describeNodegroups: { + reliesOnService: 'eks', + reliesOnCall: 'listClusters', + override: true + }, + sendIntegration: { + enabled: true + } + }, + }, + { + IAM: { + getPolicy: { + reliesOnService: 'iam', + reliesOnCall: 'listPolicies', + filterKey: 'PolicyArn', + filterValue: 'Arn', + rateLimit: 500 + }, + getUser: { + reliesOnService: 'iam', + reliesOnCall: 'listUsers', + filterKey: 'UserName', + filterValue: 'UserName', + rateLimit: 500 + } + }, + LookoutVision: { + describeModel: { + reliesOnService: 'lookoutvision', + reliesOnCall: 'listProjects', + override: true + } + }, + GuardDuty: { + getFindings: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + describePublishingDestination: { + reliesOnService: 'guardduty', + reliesOnCall: 'listDetectors', + override: true, + }, + }, + LexModelsV2:{ + describeBotAlias: { + reliesOnService: 'lexmodelsv2', + reliesOnCall: 'listBots', + override: true, + } + }, + ManagedBlockchain: { + getMember: { + reliesOnService: 'managedblockchain', + reliesOnCall: 'listNetworks', + override: true + } + } + + }, + { + IAM: { + getPolicyVersion: { + reliesOnService: 'iam', + reliesOnCall: 'listPolicies', + override: true, + rateLimit: 500 + }, + getRole: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + override: true, + rateLimit: 500 + }, + listRolePolicies: { + reliesOnService: 'iam', + reliesOnCall: 'listRoles', + filterKey: 'RoleName', + filterValue: 'RoleName', + rateLimit: 500 + } + } + }, + { + RDS: { + describeDBParameters: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBParameterGroups', + override: true + }, + describeDBSnapshotAttributes: { + reliesOnService: 'rds', + reliesOnCall: 'describeDBSnapshots', + filterKey: 'DBSnapshotIdentifier', + filterValue: 'DBSnapshotIdentifier' + }, + sendIntegration: { + enabled: true + } + }, + Route53: { + listResourceRecordSets: { + reliesOnService: 'route53', + reliesOnCall: 'listHostedZones', + filterKey: 'HostedZoneId', + filterValue: 'Id' + }, + }, + Route53Domains: { + getDomainDetail: { + reliesOnService: 'route53domains', + reliesOnCall: 'listDomains', + filterKey: 'DomainName', + filterValue: 'DomainName' + }, + }, + Imagebuilder: { + getContainerRecipe: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listContainerRecipes', + filterKey: 'containerRecipeArn', + filterValue: 'arn' + }, + getComponent: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listComponents', + filterKey: 'componentBuildVersionArn', + filterValue: 'arn' + }, + getInfrastructureConfiguration: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listInfrastructureConfigurations', + filterKey: 'infrastructureConfigurationArn', + filterValue: 'arn' + }, + getImageRecipe: { + reliesOnService: 'imagebuilder', + reliesOnCall: 'listImageRecipes', + filterKey: 'imageRecipeArn', + filterValue: 'arn' + } + }, + CognitoIdentityServiceProvider: { + describeUserPool: { + reliesOnService: 'cognitoidentityserviceprovider', + reliesOnCall: 'listUserPools', + filterKey: 'UserPoolId', + filterValue: 'Id' + } + }, + OpenSearchServerless: { + getNetworkSecurityPolicy: { + reliesOnService: 'opensearchserverless', + reliesOnCall: 'listNetworkSecurityPolicies', + override: true, + }, + getEncryptionSecurityPolicy: { + reliesOnService: 'opensearchserverless', + reliesOnCall: 'listEncryptionSecurityPolicies', + override: true, + } + } + }, +]; + +module.exports = { + globalServicesMultipart: globalServices, + callsMultipart: calls, + postcallsMultipart: postcalls, + integrationSendLast: integrationSendLast +}; diff --git a/helpers/aws/functions.js b/helpers/aws/functions.js index c74f53f77..c938c1453 100644 --- a/helpers/aws/functions.js +++ b/helpers/aws/functions.js @@ -3,6 +3,17 @@ var regions = require(__dirname + '/regions'); var AWS = require('aws-sdk'); var helpers = require('../shared.js'); +function isValidCidr(cidr) { + if (!cidr || typeof cidr !== 'string') return false; + var ipv4CidrPattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/; + var ipv4SinglePattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + var ipv6CidrPattern = /^([0-9a-fA-F:]+)\/(\d{1,3})$/; + var trimmed = cidr.trim(); + return ipv4CidrPattern.test(trimmed) || + ipv4SinglePattern.test(trimmed) || + ipv6CidrPattern.test(trimmed); +} + function waitForCredentialReport(iam, callback, CREDENTIAL_DOWNLOAD_STARTED) { if (!CREDENTIAL_DOWNLOAD_STARTED) { iam.generateCredentialReport(function(err, data){ @@ -32,7 +43,6 @@ function waitForCredentialReport(iam, callback, CREDENTIAL_DOWNLOAD_STARTED) { //return callback(CREDENTIAL_REPORT_ERROR); return callback('Error downloading report'); } - //CREDENTIAL_REPORT_DATA = reportData; //callback(null, CREDENTIAL_REPORT_DATA); callback(null, reportData); @@ -44,7 +54,7 @@ function addResult(results, status, message, region, resource, custom){ // Override unknown results for regions that are opt-in if (status == 3 && region && regions.optin.indexOf(region) > -1 && message && (message.indexOf('AWS was not able to validate the provided access credentials') > -1 || - message.indexOf('The security token included in the request is invalid') > -1)) { + message.indexOf('The security token included in the request is invalid') > -1)) { results.push({ status: 0, message: 'Region is not enabled', @@ -63,14 +73,17 @@ function addResult(results, status, message, region, resource, custom){ } } -function findOpenPorts(groups, ports, service, region, results) { - var found = false; - +function findOpenPorts(groups, ports, service, region, results, cache, config, callback, settings={}) { + if (config.ec2_skip_unused_groups) { + var usedGroups = getUsedSecurityGroups(cache, results, region); + if (usedGroups && usedGroups.length && usedGroups[0] === 'Error') return callback(); + } + var awsOrGov = defaultPartition(settings); for (var g in groups) { - var strings = []; - var resource = 'arn:aws:ec2:' + region + ':' + - groups[g].OwnerId + ':security-group/' + - groups[g].GroupId; + var string; + var openV4Ports = []; + var openV6Ports = []; + var resource = `arn:${awsOrGov}:ec2:${region}:${groups[g].OwnerId}:security-group/${groups[g].GroupId}`; for (var p in groups[g].IpPermissions) { var permission = groups[g].IpPermissions[p]; @@ -81,12 +94,24 @@ function findOpenPorts(groups, ports, service, region, results) { if (range.CidrIp === '0.0.0.0/0' && ports[permission.IpProtocol]) { for (var portIndex in ports[permission.IpProtocol]) { var port = ports[permission.IpProtocol][portIndex]; - - if (permission.FromPort <= port && permission.ToPort >= port) { - var string = permission.IpProtocol.toUpperCase() + - ' port ' + port + ' open to 0.0.0.0/0'; - if (strings.indexOf(string) === -1) strings.push(string); - found = true; + if (port.toString().indexOf('-') > -1) { + var portRange = port.split('-'); + var rangeFrom = Number(portRange[0]); + var rangeTo = Number(portRange[1]); + + for (let i = rangeFrom; i <= rangeTo; i++) { + if (permission.FromPort <= i && permission.ToPort >= i) { + string = `some of ${permission.IpProtocol.toUpperCase()}:${port}`; + openV4Ports.push(string); + break; + } + } + } else { + port = Number(port); + if (permission.FromPort <= port && permission.ToPort >= port) { + string = `${permission.IpProtocol.toUpperCase()}:${port}`; + if (openV4Ports.indexOf(string) === -1) openV4Ports.push(string); + } } } } @@ -98,34 +123,119 @@ function findOpenPorts(groups, ports, service, region, results) { if (rangeV6.CidrIpv6 === '::/0' && ports[permission.IpProtocol]) { for (var portIndexV6 in ports[permission.IpProtocol]) { var portV6 = ports[permission.IpProtocol][portIndexV6]; - - if (permission.FromPort <= portV6 && permission.ToPort >= portV6) { - var stringV6 = permission.IpProtocol.toUpperCase() + - ' port ' + portV6 + ' open to ::/0'; - if (strings.indexOf(stringV6) === -1) strings.push(stringV6); - found = true; + if (portV6.toString().indexOf('-') > -1) { + var portRangeV6 = Number(portV6.split('-')); + var rangeFromV6 = Number(portRangeV6[0]); + var rangeToV6 = portRangeV6[1]; + + for (let i = rangeFromV6; i <= rangeToV6; i++) { + if (permission.FromPort <= i && permission.ToPort >= i) { + string = `some of ${permission.IpProtocol.toUpperCase()}:${portV6}`; + openV6Ports.push(string); + break; + } + } + } else { + portV6 = Number(portV6); + if (permission.FromPort <= portV6 && permission.ToPort >= portV6) { + var stringV6 = `${permission.IpProtocol.toUpperCase()}:${portV6}`; + if (openV6Ports.indexOf(stringV6) === -1) openV6Ports.push(stringV6); + } } } } } } - if (strings.length) { - addResult(results, 2, - 'Security group: ' + groups[g].GroupId + - ' (' + groups[g].GroupName + - ') has ' + service + ': ' + strings.join(' and '), region, - resource); - } - } + if (openV4Ports.length || openV6Ports.length) { + var resultsString = ''; + if (openV4Ports.length) { + resultsString = `Security group: ${groups[g].GroupId} (${groups[g].GroupName}) has ${service}:${openV4Ports.join(' and ')} open to 0.0.0.0/0`; + } - if (!found) { - addResult(results, 0, 'No public open ports found', region); + if (openV6Ports.length) { + if (resultsString.length) { + resultsString = `${resultsString} and ${openV6Ports.join(' and ')} open to ::/0`; + } else { + resultsString = `Security group: ${groups[g].GroupId} (${groups[g].GroupName}) has ${service}:${openV6Ports.join(' and ')} open to ::/0`; + } + } + + if (config.ec2_skip_unused_groups && groups[g].GroupId && (!usedGroups || !usedGroups.includes(groups[g].GroupId))) { + addResult(results, 1, `Security Group: ${groups[g].GroupId} is not in use`, + region, resource); + } else if (config.check_network_interface) { + checkNetworkInterface(groups[g].GroupId,groups[g].GroupName, resultsString, region, results, resource, cache); + } else { + addResult(results, 2, resultsString, + region, resource); + } + } else { + let strings = []; + + for (const key in ports) { + strings.push(`${key.toUpperCase()}:${ports[key]}`); + } + + if (strings.length){ + addResult(results, 0, + `Security group: ${groups[g].GroupId} (${groups[g].GroupName}) does not have ${strings.join(', ')} open to 0.0.0.0/0 or ::0`, + region, resource); + } + } } return; } +function checkNetworkInterface(groupId, groupName, resultsString, region, results, resource, cache, bool = false) { + const describeNetworkInterfaces = helpers.addSource(cache, {}, + ['ec2', 'describeNetworkInterfaces', region]); + + if (!describeNetworkInterfaces || describeNetworkInterfaces.err || !describeNetworkInterfaces.data) { + if (bool) { + return false; + } + helpers.addResult(results, 3, + 'Unable to query for network interfaces: ' + helpers.addError(describeNetworkInterfaces), region); + return; + } + let hasOpenSecurityGroup = false; + let networksWithSecurityGroup = []; + for (var network of describeNetworkInterfaces.data) { + for (const group of network.Groups) { + if (groupId === group.GroupId) { + networksWithSecurityGroup.push(network); + hasOpenSecurityGroup = true; + break; + } + } + } + if (bool && !networksWithSecurityGroup.length) { + return groupId; + } + let exposedENI; + if (hasOpenSecurityGroup) { + let hasPublicIp = false; + for (var eni of networksWithSecurityGroup) { + if (eni.Association && eni.Association.PublicIp) { + hasPublicIp = true; + exposedENI = `sg ${groupId} > eni ${eni.NetworkInterfaceId}`; + break; + } + } + if (hasPublicIp) { + if (bool) return exposedENI; + addResult(results, 2, `Security Group ${groupId}(${groupName}) is associated with an ENI that is publicly exposed`, region, resource); + } else { + if (bool) return false; + addResult(results, 0, `Security Group ${groupId} (${groupName}) is only exposed internally`, region, resource); + } + } else { + if (bool) return false; + addResult(results, 2, resultsString, region, resource); + } +} function normalizePolicyDocument(doc) { /* Convert a policy document for IAM into a normalized object that can be used @@ -176,43 +286,237 @@ function normalizePolicyDocument(doc) { return statementsToReturn; } -function globalPrincipal(principal) { +function globalPrincipal(principal, settings={}) { + if (!principal) return false; + if (typeof principal === 'string' && principal === '*') { return true; } var awsPrincipals = principal.AWS; - if(!Array.isArray(awsPrincipals)) { + if (!Array.isArray(awsPrincipals)) { awsPrincipals = [awsPrincipals]; } + var awsOrGov = defaultPartition(settings); if (awsPrincipals.indexOf('*') > -1 || - awsPrincipals.indexOf('arn:aws:iam::*') > -1) { + awsPrincipals.indexOf(`arn:${awsOrGov}:iam::*`) > -1) { return true; } return false; } -function crossAccountPrincipal(principal, accountId) { +function userGlobalAccess(statement, restrictedPermissions) { + if (statement.Effect && statement.Effect === 'Allow' && + statement.Action && restrictedPermissions.some(permission=> statement.Action.includes(permission))) { + return true; + } + + return false; +} + +function crossAccountPrincipal(principal, accountId, fetchPrincipals, settings={}) { + var awsOrGov = defaultPartition(settings); if (typeof principal === 'string' && - /^[0-9]{12}$/.test(principal) && - principal !== accountId) { + (/^[0-9]{12}$/.test(principal) || new RegExp(`^arn:${awsOrGov}:.*/`).test(principal)) && + !principal.includes(accountId)) { + if (fetchPrincipals) return [principal]; return true; } var awsPrincipals = principal.AWS; - if(!Array.isArray(awsPrincipals)) { + if (!Array.isArray(awsPrincipals)) { awsPrincipals = [awsPrincipals]; } + var principals = []; + for (var a in awsPrincipals) { - if (/^arn:aws:iam::[0-9]{12}.*/.test(awsPrincipals[a]) && + if (new RegExp(`^arn:${awsOrGov}:.*`).test(awsPrincipals[a]) && awsPrincipals[a].indexOf(accountId) === -1) { - return true; + if (!fetchPrincipals) return true; + principals.push(awsPrincipals[a]); } } + if (fetchPrincipals) return principals; + return false; +} + +function hasFederatedUserRole(policyDocument) { + if (!policyDocument || !Array.isArray(policyDocument)) { + return false; + } + // true iff every statement refers to federated user access + for (let statement of policyDocument) { + if (statement.Action && + !statement.Action.includes('sts:AssumeRoleWithSAML') && + !statement.Action.includes('sts:AssumeRoleWithWebIdentity')){ + return false; + } + } + return true; +} + +function extractStatementPrincipals(statement) { + let response = []; + if (statement.Principal) { + let principal = statement.Principal; + + if (typeof principal === 'string') { + return [principal]; + } + + if (!principal.AWS) return response; + + var awsPrincipals = principal.AWS; + if (!Array.isArray(awsPrincipals)) { + awsPrincipals = [awsPrincipals]; + } + + response.push.apply(response, awsPrincipals); + } + + return response; +} + +function getDenyPermissionsMap(statements, excludeStatementId) { + let permissionsMap = {}; + + for (let statement of statements) { + if ((statement.Sid && statement.Sid == excludeStatementId) || (statement.Effect && statement.Effect.toUpperCase() !== 'DENY')) continue; + + let principals = extractStatementPrincipals(statement); + principals.forEach(principal => { + let permissionsObj = JSON.parse(JSON.stringify(getDenyActionResourceMap([statement]))); + if (permissionsMap[principal]) permissionsMap[principal] = {...permissionsObj,...permissionsMap[principal]}; + else permissionsMap[principal] = permissionsObj; + }); + } + + return permissionsMap; +} + +function getDenyActionResourceMap(statements, excludeStatementId) { + let denyActionResourceMap = {}; + for (let statement of statements) { + if (statement.Sid && statement.Sid != excludeStatementId && + statement.Effect && statement.Effect == 'Deny' && + statement.Resource && statement.Resource.length && + statement.Action && statement.Action.length) { + statement.Action.forEach(action => { + if (denyActionResourceMap[action]) denyActionResourceMap[action].push.apply(denyActionResourceMap[action], statement.Resource); + else denyActionResourceMap[action] = statement.Resource; + }); + } + } + + return denyActionResourceMap; +} + +function filterDenyPermissionsByPrincipal(permissionsMap, principal) { + let response = {}; + Object.keys(permissionsMap).forEach(key => { + if (matchKeys(key, principal)) { + Object.keys(permissionsMap[key]).forEach(action => { + if (response[action]) response[action].push.apply(response[action], permissionsMap[key][action]); + else response[action] = permissionsMap[key][action]; + }); + } + }); + return response; +} + +function isValidCondition(statement, allowedConditionKeys, iamConditionOperators, fetchConditionPrincipals, accountId, settings={}) { + if (statement.Condition && statement.Effect) { + var effect = statement.Effect; + var values = []; + var foundValid = false; + + for (var operator of Object.keys(statement.Condition)) { + var defaultOperator = operator; + if (operator.includes(':')) defaultOperator = operator.split(':')[1]; + + var subCondition = statement.Condition[operator]; + for (var key of Object.keys(subCondition)) { + let keyLower = key.toLowerCase(); + if (!allowedConditionKeys.find(conditionKey => conditionKey.toLowerCase() == keyLower)) continue; + + var value = subCondition[key]; + var awsOrGov = defaultPartition(settings); + if (iamConditionOperators.string[effect].includes(defaultOperator) || + iamConditionOperators.arn[effect].includes(defaultOperator)) { + if (keyLower === 'kms:calleraccount' && typeof value === 'string' && effect === 'Allow' && value === accountId) { + foundValid = true; + values.push(value); + } else if (/^[0-9]{12}$/.test(value) || new RegExp(`^arn:${awsOrGov}:.+`).test(value) || /^o-[a-zA-Z0-9]{10,32}$/.test(value)) { + foundValid = true; + values.push(value); + } + } else if (defaultOperator === 'Bool') { + if ((effect === 'Allow' && value) || effect === 'Deny' && !value) foundValid = true; + } else if (iamConditionOperators.ipaddress[effect].includes(defaultOperator)) { + if (value !== '0.0.0.0/0' && value !== '::/0') foundValid = true; + } + } + } + + if (!foundValid) return false; + if (fetchConditionPrincipals) return values; + } + + return true; +} + +function isEffectivePolicyStatement(statement, denyActionResourceMap) { + let statementActionResourceMap = {}; + if (statement.Action && statement.Resource) { + for (let action of statement.Action) { + statementActionResourceMap[action] = statement.Resource; + } + } + + for (let action of Object.keys(statementActionResourceMap)) { + for (let key of Object.keys(denyActionResourceMap)) { + if (matchKeys(key, action)) { + var deniedResources = []; + for (let stmResource of statementActionResourceMap[action]) { + if (denyActionResourceMap[key].find(deniedResource => matchKeys(deniedResource, stmResource))) deniedResources.push(stmResource); + } + + statementActionResourceMap[action] = statementActionResourceMap[action].filter(resource => !deniedResources.includes(resource)); + } + } + + if (statementActionResourceMap[action].length) return true; + } + + return false; +} + +function isEffectiveStatement(statement, denyPermissionsMap) { + var principals = extractStatementPrincipals(statement); + + for (let principal of principals) { + let denyActionResourceMap = filterDenyPermissionsByPrincipal(denyPermissionsMap, principal); + if (isEffectivePolicyStatement(statement, denyActionResourceMap)) return true; + } + + return false; +} + +function matchKeys(first, second) { + if (!first.length && !second.length) return true; + + if (first.length > 1 && first[0] == '*' && !second.length) return false; + + if ((first.length > 1 && first[0] == '?') || (first.length && second.length && first[0] == second[0])) return matchKeys(first.slice(1), second.slice(1)); + + if (first.length && first[0] == '*') { + return matchKeys(first.slice(1), second) || matchKeys(first,second.slice(1)); + } + return false; } @@ -228,6 +532,21 @@ function defaultPartition(settings) { return 'aws'; } +function getS3BucketLocation(cache, region, bucketName) { + var getBucketLocation = helpers.addSource(cache, {}, + ['s3', 'getBucketLocation', region, bucketName]); + + if (getBucketLocation && getBucketLocation.data) { + if (getBucketLocation.data.LocationConstraint && + regions.all.includes(getBucketLocation.data.LocationConstraint)) return getBucketLocation.data.LocationConstraint; + else if (getBucketLocation.data.LocationConstraint && + !regions.all.includes(getBucketLocation.data.LocationConstraint)) return 'global'; + else return 'us-east-1'; + } + + return 'global'; +} + function remediatePlugin(config, call, params, callback) { var service = call.split(':')[0]; var callKey = call.split(':')[1]; @@ -253,6 +572,37 @@ function nullArray(object) { return object; } +let divideArray = function(array, size) { + var arrayOfArrays = []; + while (array.length > 0) { + arrayOfArrays.push(array.splice(0, size)); + } + return arrayOfArrays; +}; + +function getEncryptionLevel(kmsKey, encryptionLevels) { + if (kmsKey.Origin) { + if (kmsKey.Origin === 'AWS_KMS') { + if (kmsKey.KeyManager) { + if (kmsKey.KeyManager === 'AWS') { + return encryptionLevels.indexOf('awskms'); + } + if (kmsKey.KeyManager === 'CUSTOMER') { + return encryptionLevels.indexOf('awscmk'); + } + } + } + if (kmsKey.Origin === 'EXTERNAL') { + return encryptionLevels.indexOf('externalcmk'); + } + if (kmsKey.Origin === 'AWS_CLOUDHSM') { + return encryptionLevels.indexOf('cloudhsm'); + } + } + + return encryptionLevels.indexOf('none'); +} + function remediatePasswordPolicy(putCall, pluginName, remediation_file, passwordKey, config, cache, settings, resource, input, callback) { config.region = defaultRegion({}); var params; @@ -418,21 +768,30 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, function(rCb) { if (!settings.input || (openIpRange && (!settings.input[ipv4InputKey] || !settings.input[ipv4InputKey].length)) && (openIpv6Range && (!settings.input[ipv6InputKey] || !settings.input[ipv6InputKey].length))) return rCb(); - var newIpRange = settings.input[ipv4InputKey] ? {CidrIp: settings.input[ipv4InputKey]} : null; - var newIpv6Range = settings.input[ipv6InputKey] ? {CidrIpv6: settings.input[ipv6InputKey]} : null; - if (ipDescription && newIpRange) newIpRange.Description = ipDescription; - if (ipv6Description && newIpv6Range) newIpRange.Description = ipv6Description; - if (openIpRange && !localIpExists && settings.input[ipv4InputKey]) { - params.IpPermissions[0].IpRanges.push(newIpRange); - finalIpRanges.push(newIpRange); + var newIpCidrRange = settings.input[ipv4InputKey].split(','); + for (var newIpCidr of newIpCidrRange) { + if (isValidCidr(newIpCidr)) { + var newIpRange = {CidrIp: newIpCidr.trim()}; + if (ipDescription && newIpRange) newIpRange.Description = ipDescription; + params.IpPermissions[0].IpRanges.push(newIpRange); + finalIpRanges.push(newIpRange); + } + } } else if (!openIpRange || (openIpRange && localIpExists) || (!settings.input[ipv4InputKey] || !settings.input[ipv4InputKey].length)) { params.IpPermissions[0].IpRanges = null; } if (openIpv6Range && !localIpV6Exists && settings.input[ipv6InputKey]) { - params.IpPermissions[0].Ipv6Ranges.push(newIpv6Range); - finalIpv6Ranges.push(newIpv6Range); + var newIpv6CidrRange = settings.input[ipv6InputKey].split(','); + for (var newIpv6Cidr of newIpv6CidrRange) { + if (isValidCidr(newIpv6Cidr)) { + var newIpv6Range = {CidrIpv6: newIpv6Cidr.trim()}; + if (ipv6Description && newIpv6Range) newIpv6Range.Description = ipv6Description; + params.IpPermissions[0].Ipv6Ranges.push(newIpv6Range); + finalIpv6Ranges.push(newIpv6Range); + } + } } else if (!openIpv6Range || (openIpv6Range && localIpV6Exists) || (!settings.input[ipv6InputKey] || !settings.input[ipv6InputKey].length)) { params.IpPermissions[0].Ipv6Ranges = null; } @@ -443,10 +802,24 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, return rCb(err); } else { if (openIpv6Range && !localIpV6Exists) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '::1/128', - 'action': 'ADDED' - }); + if (settings.input && settings.input[ipv6InputKey]) { + const newIpv6CidrRange = settings.input[ipv6InputKey].split(','); + for (const cidr of newIpv6CidrRange) { + if (isValidCidr(cidr)) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': cidr.trim(), + 'action': 'ADDED' + }); + } + } + } + if (!remediation_file['remediate']['actions'][pluginName][resource]['steps'].length || + remediation_file['remediate']['actions'][pluginName][resource]['steps'].every(s => s.action === 'Already Exists')) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '::1/128', + 'action': 'ADDED' + }); + } } else if (openIpv6Range && localIpV6Exists) { remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ 'inboundRule': '::1/128', @@ -455,10 +828,24 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, } if (openIpRange && !localIpExists) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '127.0.0.1/32', - 'action': 'ADDED' - }); + if (settings.input && settings.input[ipv4InputKey]) { + const newIpCidrRange = settings.input[ipv4InputKey].split(','); + for (const cidr of newIpCidrRange) { + if (isValidCidr(cidr)) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': cidr.trim(), + 'action': 'ADDED' + }); + } + } + } + if (!remediation_file['remediate']['actions'][pluginName][resource]['steps'].length || + remediation_file['remediate']['actions'][pluginName][resource]['steps'].every(s => s.action === 'Already Exists')) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '127.0.0.1/32', + 'action': 'ADDED' + }); + } } else if (openIpRange && localIpExists){ remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ 'inboundRule': '127.0.0.1/32', @@ -530,17 +917,765 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, }); } +function getDefaultKeyId(cache, region, defaultKeyDesc) { + var source = {}; + + var listKeys = helpers.addSource(cache, source, ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data || !listKeys.data.length) { + return false; + } + + var defaultKey = listKeys.data.find(key => { + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, key.KeyId]); + + if (describeKey && describeKey.data && describeKey.data.KeyMetadata) { + var keyToAdd = describeKey.data.KeyMetadata; + + if (keyToAdd.KeyManager && keyToAdd.KeyManager === 'AWS' && keyToAdd.Description && + keyToAdd.Description.indexOf(defaultKeyDesc) === 0 && keyToAdd.Enabled && keyToAdd.KeyState && keyToAdd.KeyState === 'Enabled') { + return keyToAdd; + } + } + }); + + if (defaultKey) return defaultKey.KeyId; + + return false; +} + +function getOrganizationAccounts(listAccounts, accountId) { + let orgAccountIds = []; + if (listAccounts.data && listAccounts.data.length){ + listAccounts.data.forEach(account => { + if (account.Id && account.Id !== accountId) orgAccountIds.push(account.Id); + }); + } + + return orgAccountIds; +} + +function getUsedSecurityGroups(cache, results, region) { + let result = []; + const describeNetworkInterfaces = helpers.addSource(cache, {}, + ['ec2', 'describeNetworkInterfaces', region]); + + if (!describeNetworkInterfaces || describeNetworkInterfaces.err || !describeNetworkInterfaces.data) { + helpers.addResult(results, 3, + 'Unable to query for network interfaces: ' + helpers.addError(describeNetworkInterfaces), region); + return result['Error']; + } + + const listFunctions = helpers.addSource(cache, {}, + ['lambda', 'listFunctions', region]); + + if (!listFunctions || listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + 'Unable to list lambda functions: ' + helpers.addError(listFunctions), region); + return result['Error']; + } + + describeNetworkInterfaces.data.forEach(interface => { + if (interface.Groups) { + interface.Groups.forEach(group => { + if (!result.includes(group.GroupId)) result.push(group.GroupId); + }); + } + }); + + listFunctions.data.forEach(func => { + if (func.VpcConfig && func.VpcConfig.SecurityGroupIds) { + func.VpcConfig.SecurityGroupIds.forEach(group => { + if (!result.includes(group)) result.push(group); + }); + } + }); + + return result; +} + +function getPrivateSubnets(subnetRTMap, subnets, routeTables) { + let response = []; + let privateRouteTables = []; + + routeTables.forEach(routeTable => { + if (routeTable.RouteTableId && routeTable.Routes && + routeTable.Routes.every(route => !route.GatewayId || !route.GatewayId.startsWith('igw-'))) { + privateRouteTables.push(routeTable.RouteTableId); + } + }); + + subnets.forEach(subnet => { + if (subnet.SubnetId && subnetRTMap[subnet.SubnetId] && privateRouteTables.includes(subnetRTMap[subnet.SubnetId])) response.push(subnet.SubnetId); + }); + + return response; +} + +function getSubnetRTMap(subnets, routeTables) { + let subnetRTMap = {}; + let vpcRTMap = {}; + + routeTables.forEach(routeTable => { + if (routeTable.RouteTableId && routeTable.Associations && routeTable.Associations.length) { + routeTable.Associations.forEach(association => { + if (association.SubnetId && !subnetRTMap[association.SubnetId]) subnetRTMap[association.SubnetId] = routeTable.RouteTableId; + }); + } + if (routeTable.VpcId && routeTable.RouteTableId && routeTable.Associations && + routeTable.Associations.find(association => association.Main) && !vpcRTMap[routeTable.VpcId]) vpcRTMap[routeTable.VpcId] = routeTable.RouteTableId; + }); + + subnets.forEach(subnet => { + if (subnet.SubnetId && subnet.VpcId && + !subnetRTMap[subnet.SubnetId] && vpcRTMap[subnet.VpcId]) subnetRTMap[subnet.SubnetId] = vpcRTMap[subnet.VpcId]; + }); + + return subnetRTMap; +} + +var isRateError = function(err) { + let isError = false; + var rateError = {message: 'rate', statusCode: 429}; + if (err && err.statusCode && rateError.statusCode == err.statusCode){ + isError = true; + } else if (err && rateError && rateError.message && err.message && + err.message.toLowerCase().indexOf(rateError.message.toLowerCase()) > -1){ + isError = true; + } + + return isError; +}; + +function makeCustomCollectorCall(executor, callKey, params, retries, apiRetryAttempts=2, apiRetryCap=1000, apiRetryBackoff=500, callback) { + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying ${callKey} again in: ${retry_seconds/1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return isRateError(err); + } + }, function(cb) { + executor[callKey](params, function(err, data) { + return cb(err, data); + }); + }, function(err, result) { + callback(err, result); + }); +} + +var debugApiCalls = function(call, service, debugMode, finished) { + if (!debugMode) return; + finished ? console.log(`[INFO] ${service}:${call} returned`) : console.log(`[INFO] ${service}:${call} invoked`); +}; + +var logError = function(service, call, region, err, errorsLocal, apiCallErrorsLocal, apiCallTypeErrorsLocal, totalApiCallErrorsLocal, errorSummaryLocal, errorTypeSummaryLocal, debugMode) { + if (debugMode) console.log(`[INFO] ${service}:${call} returned error: ${err.message}`); + totalApiCallErrorsLocal++; + + if (!errorSummaryLocal[service]) errorSummaryLocal[service] = {}; + + if (!errorSummaryLocal[service][call]) errorSummaryLocal[service][call] = {}; + + if (err.code && !errorSummaryLocal[service][call][err.code]) { + apiCallErrorsLocal++; + errorSummaryLocal[service][call][err.code] = {}; + errorSummaryLocal[service][call][err.code].total = apiCallErrorsLocal; + errorSummaryLocal.total = totalApiCallErrorsLocal; + } + + if (err.code && !errorTypeSummaryLocal[err.code]) errorTypeSummaryLocal[err.code] = {}; + if (err.code && !errorTypeSummaryLocal[err.code][service]) errorTypeSummaryLocal[err.code][service] = {}; + if (err.code && !errorTypeSummaryLocal[err.code][service][call]) { + apiCallTypeErrorsLocal++; + errorTypeSummaryLocal[err.code][service][call] = {}; + errorTypeSummaryLocal[err.code][service][call].total = apiCallTypeErrorsLocal; + errorTypeSummaryLocal.total = totalApiCallErrorsLocal; + } + + if (debugMode){ + if (!errorsLocal[service]) errorsLocal[service] = {}; + if (!errorsLocal[service][call]) errorsLocal[service][call] = {}; + if (err.code && !errorsLocal[service][call][err.code]) { + errorsLocal[service][call][err.code] = {}; + errorsLocal[service][call][err.code].total = apiCallErrorsLocal; + if (err.requestId) { + errorsLocal[service][call][err.code][err.requestId] = {}; + if (err.statusCode) errorsLocal[service][call][err.code][err.requestId].statusCode = err.statusCode; + if (err.message) errorsLocal[service][call][err.code][err.requestId].message = err.message; + if (err.time) errorsLocal[service][call][err.code][err.requestId].time = err.time; + if (region) errorsLocal[service][call][err.code][err.requestId].region = region; + } + } + } +}; + +function checkConditions(startsWithBuckets, notStartsWithBuckets, endsWithBuckets, notEndsWithBuckets, bucketName) { + const startsWithCondition = startsWithBuckets.length > 0 ? startsWithBuckets.some(startsWith => bucketName.startsWith(startsWith)): false; + const notStartsWithCondition = notStartsWithBuckets.length > 0 ? !notStartsWithBuckets.some(notStartsWith => bucketName.startsWith(notStartsWith)): false; + const endsWithCondition = endsWithBuckets.length > 0 ? endsWithBuckets.some(endsWith => bucketName.endsWith(endsWith)): false; + const notEndsWithCondition = notEndsWithBuckets.length > 0 ? !notEndsWithBuckets.some(notEndsWith => bucketName.endsWith(notEndsWith)): false; + + return { + startsWithCondition, notStartsWithCondition, endsWithCondition, notEndsWithCondition + }; +} + +var collectRateError = function(err, rateError) { + let isError = false; + + if (err && err.statusCode && rateError && rateError.statusCode == err.statusCode) { + isError = true; + } else if (err && rateError && rateError.message && err.message && + err.message.toLowerCase().indexOf(rateError.message.toLowerCase()) > -1) { + isError = true; + } + + return isError; +}; +function processFieldSelectors(fieldSelectors,buckets ,startsWithBuckets,notEndsWithBuckets,endsWithBuckets, notStartsWithBuckets) { + fieldSelectors.forEach(f => { + if (f.Field === 'resources.ARN') { + if (f.Equals && f.Equals.length) { + const bucketName = f.Equals[0].split(':::')[1].split('/')[0]; + buckets.push(bucketName); + } + if (f.StartsWith && f.StartsWith.length) { + startsWithBuckets.push(...f.StartsWith); + } + if (f.EndsWith && f.EndsWith.length) { + endsWithBuckets.push(...f.EndsWith); + } + if (f.NotStartsWith && f.NotStartsWith.length) { + notStartsWithBuckets.push(...f.NotStartsWith); + } + if (f.NotEndsWith && f.NotEndsWith.length) { + notEndsWithBuckets.push(...f.NotEndsWith); + } + } + }); + return { buckets, startsWithBuckets, endsWithBuckets, notStartsWithBuckets, notEndsWithBuckets }; +} + +var checkTags = function(cache, resourceName, resourceList, region, results, settings={}) { + const allResources = helpers.addSource(cache, {}, + ['resourcegroupstaggingapi', 'getResources', region]); + + if (!allResources || allResources.err || !allResources.data) { + resourceList.map(arn => { + helpers.addResult(results, 3, + 'Unable to query all resources from group tagging api:' + helpers.addError(allResources), region, arn); + }); + return; + } + var awsOrGov = defaultPartition(settings); + const resourceARNPrefix = `arn:${awsOrGov}:${resourceName.split(' ')[0].toLowerCase()}:`; + const filteredResourceARN = []; + allResources.data.map(resource => { + if ((resource.ResourceARN.startsWith(resourceARNPrefix)) && (resource.Tags.length > 0)){ + filteredResourceARN.push(resource.ResourceARN); + } + }); + + resourceList.map(arn => { + if (filteredResourceARN.includes(arn)) { + helpers.addResult(results, 0, `${resourceName} has tags`, region, arn); + } else { + helpers.addResult(results, 2, `${resourceName} does not have any tags`, region, arn); + } + }); +}; + +function checkSecurityGroup(securityGroup, cache, region, checkENIs = true) { + let allowsAllTraffic; + for (var p in securityGroup.IpPermissions) { + var permission = securityGroup.IpPermissions[p]; + + for (var k in permission.IpRanges) { + var range = permission.IpRanges[k]; + + if (range.CidrIp === '0.0.0.0/0') { + allowsAllTraffic = true; + } + } + + for (var l in permission.Ipv6Ranges) { + var rangeV6 = permission.Ipv6Ranges[l]; + + if (rangeV6.CidrIpv6 === '::/0') { + allowsAllTraffic = true; + } + } + } + + if (allowsAllTraffic && checkENIs) { + return checkNetworkInterface(securityGroup.GroupId, securityGroup.GroupName, '', region, null, securityGroup, cache, true); + } + return allowsAllTraffic; +} + +var getAttachedELBs = function(cache, source, region, resourceId, lbField, lbAttribute) { + let elbs = []; + + // check classice ELBs + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (describeLoadBalancers && !describeLoadBalancers.err && describeLoadBalancers.data && describeLoadBalancers.data.length) { + elbs = describeLoadBalancers.data.filter(lb => lb[lbField] && lb[lbField].some(instance => instance[lbAttribute] === resourceId)); + } + + // check ALBs/NLBs + + var describeLoadBalancersv2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (describeLoadBalancersv2 && !describeLoadBalancersv2.err && describeLoadBalancersv2.data && describeLoadBalancersv2.data.length) { + describeLoadBalancersv2.data.forEach(function(lb) { + lb.targetGroups = []; + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (describeTargetGroups && !describeTargetGroups.err && describeTargetGroups.data && describeTargetGroups.data.TargetGroups && describeTargetGroups.data.TargetGroups.length) { + describeTargetGroups.data.TargetGroups.forEach(function(tg) { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + + if (describeTargetHealth && !describeTargetHealth.err && describeTargetHealth.data + && describeTargetHealth.data.TargetHealthDescriptions && describeTargetHealth.data.TargetHealthDescriptions.length) { + describeTargetHealth.data.TargetHealthDescriptions.forEach(healthDescription => { + if (healthDescription.Target && healthDescription.Target.Id && + healthDescription.Target.Id === resourceId) { + lb.targetGroups.push({targetgroupName: tg.TargetGroupName, targetGroupArn: tg.TargetGroupArn}); + } + }); + } + }); + } + + if (lb.targetGroups && lb.targetGroups.length) { + let hasListener = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, lb.DNSName]); + if (describeListeners && describeListeners.data && describeListeners.data.Listeners && describeListeners.data.Listeners.length) { + describeListeners.data.Listeners.forEach(listener => { + if (!hasListener) { + hasListener = listener.DefaultActions.some(action => + action.TargetGroupArn && lb.targetGroups.some(tg => tg.targetGroupArn === action.TargetGroupArn) + ); + } + + }); + } + if (hasListener) { + elbs.push(lb); + } + } + }); + } + + return elbs; +}; + +var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs, region, results, resource) { + var internetExposed = ''; + var isSubnetPrivate = false; + + if (resource && resource.functionArn) { + // Check Function URL exposure + if (resource.functionUrlConfig && resource.functionUrlConfig.data) { + if (resource.functionUrlConfig.data.AuthType === 'NONE') { + internetExposed += 'public function URL'; + } else if (resource.functionUrlConfig.data.AuthType === 'AWS_IAM' && + resource.functionPolicy && resource.functionPolicy.data) { + let authConfig = resource.functionPolicy.data; + if (authConfig.Policy) { + let statements = normalizePolicyDocument(authConfig.Policy); + + if (statements) { + let hasDenyAll = false; + let hasPublicAllow = false; + let hasRestrictiveConditions = false; + + for (let statement of statements) { + // Check for explicit deny statements first + if (statement.Effect === 'Deny') { + // Check if there's a deny for all principals + if ((!statement.Condition || Object.keys(statement.Condition).length === 0) && + globalPrincipal(statement.Principal)) { + hasDenyAll = true; + break; + } + + // Check for deny with IP restrictions + if (statement.Condition && + (statement.Condition['NotIpAddress'] || + statement.Condition['IpAddress'])) { + hasRestrictiveConditions = true; + } + } else if (statement.Effect === 'Allow') { + // Skip if the statement doesn't include relevant Lambda actions + if (!statement.Action || + (!Array.isArray(statement.Action) ? + !statement.Action.includes('lambda:InvokeFunctionUrl') : + !statement.Action.some(action => + action === '*' || + action === 'lambda:*' || + action === 'lambda:InvokeFunctionUrl' + ))) { + continue; + } + + // Check for * principal with no conditions + if (globalPrincipal(statement.Principal)) { + if (!statement.Condition || Object.keys(statement.Condition).length === 0) { + hasPublicAllow = true; + } else { + // Check for common restrictive conditions + const restrictiveConditions = [ + 'aws:SourceIp', + 'aws:SourceVpc', + 'aws:SourceVpce', + 'aws:PrincipalOrgID', + 'aws:PrincipalArn', + 'aws:SourceAccount' + ]; + + const hasRestriction = restrictiveConditions.some(condition => + Object.keys(statement.Condition).some(key => + key.toLowerCase().includes(condition.toLowerCase()) + ) + ); + + if (hasRestriction) { + hasRestrictiveConditions = true; + } else if (statement.Condition['StringEquals'] && + statement.Condition['StringEquals']['lambda:FunctionUrlAuthType'] === 'NONE') { + hasPublicAllow = true; + } + } + } + } + } + + // Only mark as exposed if we have a public allow and no restrictions + if (hasPublicAllow && !hasDenyAll && !hasRestrictiveConditions) { + internetExposed += internetExposed.length ? + ', function URL with global IAM access' : + 'function URL with global IAM access'; + } + } + } + } + } + + // Check API Gateway exposure + let getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (getRestApis && getRestApis.data) { + for (let api of getRestApis.data) { + if (!api.id || !api.name) continue; + + // Get stages to check if API is deployed + let getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + // Only include if API has at least one stage deployed + if (!getStages || getStages.err || !getStages.data || !getStages.data.item || !getStages.data.item.length) continue; + + // Get integrations for this API + let getIntegration = helpers.addSource(cache, source, + ['apigateway', 'getIntegration', region, api.id]); + + if (!getIntegration || getIntegration.err || !Object.keys(getIntegration).length) continue; + + for (let apiResource of Object.values(getIntegration)) { + // Check if any integration points to this Lambda function + let lambdaIntegrations = Object.values(apiResource).filter(integration => { + return integration && integration.data && (integration.data.type === 'AWS' || integration.data.type === 'AWS_PROXY') && + integration.data.uri && + integration.data.uri.includes(resource.functionArn); + }); + + if (lambdaIntegrations.length) { + internetExposed += internetExposed.length ? `, API Gateway ${api.name}` : `API Gateway ${api.name}`; + } + } + } + } + } + + // Check public endpoint access for specific resources like EKS + if (resource && resource.resourcesVpcConfig && resource.resourcesVpcConfig.endpointPublicAccess) { + return 'public endpoint access'; + } + + if (!resource.functionArn) { + // Scenario 1: check if resource is in a private subnet + let subnetRouteTableMap, privateSubnets; + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + } else if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + } else if (describeSubnets.data.length && subnets.length) { + subnetRouteTableMap = getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + if (privateSubnets && privateSubnets.length) { + isSubnetPrivate = !subnets.some(subnet => !privateSubnets.includes(subnet.id)); + } + + // if it's in a private subnet and has no ELBs attached then its not exposed + if (isSubnetPrivate && (!elbs || !elbs.length) && !resource.functionArn) { + return ''; + } + } + } + + // Scenario 2: check if security group allows all traffic + var describeSecurityGroups; + if (!isSubnetPrivate && !resource.functionArn) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + } else if (describeSecurityGroups.data.length && securityGroups && securityGroups.length) { + let instanceSGs = describeSecurityGroups.data.filter(sg => securityGroups.find(isg => isg.GroupId === sg.GroupId)); + for (var group of instanceSGs) { + let exposedSG = checkSecurityGroup(group, cache, region); + if (exposedSG) { + internetExposed += internetExposed ? `, ${exposedSG}` : exposedSG; + } + } + } + + // if security group allows all traffic we need to check NACLs + if (internetExposed.length && !resource.functionArn) { + let subnetIds = subnets.map(s => s.id); + // Scenario 3: check if Network ACLs associated with the resource allow all traffic + var describeNetworkAcls = helpers.addSource(cache, source, + ['ec2', 'describeNetworkAcls', region]); + + if (!describeNetworkAcls || describeNetworkAcls.err || !describeNetworkAcls.data) { + helpers.addResult(results, 3, + `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); + } else if (describeNetworkAcls.data.length && subnetIds) { + let naclDeny = true; + for (let subnetId of subnetIds) { + let instanceACL = describeNetworkAcls.data.find(acl => acl.Associations.find(assoc => assoc.SubnetId === subnetId)); + if (instanceACL && instanceACL.Entries && instanceACL.Entries.length) { + const allowRules = instanceACL.Entries.filter(entry => + entry.Egress === false && + entry.RuleAction === 'allow' && + (entry.CidrBlock === '0.0.0.0/0' || entry.Ipv6CidrBlock === '::/0') + ); + + const denyIPv4 = instanceACL.Entries.find(entry => + entry.Egress === false && + entry.RuleAction === 'deny' && + entry.CidrBlock === '0.0.0.0/0' + ); + + const denyIPv6 = instanceACL.Entries.find(entry => + entry.Egress === false && + entry.RuleAction === 'deny' && + entry.Ipv6CidrBlock === '::/0' + ); + + let exposed = allowRules.some(allowRule => { + return !instanceACL.Entries.some(denyRule => { + return ( + denyRule.Egress === false && + denyRule.RuleAction === 'deny' && + ( + (allowRule.CidrBlock && denyRule.CidrBlock === allowRule.CidrBlock) || + (allowRule.Ipv6CidrBlock && denyRule.Ipv6CidrBlock === allowRule.Ipv6CidrBlock) + ) && + denyRule.Protocol === allowRule.Protocol && + ( + denyRule.PortRange ? + (allowRule.PortRange && + denyRule.PortRange.From === allowRule.PortRange.From && + denyRule.PortRange.To === allowRule.PortRange.To) : true + ) && + denyRule.RuleNumber < allowRule.RuleNumber + ); + }); + }); + + // exposed - if NACL has an allow all rule + if (exposed && !resource.functionArn) { + internetExposed += `, nacl ${instanceACL.NetworkAclId}`; + } + + // not exposed - if NACL has a deny rule + if (exposed || !denyIPv4 || !denyIPv6) { + naclDeny = false; + } + } else { + naclDeny = false; + } + } + + // not exposed - if all NACLs have deny rules + if (naclDeny && !resource.functionArn) { + return ''; + } + } + } + } + + // if there are no explicit allow or deny rules, we look at ELBs + if (elbs && elbs.length) { + if (!describeSecurityGroups || !describeSecurityGroups.data) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + } + + elbs.forEach(lb => { + let isLBPublic = false; + if (lb.Scheme && lb.Scheme.toLowerCase() === 'internet-facing') { + if (lb.SecurityGroups && lb.SecurityGroups.length) { + if (describeSecurityGroups && + !describeSecurityGroups.err && describeSecurityGroups.data && describeSecurityGroups.data.length) { + let elbSGs = describeSecurityGroups.data.filter(sg => lb.SecurityGroups.includes(sg.GroupId)); + for (var elbSG of elbSGs) { + let exposedSG = checkSecurityGroup(elbSG, cache, region, false); + if (exposedSG) { + isLBPublic = true; + } + } + } + } + } + + if (isLBPublic) { + internetExposed += internetExposed.length ? `, elb ${lb.LoadBalancerName}`: `elb ${lb.LoadBalancerName}`; + } + }); + } + + return internetExposed; +}; + +let getLambdaTargetELBs = function(cache, source, region) { + let lambdaELBMap = {}; + + var describeLoadBalancersv2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancersv2 || describeLoadBalancersv2.err || !describeLoadBalancersv2.data) { + return lambdaELBMap; + } + + describeLoadBalancersv2.data.forEach(lb => { + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (!describeTargetGroups || describeTargetGroups.err || !describeTargetGroups.data || + !describeTargetGroups.data.TargetGroups) return; + + describeTargetGroups.data.TargetGroups.forEach(tg => { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + + if (!describeTargetHealth || describeTargetHealth.err || !describeTargetHealth.data || + !describeTargetHealth.data.TargetHealthDescriptions) return; + + describeTargetHealth.data.TargetHealthDescriptions.forEach(target => { + if (target.Target && target.Target.Id && + target.Target.Id.startsWith('arn:aws:lambda')) { + if (!lambdaELBMap[target.Target.Id]) { + lambdaELBMap[target.Target.Id] = []; + } + lb.targetGroups = lb.targetGroups || []; + lb.targetGroups.push({ + targetGroupName: tg.TargetGroupName, + targetGroupArn: tg.TargetGroupArn, + targets: [target.Target] + }); + + // Check if there's an active listener for this target group + let hasListener = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, lb.DNSName]); + + if (describeListeners && describeListeners.data && + describeListeners.data.Listeners) { + hasListener = describeListeners.data.Listeners.some(listener => + listener.DefaultActions.some(action => + action.TargetGroupArn === tg.TargetGroupArn + ) + ); + } + + if (hasListener) { + lambdaELBMap[target.Target.Id].push(lb); + } + } + }); + }); + }); + + return lambdaELBMap; +}; + module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, waitForCredentialReport: waitForCredentialReport, normalizePolicyDocument: normalizePolicyDocument, globalPrincipal: globalPrincipal, + userGlobalAccess: userGlobalAccess, crossAccountPrincipal: crossAccountPrincipal, defaultRegion: defaultRegion, defaultPartition: defaultPartition, remediatePlugin: remediatePlugin, nullArray: nullArray, + divideArray:divideArray, remediatePasswordPolicy:remediatePasswordPolicy, - remediateOpenPorts: remediateOpenPorts + remediateOpenPorts: remediateOpenPorts, + hasFederatedUserRole: hasFederatedUserRole, + getEncryptionLevel: getEncryptionLevel, + extractStatementPrincipals: extractStatementPrincipals, + getDefaultKeyId: getDefaultKeyId, + isValidCondition: isValidCondition, + isEffectiveStatement: isEffectiveStatement, + getDenyActionResourceMap: getDenyActionResourceMap, + getDenyPermissionsMap: getDenyPermissionsMap, + isEffectivePolicyStatement: isEffectivePolicyStatement, + getS3BucketLocation: getS3BucketLocation, + getOrganizationAccounts: getOrganizationAccounts, + getUsedSecurityGroups: getUsedSecurityGroups, + getPrivateSubnets: getPrivateSubnets, + getSubnetRTMap: getSubnetRTMap, + makeCustomCollectorCall: makeCustomCollectorCall, + debugApiCalls: debugApiCalls, + logError: logError, + collectRateError: collectRateError, + checkTags: checkTags, + checkConditions: checkConditions, + processFieldSelectors: processFieldSelectors, + checkNetworkInterface: checkNetworkInterface, + checkNetworkExposure: checkNetworkExposure, + getAttachedELBs: getAttachedELBs, + getLambdaTargetELBs }; + diff --git a/helpers/aws/index.js b/helpers/aws/index.js index 58c1b2f55..c81f51bdc 100644 --- a/helpers/aws/index.js +++ b/helpers/aws/index.js @@ -1,10 +1,16 @@ var shared = require(__dirname + '/../shared.js'); var functions = require('./functions.js'); +var api = require('./api.js'); +var api_multipart = require('./api_multipart.js'); var regRegions = require('./regions.js'); var govRegions = require('./regions_gov.js'); +var govRegionsFedRampEast1 = require('./regions_gov_fedramp_east_1.js'); +var govRegionsFedRampWest1 = require('./regions_gov_fedramp_west_1.js'); var chinaRegions = require('./regions_china.js'); var regions = function(settings) { + if (settings.govcloud && settings.is_fedramp_type_high && settings.LAMBDA_REGION == 'us-gov-east-1') return govRegionsFedRampEast1; + if (settings.govcloud && settings.is_fedramp_type_high && settings.LAMBDA_REGION == 'us-gov-west-1') return govRegionsFedRampWest1; if (settings.govcloud) return govRegions; if (settings.china) return chinaRegions; return regRegions; @@ -13,10 +19,28 @@ var regions = function(settings) { var helpers = { regions: regions, MAX_REGIONS_AT_A_TIME: 6, - CLOUDSPLOIT_EVENTS_BUCKET: 'cloudsploit-engine-trails' + CLOUDEXPLOIT_EVENTS_BUCKET: 'cloudexploit-engine-trails', + CLOUDEXPLOIT_EVENTS_SNS: 'khulnasoft-cspm-sns-', + ENCRYPTION_LEVELS: ['none', 'sse', 'awskms', 'awscmk', 'externalcmk', 'cloudhsm'], + IAM_CONDITION_OPERATORS: { + string: { + Allow: ['StringEquals', 'StringEqualsIgnoreCase', 'StringLike'], + Deny: ['StringNotEquals', 'StringNotEqualsIgnoreCase', 'StringNotLike'] + }, + arn: { + Allow: ['ArnEquals', 'ArnLike'], + Deny: ['ArnNotEquals', 'ArnNotLike'] + }, + ipaddress: { + Allow: 'IpAddress', + Deny: 'NotIpAddress' + } + }, }; for (var s in shared) helpers[s] = shared[s]; for (var f in functions) helpers[f] = functions[f]; +for (var a in api) helpers[a] = api[a]; +for (var am in api_multipart) helpers[am] = api_multipart[am]; -module.exports = helpers; +module.exports = helpers; \ No newline at end of file diff --git a/helpers/aws/regions.js b/helpers/aws/regions.js index 3749d0093..a7bf75855 100644 --- a/helpers/aws/regions.js +++ b/helpers/aws/regions.js @@ -5,105 +5,212 @@ var regions = [ 'us-east-2', // Ohio 'us-west-1', // Northern California 'us-west-2', // Oregon + 'af-south-1', // Africa (Cape Town) + 'ap-east-1', // Asia Pacific (Hong Kong) + 'ap-south-1', // Asia Pacific (Mumbai) + 'ap-northeast-3', // Asia Pacific (Osaka) 'ca-central-1', // Canada (Montreal) 'eu-central-1', // EU (Frankfurt) 'eu-west-1', // EU (Ireland) 'eu-west-2', // London 'eu-west-3', // Paris 'eu-north-1', // Stockholm + 'eu-south-1', // EU (Milan) 'ap-northeast-1', // Asia Pacific (Tokyo) 'ap-northeast-2', // Asia Pacific (Seoul) 'ap-southeast-1', // Asia Pacific (Singapore) 'ap-southeast-2', // Asia Pacific (Sydney) - 'ap-south-1', // Asia Pacific (Mumbai) 'sa-east-1', // South America (São Paulo) - 'ap-east-1', // Asia Pacific (Hong Kong) - 'me-south-1' // Middle East (Bahrain) + 'me-south-1', // Middle East (Bahrain), + 'me-central-1', // Middle East (UAE), + 'ap-southeast-3', // Asia Pacific (Jakarta) +]; + +var newRegionsUpdate =[ + 'ap-south-2', // Asia Pacific (Hyderabad) + 'ap-southeast-4', // Asia Pacific (Melbourne) + 'eu-south-2', // Europe (Spain) + 'eu-central-2', // Europe (Zurich) + 'il-central-1', //Israel (Tel Aviv) + 'ca-west-1', //Canada West (Calgary) ]; module.exports = { default: ['us-east-1'], - all: regions, - optin: ['ap-east-1', 'me-south-1'], // Regions that AWS disables by default - acm: regions, - athena: ['us-east-1', 'us-east-2', 'us-west-2', 'ca-central-1', - 'eu-west-1', 'eu-central-1', 'eu-west-2', 'ap-southeast-1', - 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2', 'ap-south-1'], + all: [...regions, ...newRegionsUpdate], + optin: ['ap-east-1', 'me-south-1', 'ap-southeast-3'], // Regions that AWS disables by default + accessanalyzer: [...regions, ...newRegionsUpdate], + acm: [...regions, ...newRegionsUpdate], + apigateway: [...regions, ...newRegionsUpdate], + apigatewayv2: [...regions, ...newRegionsUpdate], + athena:[...regions, ...newRegionsUpdate], + bedrock: ['us-east-1', 'us-west-2', 'ap-south-1','ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'eu-central-1', 'eu-west-1', 'eu-west-3'], cloudfront: ['us-east-1'], // CloudFront uses the default global region - autoscaling: regions, + autoscaling: [...regions, 'ap-south-2', 'ap-southeast-4', 'eu-south-2', 'eu-central-2', 'il-central-1'], iam: ['us-east-1'], route53: ['us-east-1'], route53domains: ['us-east-1'], s3: ['us-east-1'], - cloudformation: regions, - cloudtrail: regions, - cloudwatchlogs: regions, - configservice: regions, - dynamodb: regions, - dms: regions, - ec2: regions, - ecr: regions, - eks: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1', 'eu-central-1', - 'eu-west-2', 'eu-west-3', 'eu-north-1', 'ap-southeast-1', 'ap-northeast-1', - 'ap-southeast-2', 'ap-northeast-2', 'ap-south-1'], + s3control: ['us-east-1'], + cognitoidentityserviceprovider: ['us-east-1','us-east-2','us-west-1','us-west-2','af-south-1','ap-southeast-3', + 'ap-south-1', 'ap-south-2', 'ap-northeast-3','ap-northeast-2','ap-southeast-1','ap-southeast-2', 'ap-southeast-4', 'ap-northeast-1','ca-central-1','eu-central-1','eu-central-2','eu-west-1','eu-west-2','eu-south-1','eu-south-2', + 'eu-west-3','eu-north-1','il-central-1','me-south-1','me-central-1','sa-east-1'], + cloudformation: [...regions, ...newRegionsUpdate], + cloudtrail: [...regions, ...newRegionsUpdate], + cloudwatchlogs: [...regions,...newRegionsUpdate], + comprehend: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-central-1', + 'eu-west-2', 'eu-west-1', 'ap-southeast-1', 'ap-northeast-1', + 'ap-southeast-2', 'ap-northeast-2', 'ap-south-1', 'ca-central-1'], + configservice: [...regions, ...newRegionsUpdate], + dax: ['us-east-1'], // available Globally + devopsguru: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2','eu-west-1', 'eu-west-2', 'eu-west-3','ap-northeast-1', + 'ap-southeast-1', 'ap-southeast-2', 'eu-north-1', 'ap-south-1', 'ap-northeast-2', 'ca-central-1','eu-central-1', + 'sa-east-1'], + dynamodb: [...regions, ...newRegionsUpdate], + docdb: ['us-east-1', 'us-east-2', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', + 'eu-west-2', 'eu-west-3', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', + 'ap-southeast-2', 'ap-south-1', 'sa-east-1', 'eu-south-1', 'ap-east-1', 'ap-south-2', 'me-central-1'], + dlm: [...regions, ...newRegionsUpdate], + dms: [...regions, ...newRegionsUpdate], + ec2: [...regions, ...newRegionsUpdate], + ecr: [...regions, ...newRegionsUpdate], + eks: [...regions, ...newRegionsUpdate], + elasticbeanstalk: [...regions, 'il-central-1'], elastictranscoder: ['us-east-1', 'us-west-2', 'us-west-1', 'eu-west-1', 'ap-southeast-1', 'ap-northeast-1', 'ap-southeast-2', 'ap-south-1'], - elb: regions, - elbv2: regions, - es: regions, - kinesis: regions, - firehose: ['us-east-1', 'us-east-2', 'us-west-2', 'us-west-1', - 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', - 'ap-northeast-1','ap-northeast-2', - 'ap-southeast-1','ap-southeast-2','ap-south-1','sa-east-1'], - kms: regions, - vpc: regions, - flowlogs: regions, - rds: regions, - redshift: regions, - apigateway: regions, - cloudwatch: regions, - ecs: regions, - sagemaker: [ - 'us-east-1', 'us-east-2', 'us-west-2', 'ap-northeast-1', 'ap-northeast-2', - 'ap-southeast-2', 'eu-central-1', 'eu-central-1', 'eu-west-1'], - ses: [ - 'us-east-1', 'us-west-2', - 'eu-west-1'], - sns: regions, - sqs: regions, - ssm: regions, + elb: [...regions, ...newRegionsUpdate], + elbv2: [...regions, ...newRegionsUpdate], + eventbridge: [...regions, ...newRegionsUpdate], + emr: [...regions, ...newRegionsUpdate], + es: [...regions, ...newRegionsUpdate], + glue: [...regions, ...newRegionsUpdate], + kinesis: [...regions, ...newRegionsUpdate], + kinesisvideo: ['us-east-1', 'us-east-2', 'us-west-2', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-west-3', 'ap-northeast-1','ap-northeast-2', 'ap-southeast-1', + 'ap-southeast-2', 'ap-south-1', 'ap-east-1','sa-east-1', 'af-south-1'], + firehose: [...regions, ...newRegionsUpdate], + kms: [...regions, ...newRegionsUpdate], + vpc: [...regions, ...newRegionsUpdate], + flowlogs: [...regions, ...newRegionsUpdate], + rds: [...regions, ...newRegionsUpdate], + redshift: [...regions, ...newRegionsUpdate], + cloudwatch: [...regions, ...newRegionsUpdate], + ecs: [...regions, ...newRegionsUpdate], + resourcegroupstaggingapi: [...regions, ...newRegionsUpdate], + sagemaker: [...regions, ...newRegionsUpdate], + secretsmanager: [...regions, ...newRegionsUpdate], + ses: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-south-1', 'ap-northeast-1', + 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-3', 'ap-south-1', + 'sa-east-1', 'me-south-1', 'af-south-1', 'ap-southeast-3', 'il-central-1'], + sns: [...regions, ...newRegionsUpdate], + securityhub: [...regions, ...newRegionsUpdate], + sqs: [...regions, ...newRegionsUpdate], + ssm: [...regions, ...newRegionsUpdate], shield: ['us-east-1'], - // SSE via KMS is only supported in some regions - // even though SQS is supported in all regions. - sqs_encrypted: ['us-east-1', 'us-east-2', 'us-west-2', 'us-west-1', - 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', - 'ap-northeast-1','ap-northeast-2', - 'ap-southeast-1','ap-southeast-2','ap-south-1','sa-east-1'], + sqs_encrypted: [...regions, ...newRegionsUpdate], sts: ['us-east-1'], - transfer: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', - 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', - 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3'], - lambda: regions, - directconnect: regions, - directoryservice: ['us-east-1', 'us-east-2', 'us-west-2', 'us-west-1', 'ca-central-1', - 'sa-east-1', 'eu-west-1', 'eu-central-1', 'eu-west-2', - 'ap-southeast-1', 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2', - 'ap-south-1'], - efs: [ 'eu-west-2', 'eu-west-1', 'ap-northeast-2', 'ap-northeast-1', 'ap-southeast-1', - 'ap-southeast-2', 'eu-central-1', 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2'], + transfer: [...regions, ...newRegionsUpdate], + lambda: [...regions, ...newRegionsUpdate], + mwaa: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'ap-south-1', 'eu-north-1', 'eu-central-1', + 'ap-southeast-2', 'ap-southeast-1', 'ap-northeast-2', 'ap-northeast-1', 'ca-central-1', 'sa-east-1'], + directconnect: ['us-east-1'], // this is global service + directoryservice: [...regions, ...newRegionsUpdate], + efs: [...regions, ...newRegionsUpdate], support: ['us-east-1'], - wafregional: ['us-east-1'], + wafregional: regions, + wafv2: [...regions, ...newRegionsUpdate], + waf: ['us-east-1'], organizations: ['us-east-1'], - guardduty: regions, - workspaces: ['us-east-1', 'us-west-2', 'ca-central-1', 'sa-east-1', + guardduty: [...regions, ...newRegionsUpdate], + workspaces: ['us-east-1', 'us-west-2', 'ca-central-1', 'sa-east-1', 'ap-south-1', 'eu-west-1', 'eu-central-1', 'eu-west-2', 'ap-southeast-1', - 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2'], - servicequotas: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', - 'ap-south-1', 'ap-southeast-1', 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2', - 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1', 'sa-east-1'], - xray: ['us-east-1', 'us-east-2', 'us-west-2', 'us-west-1', - 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', - 'ap-northeast-1','ap-northeast-2', - 'ap-southeast-1','ap-southeast-2','ap-south-1','sa-east-1', 'ap-east-1'] + 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2', 'af-south-1', 'il-central-1'], + servicequotas: [...regions, ...newRegionsUpdate], + xray: [...regions, ...newRegionsUpdate], + codestar: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ap-northeast-2', + 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-north-1'], + codebuild: [...regions, 'ap-south-2', 'ap-southeast-4', 'eu-south-2', 'eu-central-2', 'il-central-1'], + mq: [...regions, 'ap-south-2', 'ap-southeast-4', 'eu-south-2', 'eu-central-2', 'il-central-1'], + glacier: regions, + backup: [...regions, ...newRegionsUpdate], + elasticache: [...regions, ...newRegionsUpdate], + timestreamwrite: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-central-1', 'eu-west-1', 'eu-north-1', 'ap-south-1', 'ap-southeast-1', 'ap-southeast-2', + 'ap-northeast-1'], + neptune: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', 'ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3', + 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'sa-east-1', 'ap-east-1', 'me-south-1', 'af-south-1', 'il-central-1','me-central-1' + ], + memorydb: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-north-1', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', + 'ap-southeast-2', 'ap-south-1', 'sa-east-1', 'ap-east-1', 'eu-west-3', 'eu-south-1'], + + kafka: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'ca-west-1', 'eu-central-1', 'eu-west-1', + 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-south-1', 'eu-south-2', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', + 'ap-southeast-2', 'ap-south-1', 'sa-east-1', 'ap-east-1', 'me-south-1', 'af-south-1', 'ap-south-2','ap-southeast-3', 'ap-southeast-4', 'ap-northeast-3', + 'eu-central-2', 'me-central-1', 'il-central-1'], + kendra: ['us-east-1', 'us-east-2', 'us-west-2', 'ap-southeast-1', 'ap-southeast-2', 'ca-central-1', 'eu-west-1', 'ap-northeast-1', 'ap-south-1', 'eu-west-2'], + proton: ['us-east-1', 'us-east-2', 'us-west-2', 'ap-northeast-1', 'eu-west-1', 'eu-west-2', 'eu-central-1', + 'ca-central-1', 'ap-southeast-2', 'ap-southeast-1', 'ap-northeast-2'], + customerprofiles: ['us-east-1', 'us-west-2', 'eu-west-2', 'ca-central-1', 'eu-central-1', + 'ap-southeast-1', 'ap-northeast-1', 'ap-southeast-2', 'ap-northeast-2'], + qldb: ['us-east-1', 'us-east-2', 'us-west-2', 'ap-northeast-2', + 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'ca-central-1', 'eu-central-1', + 'eu-west-1', 'eu-west-2'], + finspace: ['us-east-1', 'us-east-2', 'us-west-2', 'ca-central-1', 'eu-west-1'], + codepipeline: [...regions, 'ap-south-2', 'ap-southeast-4', 'eu-south-2', 'eu-central-2', 'il-central-1'], + codeartifact: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-central-1', + 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-south-1', + 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1'], + auditmanager: [ + 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', + 'eu-west-2', 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1' + ], + appflow: [ + 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', + 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1','eu-west-3', 'sa-east-1', 'ap-northeast-2', 'af-south-1' + ], + translate: [ + 'us-east-1', 'us-east-2', 'us-west-1','us-west-2', 'eu-west-1', 'eu-west-2', 'ap-northeast-2', + 'ap-east-1','af-south-1', 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'ca-central-1', 'eu-west-3', 'eu-central-1', + 'eu-north-1' + ], + databrew: ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', + 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-south-1', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', + 'ap-southeast-2', 'ap-south-1', 'sa-east-1', 'ap-east-1', 'af-south-1' + ], + managedblockchain: ['us-east-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-northeast-1', 'eu-west-1', 'eu-west-2'], + connect: ['us-east-1', 'us-west-2', 'eu-west-2', 'ca-central-1', 'eu-central-1', + 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'ap-northeast-2', 'af-south-1'], + apprunner: ['us-east-1','us-east-2','us-west-2', 'eu-west-1','ap-northeast-1', 'eu-central-1', 'ap-southeast-2', 'ap-south-1', + 'ap-southeast-1', 'eu-west-2', 'eu-west-3'], + healthlake: ['us-east-1', 'us-east-2', 'us-west-2', 'ap-south-1'], + lookoutequipment: ['us-east-1', 'eu-west-1', 'ap-northeast-2'], + iotsitewise: ['us-east-1', 'us-west-2', 'ap-south-1', 'ap-southeast-1', 'ap-northeast-2', 'ap-southeast-2', + 'ap-northeast-1', 'eu-central-1', 'eu-west-1', 'ca-central-1', 'us-east-2'], + location: [ + 'us-east-1', 'us-east-2', 'us-west-2', 'eu-central-1', 'eu-west-1', 'eu-north-1', + 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'sa-east-1', 'eu-west-2', 'ca-central-1', 'ap-south-1' + ], + lookoutvision: ['us-east-1', 'us-east-2', 'ap-northeast-1', 'ap-northeast-2', 'eu-central-1', 'eu-west-1', 'us-west-2'], + lookoutmetrics: ['us-east-1', 'us-east-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'eu-central-1', + 'eu-west-1', 'eu-north-1', 'us-west-2'], + forecastservice: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-central-1', 'eu-west-1', + 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1'], + lexmodelsv2: [ 'us-east-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', + 'eu-west-2', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'af-south-1'], + fsx: [...regions, ...newRegionsUpdate], + wisdom: ['us-east-1', 'us-west-2', 'eu-west-2', 'eu-central-1', 'ap-northeast-1', 'ap-southeast-2'], + voiceid: ['us-east-1', 'us-west-2', 'eu-west-2', 'ca-central-1', 'eu-central-1', + 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'ap-northeast-2', 'af-south-1'], + appmesh: [...regions, 'il-central-1'], + frauddetector: ['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1', 'ap-southeast-1', 'ap-southeast-2'], + imagebuilder: [...regions, ...newRegionsUpdate], + computeoptimizer: ['us-east-1'], + appconfig: [...regions, ...newRegionsUpdate], + opensearch: [...regions, ...newRegionsUpdate], + opensearchserverless: ['us-east-2', 'us-east-1', 'us-west-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', + 'eu-central-1', 'eu-west-1'] }; + diff --git a/helpers/aws/regions_china.js b/helpers/aws/regions_china.js index 3d82dca40..5a7e38a71 100644 --- a/helpers/aws/regions_china.js +++ b/helpers/aws/regions_china.js @@ -8,39 +8,58 @@ var regions = [ module.exports = { default: ['cn-north-1'], all: regions, + optin: [], + accessanalyzer: [], + cognitoidentityserviceprovider: regions, acm: [], + apigateway: regions, + apigatewayv2: regions, athena: [], + bedrock:[], cloudfront: [], + efs: [], autoscaling: regions, iam: ['cn-north-1'], route53: [], route53domains: [], s3: regions, + s3control: regions, cloudtrail: regions, cloudwatchlogs: regions, cloudformation: regions, + comprehend: [], configservice: regions, + dlm: regions, dms: regions, + dax: ['cn-northwest-1'], + devopsguru: regions, dynamodb: regions, ec2: regions, ecr: regions, eks: [], + elasticbeanstalk: regions, elastictranscoder: [], elb: regions, elbv2: regions, + emr: regions, es: regions, + eventbridge: regions, kinesis: [], + kinesisvideo: regions, firehose: regions, + glue: regions, kms: regions, vpc: regions, flowlogs: regions, rds: regions, redshift: regions, - apigateway: regions, cloudwatch: regions, ecs: regions, sagemaker: [], + secretsmanager: regions, ses: [], + servicequotas: [], + shield: [], sns: regions, sqs: regions, ssm: regions, @@ -48,12 +67,62 @@ module.exports = { // even though SQS is supported in all regions. sqs_encrypted: regions, sts: regions, + support: [], transfer: [], lambda: regions, + mwaa: regions, directconnect: regions, directoryservice: [], organizations: ['cn-north-1'], guardduty: [], + wafregional: [], + wafv2: [], + waf: regions, workspaces: ['cn-northwest-1'], - xray: regions + xray: regions, + resourcegroupstaggingapi: regions, + codestar: regions, + codebuild: regions, + mq: regions, + glacier: regions, + backup: regions, + elasticache: regions, + timestreamwrite: regions, + neptune: regions, + docdb: regions, + memorydb: regions, + kafka: regions, + kendra: regions, + proton: regions, + customerprofiles: regions, + qldb: regions, + apprunner: regions, + finspace: regions, + codepipeline: regions, + codeartifact: regions, + auditmanager: regions, + appflow: regions, + translate: regions, + databrew: regions, + managedblockchain: regions, + healthlake: regions, + connect: regions, + lookoutequipment: regions, + iotsitewise: regions, + location: regions, + lookoutvision: regions, + lookoutmetrics: regions, + forecastservice: regions, + lexmodelsv2: regions, + fsx: regions, + wisdom: regions, + voiceid: regions, + appmesh: regions, + frauddetector: regions, + imagebuilder: regions, + computeoptimizer: regions, + appconfig: regions, + opensearch:regions, + opensearchserverless: regions, + securityhub: regions }; diff --git a/helpers/aws/regions_gov.js b/helpers/aws/regions_gov.js index c55424fce..df2b45461 100644 --- a/helpers/aws/regions_gov.js +++ b/helpers/aws/regions_gov.js @@ -8,39 +8,59 @@ var regions = [ module.exports = { default: ['us-gov-west-1'], all: regions, + optin: [], + accessanalyzer: regions, acm: regions, - athena: ['us-gov-west-1'], + apigateway: regions, + apigatewayv2: regions, + athena: regions, + backup: regions, + bedrock: ['us-gov-west-1'], cloudfront: [], autoscaling: regions, iam: regions, route53: [], route53domains: [], s3: regions, + s3control: regions, cloudtrail: regions, cloudwatchlogs: regions, cloudformation: regions, + comprehend: ['us-gov-west-1'], + cognitoidentityserviceprovider: regions, configservice: regions, + dlm: regions, dms: regions, + dax: [], + devopsguru: regions, dynamodb: regions, ec2: regions, ecr: regions, eks: [], + efs: regions, + elasticbeanstalk: regions, elastictranscoder: [], elb: regions, elbv2: regions, + emr: regions, es: regions, + eventbridge: regions, kinesis: [], + kinesisvideo: regions, firehose: [], + glue: regions, kms: regions, vpc: regions, flowlogs: regions, rds: regions, redshift: regions, - apigateway: regions, cloudwatch: regions, ecs: regions, sagemaker: [], + secretsmanager: regions, ses: [], + servicequotas: [], + shield: [], sns: regions, sqs: regions, ssm: regions, @@ -48,12 +68,61 @@ module.exports = { // even though SQS is supported in all regions. sqs_encrypted: regions, sts: regions, + support: [], transfer: [], lambda: regions, + elasticache: regions, + mwaa: regions, directconnect: regions, directoryservice: [], organizations: regions, - guardduty: ['us-gov-west-1'], - workspaces: ['us-gov-west-1'], - xray: [] + guardduty: regions, + wafregional: [], + wafv2: regions, + waf: regions, + workspaces: regions, + xray: [], + resourcegroupstaggingapi: regions, + codestar: regions, + codebuild: regions, + mq: regions, + glacier: regions, + timestreamwrite: regions, + neptune: regions, + docdb: regions, + memorydb: regions, + kafka: regions, + kendra: regions, + proton: regions, + customerprofiles: regions, + qldb: regions, + finspace: regions, + codepipeline: regions, + codeartifact: regions, + auditmanager: regions, + appflow: regions, + translate: regions, + databrew: regions, + managedblockchain: regions, + connect: regions, + apprunner: regions, + healthlake: regions, + lookoutequipment: regions, + iotsitewise: regions, + location: regions, + lookoutvision: regions, + lookoutmetrics: regions, + forecastservice: regions, + lexmodelsv2: regions, + fsx: regions, + wisdom: regions, + voiceid: regions, + appmesh: regions, + frauddetector: regions, + imagebuilder: regions, + computeoptimizer: regions, + appconfig: regions, + opensearch:regions, + opensearchserverless: regions, + securityhub: regions }; diff --git a/helpers/aws/regions_gov_fedramp_east_1.js b/helpers/aws/regions_gov_fedramp_east_1.js new file mode 100644 index 000000000..84ca9e067 --- /dev/null +++ b/helpers/aws/regions_gov_fedramp_east_1.js @@ -0,0 +1,123 @@ +var regions = [ + 'us-gov-east-1' +]; + +module.exports = { + default: ['us-gov-west-1'], + all: regions, + optin: [], + accessanalyzer: regions, + acm: regions, + apigateway: regions, + athena: regions, + backup: regions, + bedrock: [], + cloudfront: [], + autoscaling: regions, + iam: regions, + route53: [], + route53domains: [], + s3: regions, + s3control: regions, + cloudtrail: regions, + cloudwatchlogs: regions, + cloudformation: regions, + comprehend: [], + cognitoidentityserviceprovider: regions, + configservice: regions, + dlm: regions, + dms: regions, + dax: [], + devopsguru: regions, + dynamodb: regions, + ec2: regions, + ecr: regions, + eks: [], + efs: regions, + elasticbeanstalk: regions, + elastictranscoder: [], + elb: regions, + elbv2: regions, + emr: regions, + es: regions, + eventbridge: regions, + kinesis: [], + kinesisvideo: regions, + firehose: [], + glue: regions, + kms: regions, + vpc: regions, + flowlogs: regions, + rds: regions, + redshift: regions, + cloudwatch: regions, + ecs: regions, + sagemaker: [], + secretsmanager: regions, + ses: [], + servicequotas: [], + shield: [], + sns: regions, + sqs: regions, + ssm: regions, + // SSE via KMS is only supported in some regions + // even though SQS is supported in all regions. + sqs_encrypted: regions, + sts: regions, + support: [], + transfer: [], + lambda: regions, + elasticache: regions, + mwaa: regions, + directconnect: regions, + directoryservice: [], + organizations: regions, + guardduty: regions, + wafregional: [], + wafv2: regions, + waf: regions, + workspaces: regions, + xray: [], + resourcegroupstaggingapi: regions, + codestar: regions, + codebuild: regions, + mq: regions, + glacier: regions, + timestreamwrite: regions, + neptune: regions, + docdb: regions, + memorydb: regions, + kafka: regions, + kendra: regions, + proton: regions, + customerprofiles: regions, + qldb: regions, + finspace: regions, + codepipeline: regions, + codeartifact: regions, + auditmanager: regions, + appflow: regions, + translate: regions, + databrew: regions, + managedblockchain: regions, + connect: regions, + apprunner: regions, + healthlake: regions, + lookoutequipment: regions, + iotsitewise: regions, + location: regions, + lookoutvision: regions, + lookoutmetrics: regions, + forecastservice: regions, + lexmodelsv2: regions, + fsx: regions, + wisdom: regions, + voiceid: regions, + appmesh: regions, + frauddetector: regions, + imagebuilder: regions, + computeoptimizer: regions, + appconfig: regions, + opensearch:regions, + opensearchserverless: regions +}; diff --git a/helpers/aws/regions_gov_fedramp_west_1.js b/helpers/aws/regions_gov_fedramp_west_1.js new file mode 100644 index 000000000..b4793ef18 --- /dev/null +++ b/helpers/aws/regions_gov_fedramp_west_1.js @@ -0,0 +1,123 @@ +var regions = [ + 'us-gov-west-1' +]; + +module.exports = { + default: ['us-gov-west-1'], + all: regions, + optin: [], + accessanalyzer: regions, + acm: regions, + apigateway: regions, + athena: regions, + backup: regions, + bedrock: [], + cloudfront: [], + autoscaling: regions, + iam: regions, + route53: [], + route53domains: [], + s3: regions, + s3control: regions, + cloudtrail: regions, + cloudwatchlogs: regions, + cloudformation: regions, + comprehend: regions, + cognitoidentityserviceprovider: regions, + configservice: regions, + dlm: regions, + dms: regions, + dax: [], + devopsguru: regions, + dynamodb: regions, + ec2: regions, + ecr: regions, + eks: [], + efs: regions, + elasticbeanstalk: regions, + elastictranscoder: [], + elb: regions, + elbv2: regions, + emr: regions, + es: regions, + eventbridge: regions, + kinesis: [], + kinesisvideo: regions, + firehose: [], + glue: regions, + kms: regions, + vpc: regions, + flowlogs: regions, + rds: regions, + redshift: regions, + cloudwatch: regions, + ecs: regions, + sagemaker: [], + secretsmanager: regions, + ses: [], + servicequotas: [], + shield: [], + sns: regions, + sqs: regions, + ssm: regions, + // SSE via KMS is only supported in some regions + // even though SQS is supported in all regions. + sqs_encrypted: regions, + sts: regions, + support: [], + transfer: [], + lambda: regions, + elasticache: regions, + mwaa: regions, + directconnect: regions, + directoryservice: [], + organizations: regions, + guardduty: regions, + wafregional: [], + wafv2: regions, + waf: regions, + workspaces: regions, + xray: [], + resourcegroupstaggingapi: regions, + codestar: regions, + codebuild: regions, + mq: regions, + glacier: regions, + timestreamwrite: regions, + neptune: regions, + docdb: regions, + memorydb: regions, + kafka: regions, + kendra: regions, + proton: regions, + customerprofiles: regions, + qldb: regions, + finspace: regions, + codepipeline: regions, + codeartifact: regions, + auditmanager: regions, + appflow: regions, + translate: regions, + databrew: regions, + managedblockchain: regions, + connect: regions, + apprunner: regions, + healthlake: regions, + lookoutequipment: regions, + iotsitewise: regions, + location: regions, + lookoutvision: regions, + lookoutmetrics: regions, + forecastservice: regions, + lexmodelsv2: regions, + fsx: regions, + wisdom: regions, + voiceid: regions, + appmesh: regions, + frauddetector: regions, + imagebuilder: regions, + computeoptimizer: regions, + appconfig: regions, + opensearch:regions, + opensearchserverless: regions +}; diff --git a/helpers/aws/resources.js b/helpers/aws/resources.js new file mode 100644 index 000000000..e853f19ae --- /dev/null +++ b/helpers/aws/resources.js @@ -0,0 +1,521 @@ +// This file contains a list of ARN paths for each API call type +// that are used to extract ARNs for resources + + +module.exports = { + acm: { + listCertificates: 'CertificateArn', + describeCertificate: 'Certificate.CertificateArn' + }, + accessanalyzer: { + listAnalyzers: 'arn', + listFindings: '' + }, + apigateway: { + getRestApis: 'arn:aws:apigateway:{region}::/restapis/{id}', + getStages: 'arn:aws:apigateway:{region}::/restapis/{resourceId}/stages/{stageName}', + getClientCertificate: 'arn:aws:apigateway:{region}::/clientcertificates/{clientCertificateId}', + getDomainNames: 'arn:aws:apigateway:{region}::/domainnames/{domainName}', + }, + appflow: { + listFlows: 'flowArn', + describeFlow: 'flowArn' + }, + appmesh: { + listMeshes: 'arn', + describeMesh: 'mesh.metadata.arn', + listVirtualGateways: 'arn', + describeVirtualGateway: 'virtualGateway.metadata.arn' + }, + apprunner: { + listServices: 'ServiceArn', + describeService: 'Service.ServiceArn' + }, + athena:{ + getWorkGroup: 'WorkGroup.Name', + listWorkGroups: 'arn:aws:athena:{region}:{cloudAccount}:workgroup/{Name}' + }, + auditmanager: { + getSettings: '', + }, + autoscaling: { + describeAutoScalingGroups: 'AutoScalingGroupARN', + describeLaunchConfigurations: 'LaunchConfigurationARN', + describeNotificationConfigurations: 'TopicARN', + + }, + backup: { + listBackupVaults: 'BackupVaultArn', + getBackupVaultAccessPolicy: 'BackupVaultArn', + getBackupVaultNotifications: 'BackupVaultArn', + describeRegionSettings: '', + getBackupPlan: 'BackupPlanArn' + }, + bedrock: { + listCustomModels: 'modelArn', + getCustomModel: 'modelArn', + listModelCustomizationJobs: 'jobArn', + getModelCustomizationJob: 'jobArn', + getModelInvocationLoggingConfiguration: '' + + }, + cloudformation: { + describeStacks: 'StackId', + listStacks: 'StackId', + describeStackEvents: 'StackId' + }, + cloudfront:{ + getDistribution: 'Distribution.ARN', + listDistributions: 'ARN' + }, + cloudtrail: { + describeTrails: 'TrailARN', + getEventSelectors: 'TrailARN', + getTrailStatus: '', + listTags: '' + }, + cloudwatch: { + describeAlarms: 'AlarmArn' + }, + cloudwatchlogs: { + describeLogGroups: 'arn', + describeConfigurationRecorders: 'roleARN', + describeMetricFilters: 'arn:aws:logs:{region}:{cloudAccount}:log-group:{logGroupName}' + }, + codeartifact: { + listDomains: 'arn', + }, + codebuild: { + listProjects: '', + batchGetProjects: 'arn', + }, + codepipeline: { + listPipelines: 'arn:aws:codepipeline:{region}:{cloudAccount}:{name}', + getPipeline: 'pipeline.roleArn', + }, + codestar: { + listProjects: 'projectArn', + describeProject: 'arn' + }, + cognitoidentityserviceprovider: { + listUserPools: 'arn:aws:cognito-idp:{region}:{cloudAccount}:userpool/{Id}', + describeUserPool: 'Arn', + + }, + comprehend: { + listEntitiesDetectionJobs: 'JobId', + listDominantLanguageDetectionJobs: 'JobId', + listTopicsDetectionJobs: 'JobId', + listDocumentClassificationJobs: 'JobId', + listKeyPhrasesDetectionJobs: 'JobId', + listSentimentDetectionJobs: 'JobId' + }, + computeoptimizer: { + getRecommendationSummaries: 'recommendationResourceType', + }, + configservice: { + describeConfigurationRecorderStatus: 'name', + describeConfigRules: 'ConfigRuleArn', + getComplianceDetailsByConfigRule: '', + describeConfigurationRecorders: 'name', + describeDeliveryChannels: 'name', + getDiscoveredResourceCounts: 'resourceType' + }, + connect: { + listInstances: 'Arn', + instanceAttachmentStorageConfigs: '', + listInstanceCallRecordingStorageConfigs: '', + listInstanceMediaStreamStorageConfigs: '', + listInstanceExportedReportStorageConfigs: '', + listInstanceChatTranscriptStorageConfigs: '', + }, + customerprofiles: { + listDomains: 'arn:aws:profile:{region}:{cloudAccount}:domain/{DomainName}', + getDomain: '', + }, + dms: { + describeReplicationInstances: 'ReplicationInstanceArn', + }, + docdb: { + describeDBClusters: 'DBClusterArn' + }, + devopsguru: { + listNotificationChannels: 'Id' + }, + dax: { + describeClusters: 'ClusterArn' + }, + dlm: { + getLifecyclePolicies: 'PolicyId', + getLifecyclePolicy: 'PolicyArn', + }, + dynamodb: { + listTables: 'arn:aws:dynamodb:{region}:{cloudAccount}:table/{value}', + listBackups: '', + describeTable: 'Table.TableArn', + describeContinuousBackups: '', + }, + ec2: { + describeAccountAttributes: 'AttributeName', + describeAddresses: '', + describeEgressOnlyInternetGateways: 'arn:aws:vpc:{region}:{cloudAccount}:egress-only-internet-gateway/{EgressOnlyInternetGatewayId}', + describeFlowLogs: 'arn:aws:ec2:{region}:{cloudAccount}:flow-log/{FlowLogId}', + describeImages: 'arn:aws:ec2:{region}:{cloudAccount}:image/${ImageId}', + describeInstances: 'arn:aws:ec2:{region}:{cloudAccount}:instance/{InstanceId}', + describeInternetGateways: 'arn:aws:vpc:{region}:{cloudAccount}:internet-gateway/{InternetGatewayId}', + describeNatGateways: 'arn:aws:ec2:{region}:{cloudAccount}:natgateway/{NatGatewayId}', + describeNetworkAcls: '`arn:aws:ec2:{region}:{cloudAccount}:network-acl/{NetworkAclId}', + describeNetworkInterfaces: 'arn:aws:ec2:{region}:{cloudAccount}:network-interface/{NetworkInterfaceId}', + describeRouteTables: 'RouteTableId', + describeSecurityGroups: 'arn:aws:ec2:{region}:{OwnerId}:security-group/{GroupId}', + describeSnapshotAttribute: '', + describeSnapshots: 'arn:aws:ec2:{region}:{OwnerId}:snapshot/{SnapshotId}', + describeSubnets: 'SubnetArn', + describeTags: 'ResourceId', + describeVolumes: 'arn:aws:ec2:{region}:{cloudAccount}:volume/{VolumeId}', + describeVpcEndpointServices: 'arn:aws:ec2:{region}:{Owner}:vpc-endpoint-service/{ServiceId}', + describeVpcEndpoints: 'arn:aws:ec2:{region}:{cloudAccount}:vpc-endpoint/{VpcEndpointId}', + describeVpcPeeringConnections: 'arn:aws:ec2:{region}:{cloudAccount}:vpc-peering-connection/{VpcPeeringConnectionId}', + describeVpcs: 'arn:aws:ec2:{region}:{cloudAccount}:vpc/{VpcId}', + describeVpnConnections: 'arn:aws:ec2:{region}:{cloudAccount}:vpn-connection/{VpnConnectionId}', + describeVpnGateways: 'arn:aws:vpc:{region}:{cloudAccount}:vpn-gateway/{VpnGatewayId}', + getEbsDefaultKmsKeyId: '', + getEbsEncryptionByDefault: '', + describeLaunchTemplates: 'LaunchTemplateId', + describeLaunchTemplateVersions: '' + }, + ecr: { + describeRepositories: 'repositoryArn', + getRepositoryPolicy: '', + }, + ecs: { + describeCluster: 'clusterArn', + listClusters: '', + listContainerInstances: '' + }, + efs: { + describeFileSystems: 'FileSystemArn' + }, + eks: { + listClusters: '', + describeCluster: 'cluster.arn', + }, + elasticache: { + describeCacheClusters: 'ARN', + describeReplicationGroups: 'ReplicationGroupId', + describeReservedCacheNodes: 'ReservationARN', + }, + elb: { + describeLoadBalancerAttributes: '', + describeLoadBalancerPolicies: 'PolicyDescriptions.PolicyName', + describeLoadBalancers: 'arn:aws:elasticloadbalancing:{region}:{cloudAccount}:loadbalancer/{LoadBalancerName}', + describeTags: '', + }, + elbv2: { + describeLoadBalancers: 'LoadBalancerArn', + describeLoadBalancerAttributes: '', + describeTargetGroups: 'TargetGroupArn', + describeTargetGroupAttributes: '', + describeListeners: 'ListenerArn', + describeTargetHealth: '' + }, + emr: { + describeCluster: 'Cluster.ClusterArn', + listClusters: 'ClusterArn', + listInstanceGroups: '', + describeSecurityConfiguration: 'Name' + }, + es:{ + describeElasticsearchDomain: 'DomainStatus.ARN', + listDomainNames: 'arn:aws:es:{region}:{cloudAccount}:domain/{DomainName}', + }, + elasticbeanstalk: { + describeConfigurationSettings: 'PlatformArn', + describeEnvironments: 'EnvironmentArn' + }, + elastictranscoder: { + listPipelines: 'Arn', + listJobsByPipeline: 'Arn' + }, + eventbridge: { + listEventBuses: 'Arn', + listRules: 'Arn' + }, + finspace: { + listEnvironments: 'environmentArn' + }, + firehose: { + listDeliveryStreams: '', + describeDeliveryStream: 'DeliveryStreamDescription.DeliveryStreamARN' + }, + forecastservice: { + listForecastExportJobs: 'ForecastExportJobArn', + listDatasets: 'DatasetArn', + describeDataset: 'DatasetArn' + }, + frauddetector: { + getDetectors: 'arn', + getKMSEncryptionKey: 'kmsEncryptionKeyArn', + }, + fsx: { + describeFileSystems: 'ResourceARN' + }, + glue: { + getDataCatalogEncryptionSettings: '', + getSecurityConfigurations: 'arn:aws:glue:{region}:{cloudAccount}:/securityConfiguration/{Name}', + }, + glacier: { + listVaults: 'VaultARN' + }, + databrew: { + listJobs: 'ResourceArn', + }, + guardduty: { + listDetectors: 'arn:aws:guardduty:{region}:{cloudAccount}:detector/{value}', + getDetector: 'detectorId', + getMasterAccount: '', + listFindings: '', + getFindings: 'Findings.Id', + listPublishingDestinations: 'arn:aws:guardduty:{region}:{cloudAccount}:detector/{}/publishingDestination/{DestinationId}', + describePublishingDestination: 'DestinationId', + }, + healthlake: { + listFHIRDatastores: 'DatastoreArn' + }, + iam: { + generateCredentialReport: 'arn', + getGroup: 'Group.Arn', + listGroups: 'Arn', + getUserPolicy: '', + getRole: 'Role.Arn', + listPolicies: 'Arn', + listRoles: 'Arn', + listServerCertificates: 'Arn', + listUsers: 'Arn', + listVirtualMFADevices: 'SerialNumber', + getAccountPasswordPolicy: '', + getAccountSummary: '', + listAttachedUserPolicies: 'PolicyArn', + listAttachedGroupPolicies: '', + listAttachedRolePolicies: '', + listUserPolicies: '', + listGroupPolicies: '', + listRolePolicies: '', + listSSHPublicKeys: '', + listMFADevices: '', + listGroupsForUser: '', + getGroupPolicy: '', + getRolePolicy: '', + getPolicy: 'Policy.Arn', + getUser: 'Arn' + }, + imagebuilder: { + listContainerRecipes: 'arn', + getContainerRecipe: 'containerRecipe.ARN', + listImagePipelines: 'arn', + listImageRecipes: 'arn', + getImageRecipe: 'imageRecipe.ARN', + listComponents: 'arn', + getComponent: 'component.arn', + listInfrastructureConfigurations: 'arn', + getInfrastructureConfiguration: 'infrastructureConfiguration.arn' + + }, + iotsitewise: { + describeDefaultEncryptionConfiguration: 'kmsKeyArn' + }, + kendra: { + listIndices: 'arn:aws:kendra:{region}:{cloudAccount}:index/{Name}', + describeIndex: 'Id', + + }, + kinesis: { + describeStream: 'StreamDescription.StreamARN', + listStreams: 'arn:aws:kinesis:{region}:{cloudAccount}:stream/{value}', + }, + kinesisvideo: { + listStreams: 'StreamARN' + }, + kms: { + listKeys: 'KeyArn', + describeKey: 'KeyMetadata.Arn', + listAliases: 'AliasArn', + listResourceTags: '', + listGrants: '', + getKeyPolicy: '', + getKeyRotationStatus: '', + }, + lambda: { + listFunctions: 'FunctionArn', + getPolicy: '', + listTags: '', + }, + lexmodelsv2: { + listBots: 'arn:aws:lex:{region}:{cloudAccount}:bot/{botId}', + listBotAliases: 'arn:aws:lex:{region}:{cloudAccount}:bot/{botAliasId}', + describeBotAlias: '', + }, + location: { + listGeofenceCollections: 'arn:aws:geo:{region}:{cloudAccount}:geofence-collection/{CollectionName}', + describeGeofenceCollection: 'CollectionArn', + listTrackers: 'arn:aws:geo:{region}:{cloudAccount}:tracker/{TrackerName}', + describeTracker: 'TrackerArn' + }, + lookoutmetrics: { + listAnomalyDetectors: 'AnomalyDetectorArn', + describeAnomalyDetector: 'AnomalyDetectorArn' + }, + lookoutequipment: { + listDatasets: 'DatasetArn', + describeDataset: 'DatasetArn' + }, + lookoutvision: { + listProjects: 'ProjectArn', + listModels: 'ModelArn', + describeModel: 'ModelDescription.ModelArn' + }, + managedblockchain: { + listMembers: 'Arn', + listNetworks: 'Arn', + getMember: 'Arn' + }, + memorydb: { + describeClusters: 'ARN', + }, + mq: { + listBrokers: 'BrokerArn', + describeBroker: 'BrokerArn' + }, + kafka: { + listClusters: 'ClusterArn', + }, + mwaa: { + listEnvironments: 'arn:aws:airflow:{region}:{cloudAccount}:environment/{value}', + getEnvironment: 'Environment.Arn', + }, + neptune: { + describeDBClusters: 'DBClusterArn', + + }, + organizations: { + describeOrganization: 'MasterAccountArn', + listHandshakesForAccount: 'Arn', + listAccounts: 'Arn' + }, + proton: { + listEnvironmentTemplates: 'arn', + getEnvironmentTemplate: 'environmentTemplate.arn', + }, + qldb: { + listLedgers: 'arn:aws:qldb:{region}:{cloudAccount}:ledger/{Name}', + describeLedger: 'Arn', + }, + rds: { + describeDBClusters: 'DBClusterArn', + describeDBEngineVersions: 'Engine', + describeDBInstances: 'DBInstanceArn', + describeDBParameters: '', + describeDBParameterGroups: 'DBParameterGroupArn', + describeDBSnapshots: 'DBSnapshotArn', + }, + redshift: { + describeClusterParameterGroups: 'ParameterGroupName', + describeClusterParameters: '', + describeClusters: 'arn:aws:redshift:{region}:{cloudAccount}:cluster:{ClusterIdentifier}', + describeLoggingStatus: '', + describeReservedNodes: 'arn:aws:redshift:{region}:{cloudAccount}:reserved-node:{ReservedNodeId}', + + }, + route53: { + listHostedZones: 'arn:aws:route53:::{Id}', + listResourceRecordSets: '', + }, + route53domains: { + listDomains: 'DomainName', + getDomainDetail: '', + }, + s3: { + listBuckets: 'arn:aws:s3:::{Name}', + listObjects: '', + getBucketAcl: '', + getBucketLocation: '', + getBucketPolicy: '', + getBucketEncryption: '', + getBucketWebsite: '', + getBucketLifecycleConfiguration: '', + getBucketLogging: '', + getPublicAccessBlock: '', + getBucketAccelerateConfiguration: '', + getBucketVersioning: '' + }, + secretsmanager: { + listSecrets: 'ARN', + describeSecret: 'ARN', + }, + sns:{ + listTopics: 'TopicArn', + getTopicAttributes: 'Attributes.TopicArn', + listSubscriptions: 'SubscriptionArn', + }, + ses: { + describeActiveReceiptRuleSet: 'Name', + getIdentityDkimAttributes: '', + listIdentities: '' + }, + sqs: { + getQueueAttributes: 'Attributes.QueueArn', + listQueues: 'arn:aws:sqs:{region}:{cloudAccount}:{queueName}' + }, + ssm: { + describeInstanceInformation: 'InstanceId', + describeParameters: 'Name', + listAssociations: 'AssociationId', + getServiceSetting: 'ARN', + describeSessions: 'arn:aws:ec2:{region}:{cloudAccount}:/instance/{Target}' + }, + sagemaker: { + describeNotebookInstance: 'NotebookInstanceArn', + listNotebookInstances: 'NotebookInstanceArn' + }, + shield: { + describeEmergencyContactSettings: '', + describeSubscription: '', + listProtections: '' + }, + support: { + describeTrustedAdvisorCheckResult: '', + describeTrustedAdvisorChecks: 'id', + }, + timestreamwrite: { + listDatabases: 'Arn', + }, + translate: { + listTextTranslationJobs: 'arn:aws:translate:{region}:{cloudAccount}:job/{JobName}' + }, + transfer: { + listServers: 'Arn' + }, + waf: { + listWebACLs: 'Arn' + }, + wisdom: { + listAssistants: 'assistantArn' + }, + wafRegional: { + listResourcesForWebACL: '', + listWebACLs: 'WebACLId' + }, + wafv2: { + listResourcesForWebACL: '', + listWebACLs: 'ARN' + }, + workspaces: { + describeIpGroups: 'groupId', + describeWorkspaceDirectories: 'DirectoryId', + describeWorkspaces: 'arn:aws:workspaces:{region}:{cloudAccount}:workspace/{WorkspaceId}', + describeWorkspacesConnectionStatus: '' + }, + xray: { + getEncryptionConfig: 'KeyId' + } +}; \ No newline at end of file diff --git a/helpers/azure/api.js b/helpers/azure/api.js new file mode 100644 index 000000000..2f71d0678 --- /dev/null +++ b/helpers/azure/api.js @@ -0,0 +1,1571 @@ +/* + enabled: send integration is enable or not + isSingleSource: whether resource is single source or not + +----------Bridge Side Data---------- + BridgeServiceName: it should be the api service name which we are storing in json file in s3 collection bucket. + BridgeCall: it should be the api call which we are storing in json file in s3 collection bucket. + BridgePluginCategoryName: it should be equivalent to Plugin Category Name. + BridgeProvider: it should be the cloud provider + Eg. 'aws', 'Azure', 'Google' + + BridgeArnIdentifier: no need to pass. + + BridgeIdTemplate: this should be the template for creating the resource id. + supported values: name, region, cloudAccount, project, id + + BridgeResourceType: this should be type of the resource, fetch it from the id. + Eg. 'servers' + + BridgeResourceNameIdentifier: it should be the key of resource name/id data which we are storing in json file in s3 collection bucket. + Eg. 'Name/name' or 'Id/id' + + Note: if there is no name then we have to pass the id. + + BridgeExecutionService: it should be equivalent to service name which we are sending from executor in payload data. + BridgeCollectionService: it should be equivalent to service name which we are sending from collector in payload data. + DataIdentifier: it should be the parent key field of data which we want to collect in json file in s3 collection bucket. + +----------Processor Side Data---------- +These fields should be according to the user and product manager, what they want to show in Inventory UI. + InvAsset: 'LogAlerts' + InvService: 'LogAlerts' + InvResourceCategory: 'cloud_resources' + Note: For specific category add the category name otherwise it should be 'cloud_resource' + + InvResourceType: 'LogAlerts' + If you need that your resource type to be two words with capital letter only on first letter of the word (for example: Key Vaults), you should supply the resource type with a space delimiter. + If you need that your resource type to be two words and the the first word should be in capital letters (for example: CDN Profiles), you should supply the resource type with snake case delimiter + + Take the reference from the below map +*/ + +// Note: In Below service map add only single source resources. +// and service name should be plugin category. + +var serviceMap = { + 'Redis Cache': + { + enabled: true, isSingleSource: true, InvAsset: 'redisCaches', InvService: 'redisCaches', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Redis Cache', BridgeServiceName: 'rediscaches', + BridgePluginCategoryName: 'Redis Cache', BridgeProvider: 'Azure', BridgeCall: 'listBySubscription', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'Redis', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Redis Cache', + BridgeCollectionService: 'rediscaches', DataIdentifier: 'data', + }, + 'CDN Profiles': [ + { + enabled: true, isSingleSource: true, InvAsset: 'cdnProfiles', InvService: 'cdnProfiles', + InvResourceCategory: 'cloud_resources', InvResourceType: 'CDN_Profiles', BridgeServiceName: 'profiles', + BridgePluginCategoryName: 'CDN Profiles', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'profiles', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CDN Profiles', + BridgeCollectionService: 'profiles', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'endpoint', InvService: 'cdnProfiles', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Endpoints', BridgeServiceName: 'endpoints', + BridgePluginCategoryName: 'CDN Profiles', BridgeProvider: 'Azure', BridgeCall: 'listByProfile', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'endpoints', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CDN Profiles', + BridgeCollectionService: 'endpoints', DataIdentifier: 'data', + } + ], + 'Cosmos DB': + { + enabled: true, isSingleSource: true, InvAsset: 'cosmosdb', InvService: 'cosmosDB', + InvResourceCategory: 'database', InvResourceType: 'cosmos_DB', BridgeServiceName: 'databaseaccounts', + BridgePluginCategoryName: 'Cosmos DB', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'databaseAccounts', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Cosmos DB', + BridgeCollectionService: 'databaseaccounts', DataIdentifier: 'data', + }, + 'Key Vaults': + { + enabled: true, isSingleSource: true, InvAsset: 'vaults', InvService: 'keyVaults', + InvResourceCategory: 'cloud_resources', InvResourceType: 'key vaults', BridgeServiceName: 'vaults', + BridgePluginCategoryName: 'Key Vaults', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'vaults', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Key Vaults', + BridgeCollectionService: 'vaults', DataIdentifier: 'data', + }, + 'Load Balancer': + { + enabled: true, isSingleSource: true, InvAsset: 'loadBalancer', InvService: 'loadBalancer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'load_balancer', BridgeServiceName: 'loadbalancers', + BridgePluginCategoryName: 'Load Balancer', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'loadBalancers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Load Balancer', + BridgeCollectionService: 'loadbalancers', DataIdentifier: 'data', + }, + 'Log Alerts': + { + enabled: true, isSingleSource: true, InvAsset: 'logAlerts', InvService: 'logAlerts', + InvResourceCategory: 'cloud_resources', InvResourceType: 'log alerts', BridgeServiceName: 'activitylogalerts', + BridgePluginCategoryName: 'Log Alerts', BridgeProvider: 'Azure', BridgeCall: 'listBySubscriptionId', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'activityLogAlerts', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Log Alerts', + BridgeCollectionService: 'activitylogalerts', DataIdentifier: 'data', + }, + 'Network Watcher': + { + enabled: true, isSingleSource: true, InvAsset: 'networkWatcher', InvService: 'networkWatcher', + InvResourceCategory: 'cloud_resources', InvResourceType: 'network_watcher', BridgeServiceName: 'networkwatchers', + BridgePluginCategoryName: 'Network Watcher', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'networkWatchers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Network Watcher', + BridgeCollectionService: 'networkwatchers', DataIdentifier: 'data', + }, + 'Azure Policy': + { + enabled: true, isSingleSource: true, InvAsset: 'azurePolicy', InvService: 'azurePolicy', + InvResourceCategory: 'cloud_resources', InvResourceType: 'azure_policy', BridgeServiceName: 'policyassignments', + BridgePluginCategoryName: 'Azure Policy', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'policyAssignments', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'Azure Policy', + BridgeCollectionService: 'policyassignments', DataIdentifier: 'data', + }, + 'Virtual Networks':[ + { + enabled: true, isSingleSource: true, InvAsset: 'virtual_network', InvService: 'virtual_network', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Virtual Network', BridgeServiceName: 'virtualnetworks', + BridgePluginCategoryName: 'Virtual Networks', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'virtualNetworks', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Networks', + BridgeCollectionService: 'virtualnetworks', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'vn_routeTables', InvService: 'virtual_network', + InvResourceCategory: 'cloud_resources', InvResourceType: 'VN_RouteTables', BridgeServiceName: 'routetables', + BridgePluginCategoryName: 'Virtual Networks', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'routeTables', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Networks', + BridgeCollectionService: 'routetables', DataIdentifier: 'data', + } + ], + 'Queue Service': + { + enabled: true, isSingleSource: true, InvAsset: 'queueService', InvService: 'queueService', + InvResourceCategory: 'storage', InvResourceType: 'queue_service', BridgeServiceName: 'queueservice', + BridgePluginCategoryName: 'Queue Service', BridgeProvider: 'Azure', BridgeCall: 'getQueueAcl', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'queueService', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Queue Service', + BridgeCollectionService: 'queueservice', DataIdentifier: 'data', + }, + 'Table Service': + { + enabled: true, isSingleSource: true, InvAsset: 'tableService', InvService: 'tableService', + InvResourceCategory: 'storage', InvResourceType: 'table_service', BridgeServiceName: 'tableservice', + BridgePluginCategoryName: 'Table Service', BridgeProvider: 'Azure', BridgeCall: 'getTableAcl', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'tableService', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Table Service', + BridgeCollectionService: 'tableservice', DataIdentifier: 'data', + }, + 'File Service': + { + enabled: true, isSingleSource: true, InvAsset: 'fileService', InvService: 'fileService', + InvResourceCategory: 'storage', InvResourceType: 'file_service', BridgeServiceName: 'fileservice', + BridgePluginCategoryName: 'File Service', BridgeProvider: 'Azure', BridgeCall: 'listSharesSegmented', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'fileService', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'File Service', + BridgeCollectionService: 'fileservice', DataIdentifier: 'data', + }, + 'SQL Databases': + { + enabled: true, isSingleSource: true, InvAsset: 'database', InvService: 'sql', + InvResourceCategory: 'database', InvResourceType: 'sql_database', BridgeServiceName: 'databases', + BridgePluginCategoryName: 'SQL Databases', BridgeProvider: 'Azure', BridgeCall: 'listByServer', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'databases', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'SQL Databases', + BridgeCollectionService: 'databases', DataIdentifier: 'data', + }, + 'AI & ML': + { + enabled: true, isSingleSource: true, InvAsset: 'account', InvService: 'openAI', + InvResourceCategory: 'ai&ml', InvResourceType: 'OpenAI Accounts', BridgeProvider: 'Azure', + BridgeServiceName: 'openAI', BridgePluginCategoryName: 'AI & ML', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'accounts', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'AI & ML', + BridgeCollectionService: 'openai', BridgeCall: 'listAccounts', DataIdentifier: 'data', + }, + 'Blob Service': + { + enabled: true, isSingleSource: true, InvAsset: 'blob_container', InvService: 'blobservice', + InvResourceCategory: 'cloud_resources', InvResourceType: 'blob_container', BridgeServiceName: 'blobcontainers', + BridgePluginCategoryName: 'Blob Service', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'containers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Blob Service', + BridgeCollectionService: 'blobcontainers', DataIdentifier: 'data', + }, + 'Virtual Machines': + { + enabled: true, isSingleSource: true, InvAsset: 'vm_scaleset', InvService: 'virtualmachines', + InvResourceCategory: 'cloud_resources', InvResourceType: 'VM_ScaleSet', BridgeServiceName: 'virtualmachinescalesets', + BridgePluginCategoryName: 'Virtual Machines', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'virtualMachineScaleSets', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Machines', + BridgeCollectionService: 'virtualmachinescalesets', DataIdentifier: 'data', + }, + 'Event Grid': + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'eventgrid', + InvResourceCategory: 'cloud_resources', InvResourceType: 'EventGrid Domain', BridgeServiceName: 'eventgrid', + BridgePluginCategoryName: 'Event Grid', BridgeProvider: 'Azure', BridgeCall: 'listDomains', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'domains', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Event Grid', + BridgeCollectionService: 'eventgrid', DataIdentifier: 'data', + }, + 'Event Hubs': + { + enabled: true, isSingleSource: true, InvAsset: 'namespace', InvService: 'eventhubs', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Eventhubs Namespace', BridgeServiceName: 'eventhub', + BridgePluginCategoryName: 'Event Hubs', BridgeProvider: 'Azure', BridgeCall: 'listEventHub', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'namespaces', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Event Hubs', + BridgeCollectionService: 'eventhub', DataIdentifier: 'data', + }, + 'Defender': [ + { + enabled: true, isSingleSource: true, InvAsset: 'defender', InvService: 'defender', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Defender', BridgeServiceName: 'pricings', + BridgePluginCategoryName: 'Defender', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'pricings', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Defender', + BridgeCollectionService: 'pricings', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'defender', InvService: 'defender', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Defender Settings', BridgeServiceName: 'securitycenter', + BridgePluginCategoryName: 'Defender', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'settings', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Defender', + BridgeCollectionService: 'securitycenter', DataIdentifier: 'data', + } + ], + 'Application Gateway': [ + { + enabled: true, isSingleSource: true, InvAsset: 'applicationGateway', InvService: 'applicationGateway', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Application Gateway', BridgeServiceName: 'applicationgateway', + BridgePluginCategoryName: 'Application Gateway', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'applicationGateways', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Application Gateway', + BridgeCollectionService: 'applicationgateway', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'policy', InvService: 'applicationGateway', + InvResourceCategory: 'cloud_resources', InvResourceType: 'wafpolicies', BridgeServiceName: 'wafpolicies', + BridgePluginCategoryName: 'Application Gateway', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'ApplicationGatewayWebApplicationFirewallPolicies', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Application Gateway', + BridgeCollectionService: 'wafpolicies', DataIdentifier: 'data', + } + ], + 'Entra ID': [ + { + enabled: true, isSingleSource: true, InvAsset: 'entraId', InvService: 'entraId', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Roles', BridgeServiceName: 'roledefinitions', + BridgePluginCategoryName: 'Entra ID', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'roleDefinitions', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Entra ID', + BridgeCollectionService: 'roledefinitions', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'entraId', InvService: 'entraId', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Application', BridgeServiceName: 'applications', + BridgePluginCategoryName: 'Entra ID', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: '', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Entra ID', + BridgeCollectionService: 'applications', DataIdentifier: 'data', + } + ] +}; + +// Standard calls that contain top-level operations +var calls = { + resourceGroups: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/resourcegroups?api-version=2019-10-01' + } + }, + advisor: { + recommendationsList: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Advisor/recommendations?api-version=2020-01-01' + } + }, + activityLogAlerts: { + listBySubscriptionId: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/activityLogAlerts?api-version=2020-10-01' + }, + sendIntegration: serviceMap['Log Alerts'] + }, + logAnalytics: { + listWorkspaces: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.OperationalInsights/workspaces?api-version=2023-09-01' + } + }, + storageAccounts: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01', + rateLimit: 3000 + } + }, + containerApps:{ + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.App/containerApps?api-version=2023-05-01' + } + }, + appConfigurations: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.AppConfiguration/configurationStores?api-version=2023-03-01' + } + }, + virtualNetworks: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/virtualNetworks?api-version=2020-03-01' + }, + sendIntegration: serviceMap['Virtual Networks'][0] + }, + natGateways: { + listBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/natGateways?api-version=2020-11-01' + } + }, + virtualMachines: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01', + paginate: 'nextLink' + } + }, + publicIpAddresses: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/publicIPAddresses?api-version=2023-06-01' + } + }, + images: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/images?api-version=2022-08-01', + paginate: 'nextLink' + } + }, + vmScaleSet: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachineScaleSets?api-version=2022-08-01', + paginate: 'nextLink' + } + }, + snapshots: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/snapshots?api-version=2020-12-01' + } + }, + disks: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/disks?api-version=2023-04-02' + } + }, + networkSecurityGroups: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkSecurityGroups?api-version=2020-03-01' + } + }, + networkInterfaces: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkInterfaces?api-version=2020-11-01' + } + }, + vaults: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.KeyVault/vaults?api-version=2023-07-01' + }, + sendIntegration: serviceMap['Key Vaults'], + }, + recoveryServiceVaults: { + listBySubscriptionId: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.RecoveryServices/vaults?api-version=2016-06-01' + } + }, + resources: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/resources?api-version=2021-04-01' + }, + }, + redisCaches: { + listBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Cache/redis?api-version=2023-08-01' + }, + sendIntegration: serviceMap['Redis Cache'] + }, + routeTables: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/routeTables?api-version=2022-07-01' + }, + sendIntegration: serviceMap['Virtual Networks'][1] + }, + managedClusters: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ContainerService/managedClusters?api-version=2020-03-01' + } + }, + managedInstances: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Sql/managedInstances?api-version=2021-11-01' + } + }, + networkWatchers: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkWatchers?api-version=2022-01-01' + }, + sendIntegration: serviceMap['Network Watcher'] + }, + policyAssignments: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyAssignments?api-version=2019-09-01', + }, + sendIntegration: serviceMap['Azure Policy'] + }, + policyDefinitions: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions?api-version=2019-09-01' + } + }, + webApps: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Web/sites?api-version=2019-08-01' + } + }, + appServiceCertificates: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Web/certificates?api-version=2019-08-01' + } + }, + logProfiles: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/logprofiles?api-version=2016-03-01' + } + }, + profiles: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Cdn/profiles?api-version=2024-02-01' + }, + sendIntegration: serviceMap['CDN Profiles'][0] + }, + autoProvisioningSettings: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/autoProvisioningSettings?api-version=2017-08-01-preview' + } + }, + applicationGateway: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/applicationGateways?api-version=2022-07-01' + }, + sendIntegration: serviceMap['Application Gateway'][0] + }, + securityContacts: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/securityContacts?api-version=2017-08-01-preview', + ignoreLocation: true + } + }, + securityContactv2: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview', + ignoreLocation: true, + hasListResponse: true + } + }, + subscriptions: { + listLocations: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/locations?api-version=2020-01-01' + }, + get: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}?api-version=2020-01-01', + getCompleteResponse: true, + } + }, + roleDefinitions: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions?api-version=2015-07-01' + }, + sendIntegration: serviceMap['Entra ID'][0] + }, + managementLocks: { + listAtSubscriptionLevel: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/locks?api-version=2016-09-01' + } + }, + machineLearning: { + listWorkspaces: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.MachineLearningServices/workspaces?api-version=2024-04-01' + }, + listRegistries: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.MachineLearningServices/registries?api-version=2024-04-01' + }, + + }, + loadBalancers: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/loadBalancers?api-version=2020-03-01' + }, + sendIntegration: serviceMap['Load Balancer'] + }, + users: { + list: { + url: 'https://graph.microsoft.com/v1.0/users', + graph: true + } + }, + applications: { + list: { + url: 'https://graph.microsoft.com/v1.0/applications/', + graph: true, + }, + sendIntegration: serviceMap['Entra ID'][1] + }, + automationAccounts: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Automation/automationAccounts?api-version=2023-11-01' + } + }, + registries: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ContainerRegistry/registries?api-version=2023-01-01-preview' + } + }, + pricings: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/pricings?api-version=2018-06-01' + }, + sendIntegration: serviceMap['Defender'][0] + }, + availabilitySets: { + listBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/availabilitySets?api-version=2019-12-01' + } + }, + virtualMachineScaleSets: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachineScaleSets?api-version=2023-07-01' + }, + sendIntegration: serviceMap['Virtual Machines'] + }, + bastionHosts: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/bastionHosts?api-version=2022-09-01' + } + }, + wafPolicies: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies?api-version=2022-07-01' + }, + sendIntegration: serviceMap['Application Gateway'][1] + }, + autoscaleSettings: { + listBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/autoscalesettings?api-version=2015-04-01' + } + }, + diagnosticSettingsOperations: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/diagnosticSettings?api-version=2017-05-01-preview' + } + }, + servers: { + listSql: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Sql/servers?api-version=2022-05-01-preview' + }, + listMysql: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/servers?api-version=2017-12-01' + }, + listMysqlFlexibleServer: { + url : 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/flexibleServers?api-version=2023-12-30' + }, + listPostgres: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforPostgreSQL/servers?api-version=2017-12-01' + }, + listPostgresFlexibleServer: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforPostgreSQL/flexibleServers?api-version=2022-12-01' + } + }, + databaseAccounts: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DocumentDB/databaseAccounts?api-version=2020-06-01-preview' + }, + sendIntegration: serviceMap['Cosmos DB'] + }, + securityCenter: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/settings?api-version=2021-06-01' + }, + sendIntegration: serviceMap['Defender'][1] + }, + publicIPAddresses: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/publicIPAddresses?api-version=2021-08-01' + } + }, + privateDnsZones: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/privateDnsZones?api-version=2018-09-01' + } + }, + privateEndpoints: { + listBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/privateEndpoints?api-version=2022-01-01' + } + }, + eventGrid: { + listDomains: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.EventGrid/domains?api-version=2023-12-15-preview' + }, + sendIntegration: serviceMap['Event Grid'] + }, + eventHub: { + listEventHub: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.EventHub/namespaces?api-version=2022-10-01-preview' + }, + sendIntegration: serviceMap['Event Hubs'] + }, + serviceBus: { + listNamespacesBySubscription: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ServiceBus/namespaces?api-version=2022-10-01-preview' + } + }, + mediaServices:{ + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Media/mediaservices?api-version=2023-01-01' + } + + }, + computeGalleries: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/galleries?api-version=2022-08-03' + } + }, + batchAccounts: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Batch/batchAccounts?api-version=2024-02-01' + } + }, + openAI: { + listAccounts: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CognitiveServices/accounts?api-version=2023-05-01' + }, + sendIntegration: serviceMap['AI & ML'] + }, + databricks: { + listWorkspaces: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Databricks/workspaces?api-version=2023-02-01' + } + }, + apiManagementService: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ApiManagement/service?api-version=2022-08-01' + } + }, + // For CIEM + aad: { + listRoleAssignments: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01' + }, + listDenyAssignments: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/denyAssignments?api-version=2022-04-01' + } + }, + // For CIEM + groups: { + list: { + url: 'https://graph.microsoft.com/v1.0/groups', + graph: true + } + }, + // For CIEM + servicePrincipals: { + list: { + url: 'https://graph.microsoft.com/v1.0/servicePrincipals', + graph: true + } + }, + classicFrontDoors: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/frontDoors?api-version=2019-05-01' + } + }, + afdWafPolicies: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/frontdoorWebApplicationFirewallPolicies?api-version=2022-05-01' + + } + }, + synapse: { + listWorkspaces: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Synapse/workspaces?api-version=2021-06-01' + } + } + +}; + +var postcalls = { + //For CIEM + aad: { + getGroupMembers: { + reliesOnPath: 'groups.list', + properties: ['id'], + url: 'https://graph.microsoft.com/v1.0/groups/{id}/members', + graph: true + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['roleDefinitions','users','groups','servicePrincipals'] + } + } + }, + recoveryServiceVaults: { + getRecoveryServiceVault: { + reliesOnPath: 'recoveryServiceVaults.listBySubscriptionId', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2022-04-01' + } + }, + webhooks: { + listByAutomationAccounts: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/webhooks?api-version=2015-10-31' + } + }, + accountVariables: { + listByAutomationAccounts: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/variables?api-version=2023-11-01' + } + }, + availabilitySets:{ + listByResourceGroup: { + reliesOnPath: 'resourceGroups.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/Microsoft.Compute/availabilitySets?api-version=2020-12-01' + } + }, + resources: { + listByResourceGroup: { + reliesOnPath: 'resourceGroups.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/resources?api-version=2021-04-01' + } + }, + advancedThreatProtection: { + get: { + reliesOnPath: 'databaseAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/Microsoft.Security/advancedThreatProtectionSettings/current?api-version=2017-08-01-preview' + }, + listPostgresFlexibleServer: { + reliesOnPath: 'servers.listPostgresFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/advancedThreatProtectionSettings?api-version=2023-06-01-preview' + } + }, + sourceControls: { + listByAutomationAccounts: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/sourceControls?api-version=2023-11-01' + } + }, + automationAccounts:{ + get: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2023-11-01' + } + }, + backupProtectedItems: { + listByVault: { + reliesOnPath: 'recoveryServiceVaults.listBySubscriptionId', + properties: ['id'], + url: 'https://management.azure.com/{id}/backupProtectedItems?api-version=2019-05-13' + } + }, + backupPolicies: { + listByVault: { + reliesOnPath: 'recoveryServiceVaults.listBySubscriptionId', + properties: ['id'], + url: 'https://management.azure.com/{id}/backupPolicies?api-version=2019-05-13' + } + }, + serverBlobAuditingPolicies: { + get: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/auditingSettings?api-version=2021-11-01' + } + }, + serverSecurityAlertPolicies: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/securityAlertPolicies?api-version=2017-03-01-preview' + } + }, + + advancedThreatProtectionSettings: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/advancedThreatProtectionSettings?api-version=2021-11-01-preview' + } + }, + vulnerabilityAssessments: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/vulnerabilityAssessments?api-version=2021-02-01-preview' + } + }, + failoverGroups: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/failoverGroups?api-version=2021-02-01-preview' + } + }, + serverAutomaticTuning: { + get: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/automaticTuning/current?api-version=2020-08-01-preview' + } + }, + certificates: { + listByAutomationAccounts: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/certificates?api-version=2023-11-01' + } + }, + flowLogs: { + list: { + reliesOnPath: 'networkWatchers.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/flowLogs?api-version=2020-11-01' + } + }, + virtualNetworkPeerings: { + list: { + reliesOnPath: 'virtualNetworks.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/virtualNetworkPeerings?api-version=2020-11-01' + } + }, + flexibleServersConfigurations: { + listByServer: { + reliesOnPath: 'servers.listMysqlFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/configurations?api-version=2021-05-01' + }, + listByPostgresServer: { + reliesOnPath: 'servers.listPostgresFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/configurations?api-version=2022-12-01' + }, + }, + serverAdministrators: { + list: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}/administrators?api-version=2017-12-01' + } + }, + recordSets: { + list: { + reliesOnPath: 'privateDnsZones.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/ALL?api-version=2018-09-01' + } + }, + virtualMachines: { + get: { + reliesOnPath: 'virtualMachines.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2020-12-01' + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['networkInterfaces', 'publicIPAddresses', 'recordSets'] + } + } + }, + virtualMachineExtensions: { + list: { + reliesOnPath: 'virtualMachines.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/extensions?api-version=2019-12-01' + } + }, + virtualMachineScaleSetVMs: { + list: { + reliesOnPath: 'virtualMachineScaleSets.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/virtualMachines?api-version=2020-12-01' + } + }, + virtualNetworkGateways: { + listByResourceGroup: { + reliesOnPath: 'resourceGroups.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/Microsoft.Network/virtualNetworkGateways?api-version=2020-11-01' + } + }, + networkGatewayConnections: { + listByResourceGroup: { + reliesOnPath: 'resourceGroups.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/Microsoft.Network/connections?api-version=2020-11-01' + } + }, + blobContainers: { + list: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/blobServices/default/containers?api-version=2019-06-01', + rateLimit: 3000, + limit: 20000 + }, + sendIntegration: serviceMap['Blob Service'] + }, + blobServices: { + list: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/blobServices?api-version=2019-06-01', + rateLimit: 3000 + }, + getServiceProperties: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/blobServices/default?api-version=2019-06-01', + rateLimit: 500 + } + }, + fileShares: { + list: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/fileServices/default/shares?api-version=2023-01-01', + rateLimit: 3000 + } + }, + storageAccounts: { + listKeys: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/listKeys?api-version=2019-06-01', + post: true, + rateLimit: 3000 + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['storageAccounts', 'blobServices', 'blobContainers', 'fileShares'] + }, + } + }, + encryptionProtectors: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/encryptionProtector?api-version=2015-05-01-preview' + }, + }, + managedInstanceEncryptionProtectors: { + listByInstance: { + reliesOnPath: 'managedInstances.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/encryptionProtector?api-version=2024-05-01-preview' + }, + }, + webApps: { + getAuthSettings: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/config/authsettings/list?api-version=2019-08-01', + post: true + }, + listConfigurations: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/config?api-version=2019-08-01' + }, + listAppSettings: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/config/appsettings/list?api-version=2021-02-01', + post: true + }, + getBackupConfiguration: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/config/backup/list?api-version=2021-02-01', + post: true + }, + getWebAppDetails: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2022-03-01' + }, + sendIntegration: { + enabled: true + } + }, + containerApps: { + getAuthSettings : { + reliesOnPath: 'containerApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/authConfigs?api-version=2023-05-01', + } + }, + endpoints: { + listByProfile: { + reliesOnPath: 'profiles.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/endpoints?api-version=2019-04-15' + }, + sendIntegration: serviceMap['CDN Profiles'][1] + }, + customDomain: { + listByFrontDoorProfiles: { + reliesOnPath: 'profiles.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/customDomains?api-version=2021-06-01' + } + }, + vaults: { + getKeys: { + reliesOnPath: 'vaults.list', + properties: ['vaultUri'], + url: '{vaultUri}keys?api-version=7.0', + vault: true + }, + getSecrets: { + reliesOnPath: 'vaults.list', + properties: ['vaultUri'], + url: '{vaultUri}secrets?api-version=7.0', + vault: true + }, + getCertificates: { + reliesOnPath: 'vaults.list', + properties: ['vaultUri'], + url: '{vaultUri}certificates?api-version=7.3', + vault: true + } + }, + databases: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/databases?api-version=2023-02-01-preview' + }, + sendIntegration: serviceMap['SQL Databases'] + }, + serverAzureADAdministrators: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/administrators?api-version=2014-04-01' + } + }, + usages: { + list: { + reliesOnPath: 'subscriptions.listLocations', + properties: ['name'], + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/locations/{name}/usages?api-version=2020-03-01' + } + }, + firewallRules: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2019-06-01-preview' + }, + listByServerMySQL: { + reliesOnPath: 'servers.listMysql', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2017-12-01' + }, + listByServerPostgres: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2017-12-01' + }, + listByFlexibleServerPostgres: { + reliesOnPath: 'servers.listPostgresFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2022-12-01' + }, + listByFlexibleServerMysql: { + reliesOnPath: 'servers.listMysqlFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2021-05-01' + } + }, + outboundFirewallRules: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/outboundFirewallRules?api-version=2022-02-01-preview' + } + }, + virtualNetworkRules: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/virtualNetworkRules?api-version=2019-06-01-preview' + }, + listByServerMySQL: { + reliesOnPath: 'servers.listMysql', + properties: ['id'], + url: 'https://management.azure.com/{id}/virtualNetworkRules?api-version=2017-12-01' + }, + listByServerPostgres: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}/virtualNetworkRules?api-version=2017-12-01' + } + }, + managedClusters: { + getUpgradeProfile: { + reliesOnPath: 'managedClusters.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/upgradeProfiles/default?api-version=2020-03-01' + }, + pools: { + reliesOnPath: 'managedClusters.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/agentPools?api-version=2022-03-01' + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['managedClusters', 'virtualNetworks', 'virtualNetworkPeerings'] + }, + } + }, + functions: { + config: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/config?api-version=2022-03-01' + }, + usages: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/usages?api-version=2022-03-01' + }, + list: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/functions?api-version=2021-03-01' + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['webApps'] + } + } + }, + registries: { + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['replications'] + } + } + }, + dbServers: { + getSQL: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2022-05-01-preview' + }, + getMySQL: { + reliesOnPath: 'servers.listMysql', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2017-12-01' + }, + getPostgres: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2017-12-01' + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['privateEndpoints','firewallRules', 'virtualNetworkRules', + 'networkInterfaces', 'failoverGroups', 'outboundFirewallRules'] + } + } + }, + replications: { + list: { + reliesOnPath: 'registries.list', + properties: ['id'], + url: 'https://management.azure.com{id}/replications?api-version=2019-05-01' + } + }, + configurations: { + listByServer: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}/configurations?api-version=2017-12-01' + } + }, + mediaServices: { + listContentKeyPolicies: { + reliesOnPath: 'mediaServices.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/contentKeyPolicies?api-version=2023-01-01' + }, + get: { + reliesOnPath: 'mediaServices.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2023-01-01' + } + }, + afdSecurityPolicies: { + listByProfile: { + reliesOnPath: 'profiles.list', + properties: ['id'], + url: 'https://management.azure.com/subscriptions/{id}/securityPolicies?api-version=2023-05-01' + + } + }, + connectionPolicies: { + listByServer: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/connectionPolicies?api-version=2021-05-01-preview' + + } + }, + diskEncryptionSet: { + get: { + reliesOnPath: 'disks.list', + properties: ['encryption.diskEncryptionSetId'], + url: 'https://management.azure.com/{encryption.diskEncryptionSetId}?api-version=2023-04-02', + } + }, + encryptionScopes: { + listByStorageAccounts: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/encryptionScopes?api-version=2023-01-01' + } + }, + eventHub: { + listNetworkRuleSet: { + reliesOnPath: 'eventHub.listEventHub', + properties: ['id'], + url: 'https://management.azure.com/{id}/networkRuleSets/default?api-version=2022-10-01-preview' + } + } +}; + +var tertiarycalls = { + databaseBlobAuditingPolicies: { + get: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/auditingSettings?api-version=2017-03-01-preview' + } + }, + diagnosticSettings: { + listByEndpoint: { + reliesOnPath: 'endpoints.listByProfile', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByBlobServices: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/blobServices/default/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByQueueServices: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/queueServices/default/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + + }, + listByTableServices: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/tableServices/default/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + + }, + listByAzureFrontDoor: { + reliesOnPath: 'profiles.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByApplicationGateways: { + reliesOnPath: 'applicationGateway.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByKeyVault: { + reliesOnPath: 'vaults.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByLoadBalancer: { + reliesOnPath: 'loadBalancers.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByDatabaseAccounts: { + reliesOnPath: 'databaseAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByNetworkSecurityGroup: { + reliesOnPath: 'networkSecurityGroups.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByRecoveryServiceVault: { + reliesOnPath: 'recoveryServiceVaults.listBySubscriptionId', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByVirtualNetworks: { + reliesOnPath: 'virtualNetworks.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByContainerRegistries: { + reliesOnPath: 'registries.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByMediaService: { + reliesOnPath: 'mediaServices.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByPostgresFlexibleServers: { + reliesOnPath: 'servers.listPostgresFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByPostgresServers: { + reliesOnPath: 'servers.listPostgres', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByServiceBusNamespaces: { + reliesOnPath: 'serviceBus.listNamespacesBySubscription', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByAksClusters: { + reliesOnPath: 'managedClusters.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByAppConfigurations: { + reliesOnPath: 'appConfigurations.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByRedisCache: { + reliesOnPath: 'redisCaches.listBySubscription', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByAutomationAccounts: { + reliesOnPath: 'automationAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByOpenAIAccounts: { + reliesOnPath: 'openAI.listAccounts', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByDatabase: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByAppServices: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByBastionHosts: { + reliesOnPath: 'bastionHosts.listAll', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByMachineLearningWorkspce: { + reliesOnPath: 'machineLearning.listWorkspaces', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + + }, + listByDatabricksWorkspace: { + reliesOnPath: 'databricks.listWorkspaces', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByEventGridDomains: { + reliesOnPath: 'eventGrid.listDomains', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByEventHubs:{ + reliesOnPath: 'eventHub.listEventHub', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByBatchAccounts: { + reliesOnPath: 'batchAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByMysqlFlexibleServer: { + reliesOnPath: 'servers.listMysqlFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + }, + listByWorkspaces: { + reliesOnPath: 'synapse.listWorkspaces', + properties: ['id'], + url: 'https://management.azure.com/{id}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview' + } + }, + backupShortTermRetentionPolicies: { + listByDatabase: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/backupShortTermRetentionPolicies?api-version=2020-11-01-preview' + } + }, + getCertificatePolicy: { + get: { + reliesOnPath: 'vaults.getCertificates', + properties: ['id'], + url: '{id}/policy?api-version=7.3', + vault: true + } + }, + syncGroups: { + list: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/syncGroups?api-version=2021-11-01' + } + }, + ledgerDigestUploads: { + list: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/ledgerDigestUploads?api-version=2021-11-01' + } + }, + transparentDataEncryption: { + list: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/transparentDataEncryption?api-version=2021-11-01' + } + }, + dataMaskingPolicies: { + get: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/dataMaskingPolicies/Default?api-version=2021-11-01', + } + }, + devOpsAuditingSettings: { + list: { + reliesOnPath: 'servers.listSql', + properties: ['id'], + url: 'https://management.azure.com/{id}/devOpsAuditingSettings?api-version=2021-11-01' + } + }, + patchSchedules: { + listByRedisCache: { + reliesOnPath: 'redisCaches.listBySubscription', + properties: ['id'], + url: 'https://management.azure.com/{id}/patchSchedules?api-version=2023-08-01' + } + }, + currentSensitivityLabels: { + list: { + reliesOnPath: 'databases.listByServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/currentSensitivityLabels?api-version=2021-11-01' + } + }, +}; + +var specialcalls = { + tableService: { + listTablesSegmented: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + listTablesSegmentedNew: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + getProperties: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + sendIntegration: serviceMap['Table Service'] + }, + fileService: { + listSharesSegmented: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + sendIntegration: serviceMap['File Service'] + }, + blobService: { + listContainersSegmented: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + getProperties: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + } + }, + queueService: { + listQueuesSegmented: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + listQueuesSegmentedNew: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + getProperties: { + reliesOnPath: ['storageAccounts.listKeys'], + rateLimit: 3000 + }, + sendIntegration: serviceMap['Queue Service'] + } +}; + +module.exports = { + calls: calls, + postcalls: postcalls, + tertiarycalls: tertiarycalls, + specialcalls: specialcalls, + serviceMap: serviceMap +}; diff --git a/helpers/azure/auth.js b/helpers/azure/auth.js index 64c9686ad..992794134 100644 --- a/helpers/azure/auth.js +++ b/helpers/azure/auth.js @@ -1,5 +1,6 @@ -var request = require('request'); var locations = require(__dirname + '/locations.js'); +var axios = require('axios'); +var locations_gov = require(__dirname + '/locations_gov.js'); var dontReplace = { 'type': { @@ -35,45 +36,71 @@ module.exports = { if (!azureConfig.KeyValue) return callback('No KeyValue provided'); if (!azureConfig.DirectoryID) return callback('No DirectoryID provided'); if (!azureConfig.SubscriptionID) return callback('No SubscriptionID provided'); + var { ClientSecretCredential } = require('@azure/identity'); - var msRestAzure = require('ms-rest-azure'); - - function performLogin(tokenAudience, cb) { - msRestAzure.loginWithServicePrincipalSecret( - azureConfig.ApplicationID, - azureConfig.KeyValue, - azureConfig.DirectoryID, - tokenAudience, function(err, credentials) { - if (err) return cb(err); - if (!credentials) return cb('Unable to log into Azure using provided credentials.'); - if (!credentials.environment) return cb('Unable to obtain environment from Azure application'); - if (!credentials.tokenCache || - !credentials.tokenCache._entries || - !credentials.tokenCache._entries[0] || - !credentials.tokenCache._entries[0].accessToken) { - return cb('Unable to obtain token from Azure.'); - } - - cb(null, credentials); + function getToken(credential, scopes, cb) { + credential.getToken(scopes) + .then(response => { + cb(null, response.token); + }) + .catch(error => { + cb(error); }); } - // First, login without audience - performLogin(null, function(err, credentials) { - if (err) return callback(err); - performLogin({ tokenAudience: 'graph' }, function(graphErr, graphCredentials) { - if (graphErr) return callback(graphErr); - performLogin({ tokenAudience: 'https://vault.azure.net' }, function(vaultErr, vaultCredentials) { - if (vaultErr) return callback(vaultErr); - callback(null, { - environment: credentials.environment, - token: credentials.tokenCache._entries[0].accessToken, - graphToken: graphCredentials.tokenCache._entries[0].accessToken, - vaultToken: vaultCredentials.tokenCache._entries[0].accessToken + const credential = new ClientSecretCredential( + azureConfig.DirectoryID, + azureConfig.ApplicationID, + azureConfig.KeyValue + ); + + if (azureConfig.Govcloud) { + const armScope = 'https://management.usgovcloudapi.net/.default'; + const graphScope = 'https://graph.microsoft.us/.default'; + const vaultScope = 'https://vault.azure.us/.default'; + + getToken(credential, [armScope], function(err, armToken) { + if (err) return callback(err); + getToken(credential, [graphScope], function(graphErr, graphToken) { + if (graphErr) return callback(graphErr); + getToken(credential, [vaultScope], function(vaultErr, vaultToken) { + if (vaultErr) console.log('No vault'); + callback(null, { + environment: { + name: 'AzureUSGovernment', + portalUrl: 'https://portal.azure.us' + }, + token: armToken, + graphToken: graphToken, + vaultToken: vaultToken + }); }); }); }); - }); + } else { + const armScope = 'https://management.azure.com/.default'; + const graphScope = 'https://graph.microsoft.com/.default'; + const vaultScope = 'https://vault.azure.net/.default'; + + getToken(credential, [armScope], function(err, armToken) { + if (err) return callback(err); + getToken(credential, [graphScope], function(graphErr, graphToken) { + if (graphErr) return callback(graphErr); + getToken(credential, [vaultScope], function(vaultErr, vaultToken) { + if (vaultErr) return callback(vaultErr); + callback(null, { + environment: { + name: 'AzureCloud', + portalUrl: 'https://portal.azure.com' + }, + token: armToken, + graphToken: graphToken, + vaultToken: vaultToken + }); + }); + }); + }); + } }, call: function(params, callback) { @@ -81,76 +108,192 @@ module.exports = { 'Authorization': `Bearer ${params.token}` }; + var requestData = null; if (params.body && Object.keys(params.body).length) { - headers['Content-Length'] = JSON.stringify(params.body).length; + requestData = JSON.stringify(params.body); + headers['Content-Length'] = requestData.length; headers['Content-Type'] = 'application/json;charset=UTF-8'; } - request({ + if (params.govcloud) params.url = params.url.replace('management.azure.com', 'management.usgovcloudapi.net'); + + var axiosOptions = { method: params.method ? params.method : params.post ? 'POST' : 'GET', - uri: params.url, + url: params.url, headers: headers, - body: params.body ? JSON.stringify(params.body) : null - }, function(error, response, body) { - if (response && response.statusCode === 200 && body) { + data: requestData, + // Handle response as text first, then parse manually to match original behavior + transformResponse: [(data) => data] + }; + + axios(axiosOptions) + .then(function(response) { + var body = response.data; + + if (response && [200, 202].includes(response.status) && body) { + try { + body = JSON.parse(body); + } catch (e) { + return callback(`Error parsing response from Azure API: ${e}`); + } + return callback(null, body); + } else { + handleErrorResponse(body, response, callback); + } + }) + .catch(function(error) { + if (error.response) { + // The request was made and the server responded with a status code outside 2xx + handleErrorResponse(error.response.data, error.response, callback); + } else if (error.request) { + // The request was made but no response was received + if (error.code === 'ECONNRESET') { + console.log('[ERROR] Unhandled error from Azure API: Error: ECONNRESET'); + return callback('Unknown error occurred while calling the Azure API: ECONNRESET'); + } + console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); + return callback('Unknown error occurred while calling the Azure API'); + } else { + // Something happened in setting up the request + console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); + return callback('Unknown error occurred while calling the Azure API'); + } + }); + + function handleErrorResponse(body, response, callback) { + if (body) { try { body = JSON.parse(body); } catch (e) { - return callback(`Error parsing response from Azure API: ${e}`); + return callback(`Error parsing error response from Azure API: ${e}`); } - return callback(null, body); - } else { - if (body) { + + if (typeof body == 'string') { + // Need to double parse it try { body = JSON.parse(body); } catch (e) { - return callback(`Error parsing error response from Azure API: ${e}`); + return callback(`Error parsing error response string from Azure API: ${e}`); } + } - if (typeof body == 'string') { - // Need to double parse it - try { - body = JSON.parse(body); - } catch (e) { - return callback(`Error parsing error response string from Azure API: ${e}`); - } + if (response && ((response.statusCode && response.statusCode === 429) || (response.status && response.status === 429)) && + body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + var errorMessage = `TooManyRequests: ${body.error.message}`; + return callback(errorMessage, null, response); + } else if (body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + return callback(body.error.message); + } else if (body && + body['odata.error'] && + body['odata.error'].message && + body['odata.error'].message.value && + typeof body['odata.error'].message.value == 'string') { + if (body['odata.error'].requestId) { + body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; } - - if (body && - body.error && - body.error.message && - typeof body.error.message == 'string') { - return callback(body.error.message); - } else if (body && - body['odata.error'] && - body['odata.error'].message && - body['odata.error'].message.value && - typeof body['odata.error'].message.value == 'string') { - if (body['odata.error'].requestId) { - body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; - } - return callback(body['odata.error'].message.value); - } else if (body && - body.message && - typeof body.message == 'string') { - if (body.code && typeof body.code == 'string') { - body.message = (body.code + ': ' + body.message); - } - return callback(body.message); + return callback(body['odata.error'].message.value); + } else if (body && + body.message && + typeof body.message == 'string') { + if (body.code && typeof body.code == 'string') { + body.message = (body.code + ': ' + body.message); } - - console.log(`[ERROR] Unhandled error from Azure API: Body: ${JSON.stringify(body)}`); + return callback(body.message); + } else if (body && + body.Message && + typeof body.Message == 'string') { + if (body.Code && typeof body.Code == 'string') { + body.Message = (body.Code + ': ' + body.Message); + } + return callback(body.Message); + } + if (typeof body == 'string') { + // Need to double parse it + try { + body = JSON.parse(body); + } catch (e) { + return callback(`Error parsing error response string from Azure API: ${e}`); + } + } + if (response && ((response.statusCode && response.statusCode === 429) || (response.status && response.status === 429)) && + body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + errorMessage = `TooManyRequests: ${body.error.message}`; + return callback(errorMessage, null, response); + } else if (body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + return callback(body.error.message); + } else if (body && + body['odata.error'] && + body['odata.error'].message && + body['odata.error'].message.value && + typeof body['odata.error'].message.value == 'string') { + if (body['odata.error'].requestId) { + body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; + } + return callback(body['odata.error'].message.value); + } else if (body && + body.message && + typeof body.message == 'string') { + if (body.code && typeof body.code == 'string') { + body.message = (body.code + ': ' + body.message); + } + return callback(body.message); + } else if (body && + body.Message && + typeof body.Message == 'string') { + if (body.Code && typeof body.Code == 'string') { + body.Message = (body.Code + ': ' + body.Message); + } + return callback(body.Message); } - console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); - return callback('Unknown error occurred while calling the Azure API'); + console.log(`[ERROR] Unhandled error from Azure API: Body: ${JSON.stringify(body)}`); } - }); + + console.log('[ERROR] Unhandled error from Azure API'); + return callback('Unknown error occurred while calling the Azure API'); + } }, - addLocations: function(obj, service, collection, err, data) { + addLocations: function(obj, service, collection, err, data, skip_locations) { if (!service || !locations[service]) return; locations[service].forEach(function(location) { + if (skip_locations.includes(location)) return; + collection[location.toLowerCase()] = {}; + if (err) { + collection[location.toLowerCase()].err = err; + } else if (data) { + if (data.value && Array.isArray(data.value)) { + collection[location.toLowerCase()].data = data.value.filter(function(dv) { + if (dv.location && + dv.location.toLowerCase().replace(/ /g, '') == location.toLowerCase()) { + return true; + } else if (location.toLowerCase() == 'global' && (!dv.location || obj.ignoreLocation)) { + return true; + } + return false; + }); + reduceProperties(service, collection[location.toLowerCase()].data); + } + } + }); + }, + + addGovLocations: function(obj, service, collection, err, data, skip_locations) { + if (!service || !locations_gov[service]) return; + locations_gov[service].forEach(function(location) { + if (skip_locations.includes(location)) return; collection[location.toLowerCase()] = {}; if (err) { collection[location.toLowerCase()].err = err; @@ -160,7 +303,7 @@ module.exports = { if (dv.location && dv.location.toLowerCase().replace(/ /g, '') == location.toLowerCase()) { return true; - } else if (location.toLowerCase() == 'global' && !dv.location) { + } else if (location.toLowerCase() == 'global' && (!dv.location || obj.ignoreLocation)) { return true; } return false; diff --git a/helpers/azure/functions.js b/helpers/azure/functions.js index dea05a694..1a390e58a 100644 --- a/helpers/azure/functions.js +++ b/helpers/azure/functions.js @@ -2,6 +2,19 @@ var shared = require(__dirname + '/../shared.js'); var auth = require(__dirname + '/auth.js'); var async = require('async'); +const defualtPolicyAssignments = { + adaptiveApplicationControlsMonitoringEffect: 'AuditIfNotExists', + diskEncryptionMonitoringEffect: 'AuditIfNotExists', + endpointProtectionMonitoringEffect: 'AuditIfNotExists', + identityRemoveExternalAccountWithWritePermissionsMonitoringEffect: 'AuditIfNotExists', + disableIPForwardingMonitoringEffect: 'AuditIfNotExists', + jitNetworkAccessMonitoringEffect: 'AuditIfNotExists', + nextGenerationFirewallMonitoringEffect: 'AuditIfNotExists', + identityDesignateLessThanOwnersMonitoringEffect: 'AuditIfNotExists', + systemUpdatesMonitoringEffect: 'AuditIfNotExists', + systemConfigurationsMonitoringEffect: 'AuditIfNotExists' +}; + function addResult(results, status, message, region, resource, custom) { // Override unknown results for known error messages if (status == 3 && message && typeof message == 'string') { @@ -41,8 +54,7 @@ function addResult(results, status, message, region, resource, custom) { }); } -function findOpenPorts(ngs, protocols, service, location, results) { - let found = false; +function findOpenPorts(ngs, protocols, service, location, results, checkAllPorts) { var openPrefix = ['*', '0.0.0.0', '0.0.0.0/0', '', '/0', '::/0', 'internet']; for (let sGroups of ngs) { @@ -67,6 +79,7 @@ function findOpenPorts(ngs, protocols, service, location, results) { break; } } + if (sourceFound) { for (let protocol in protocols) { let ports = protocols[protocol]; @@ -76,8 +89,10 @@ function findOpenPorts(ngs, protocols, service, location, results) { securityRule.properties['direction'] && securityRule.properties['direction'] === 'Inbound' && securityRule.properties['protocol'] && - (securityRule.properties['protocol'] === protocol || securityRule.properties['protocol'] === '*')) { + typeof securityRule.properties['protocol'] == 'string' && + (securityRule.properties['protocol'].toUpperCase() === protocol || securityRule.properties['protocol'].toUpperCase() === '*')) { if (securityRule.properties['destinationPortRange']) { + if (securityRule.properties['destinationPortRange'].toString().indexOf("-") > -1) { let portRange = securityRule.properties['destinationPortRange'].split("-"); let startPort = portRange[0]; @@ -87,20 +102,38 @@ function findOpenPorts(ngs, protocols, service, location, results) { ` port ` + ports + ` open to ` + sourceFilter; strings.push(string); if (strings.indexOf(string) === -1) strings.push(string); - found = true; } - } else if (securityRule.properties['destinationPortRange'].toString().indexOf(port) > -1) { + } else if (parseInt(securityRule.properties['destinationPortRange']) === port) { var string = `Security Rule "` + securityRule['name'] + `": ` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + (ports === '*' ? ` and all ports` : ` port ` + ports) + ` open to ` + sourceFilter; if (strings.indexOf(string) === -1) strings.push(string); - found = true; + } else if (checkAllPorts && + openPrefix.includes(securityRule.properties['destinationPortRange'])) { + var openAllstring = `Security Rule "` + securityRule['name'] + `": ` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + + (ports === '*' ? ` and all ports` : ` port ` + ports) + ` open to ` + sourceFilter; + if (strings.indexOf(openAllstring) === -1) strings.push(openAllstring); + } + } else if (securityRule.properties['destinationPortRanges']) { + if (securityRule.properties['destinationPortRanges'].indexOf(port.toString()) > -1) { + var string = `Security Rule "` + securityRule['name'] + `": ` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + + ` port ` + ports + ` open to ` + sourceFilter; + if (strings.indexOf(string) === -1) strings.push(string); + } else { + for (let portRange of securityRule.properties['destinationPortRanges']){ + if (portRange.toString().indexOf("-") > -1) { + portRange = portRange.split("-"); + let startPort = portRange[0]; + let endPort = portRange[1]; + if (parseInt(startPort) <= port && parseInt(endPort) >= port){ + var string = `Security Rule "` + securityRule['name'] + `": ` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + + ` port ` + ports + ` open to ` + sourceFilter; + strings.push(string); + if (strings.indexOf(string) === -1) strings.push(string); + break; + } + } + } } - } else if (securityRule.properties['destinationPortRanges'] && - securityRule.properties['destinationPortRanges'].toString().indexOf(port) > -1) { - var string = `Security Rule "` + securityRule['name'] + `": ` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + - ` port ` + ports + ` open to ` + sourceFilter; - if (strings.indexOf(string) === -1) strings.push(string); - found = true; } } } @@ -112,11 +145,18 @@ function findOpenPorts(ngs, protocols, service, location, results) { 'Security group:(' + sGroups.name + ') has ' + service + ': ' + strings.join(' and '), location, resource); - } - } + } else { + let strings = []; - if (!found) { - addResult(results, 0, 'No public open ports found', location); + for (const key in protocols) { + strings.push(`${key.toUpperCase()}:${protocols[key]}`); + } + if (strings.length){ + addResult(results, 0, + `Security group:( ${sGroups.name}) does not have ${strings.join(', ')} open *`, + location, resource); + } + } } return; @@ -137,9 +177,9 @@ function checkPolicyAssignment(policyAssignments, param, text, results, location } const policyAssignment = policyAssignments.data.find((policyAssignment) => { - return (policyAssignment && - policyAssignment.displayName && - policyAssignment.displayName.toLowerCase().includes('asc default')); + return (policyAssignment && + policyAssignment.displayName && + policyAssignment.displayName.toLowerCase().includes('asc default')); }); if (!policyAssignment) { @@ -151,18 +191,19 @@ function checkPolicyAssignment(policyAssignments, param, text, results, location // This check is required to handle a defect in the Azure API that causes // unmodified ASC policies to return an empty object for parameters: {} // https://knowledgebase.paloaltonetworks.com/KCSArticleDetail?id=kA10g000000PMSZCA4 - if (policyAssignment.parameters && - !Object.keys(policyAssignment.parameters).length) { - addResult(results, 0, - 'There ASC Default Policy Assignment includes all plugins', location, - policyAssignment.id); - return; + + // The api used returns empty parameters in case of all the default values, + var policyAssignmentStatus = ''; + if (policyAssignment.parameters && Object.keys(policyAssignment.parameters).length) { + policyAssignmentStatus = (policyAssignment.parameters && policyAssignment.parameters[param] && policyAssignment.parameters[param].value) || defualtPolicyAssignments[param] || ''; + } else { + policyAssignmentStatus = defualtPolicyAssignments[param] } - if (policyAssignment.parameters && - policyAssignment.parameters[param] && - policyAssignment.parameters[param].value && - (policyAssignment.parameters[param].value == 'AuditIfNotExists' || policyAssignment.parameters[param].value == 'Audit')) { + if (!policyAssignmentStatus || !policyAssignmentStatus.length) { + addResult(results, 0, + text + ' is no supported', location, policyAssignment.id); + } else if (policyAssignmentStatus == 'AuditIfNotExists' || policyAssignmentStatus == 'Audit') { addResult(results, 0, text + ' is enabled', location, policyAssignment.id); } else { @@ -171,7 +212,7 @@ function checkPolicyAssignment(policyAssignments, param, text, results, location } } -function checkLogAlerts(activityLogAlerts, conditionResource, text, results, location) { +function checkLogAlerts(activityLogAlerts, conditionResource, text, results, location, parentConditionResource) { if (!activityLogAlerts) return; if (activityLogAlerts.err || !activityLogAlerts.data) { @@ -201,25 +242,37 @@ function checkLogAlerts(activityLogAlerts, conditionResource, text, results, loc if (!allConditions || !allConditions.allOf || !allConditions.allOf.length) continue; - var conditionOperation = allConditions.allOf.filter((d) => { - return (d.equals && d.equals.toLowerCase().indexOf(conditionResource) > -1); + return (d.equals && d.equals.toLowerCase().indexOf(conditionResource) > -1 || + (parentConditionResource && d.equals && d.equals.toLowerCase().indexOf(parentConditionResource) > -1)); }); + if (conditionOperation && conditionOperation.length) { + if (conditionResource.includes('microsoft.security') && allConditions.allOf.every(condition => condition.field && condition.field == 'category' && + condition.equals && condition.equals.toLowerCase() == 'security')) { + alertCreateUpdateEnabled = (!alertCreateUpdateEnabled && activityLogAlertResource.enabled ? true : alertCreateUpdateEnabled); + break; + } + allConditions.allOf.forEach(condition => { if (condition.field && (condition.field === 'resourceType') && (condition.equals && (condition.equals.toLowerCase() === conditionResource))) { alertCreateDeleteEnabled = (!alertCreateDeleteEnabled && activityLogAlertResource.enabled ? true : alertCreateDeleteEnabled); - } else if (condition.equals.toLowerCase().indexOf(conditionResource + '/write') > -1) { + } else if (condition.equals && condition.equals.toLowerCase().indexOf(conditionResource + '/write') > -1) { alertCreateUpdateEnabled = (!alertCreateUpdateEnabled && activityLogAlertResource.enabled ? true : alertCreateUpdateEnabled); - } else - if (condition.equals.toLowerCase().indexOf(conditionResource + '/delete') > -1) { + } else if (condition.equals && condition.equals.toLowerCase().indexOf(conditionResource + '/delete') > -1) { alertDeleteEnabled = (!alertDeleteEnabled && activityLogAlertResource.enabled ? true : alertDeleteEnabled); } - }) + }); } } - if ((alertCreateDeleteEnabled && alertDeleteEnabled && alertCreateUpdateEnabled) || + if (conditionResource == 'microsoft.security/policies' && alertCreateUpdateEnabled) { + addResult(results, 0, + `Log Alert for ${text} write/update is enabled`, location, subscriptionId); + } else if (conditionResource == 'microsoft.security/policies' && !alertCreateUpdateEnabled) { + addResult(results, 2, + `Log Alert for ${text} write/update is not enabled`, location, subscriptionId); + } else if ((alertCreateDeleteEnabled && alertDeleteEnabled && alertCreateUpdateEnabled) || (alertCreateUpdateEnabled && alertDeleteEnabled) || (alertCreateDeleteEnabled && !alertDeleteEnabled && !alertCreateUpdateEnabled)) { addResult(results, 0, @@ -281,7 +334,7 @@ function checkServerConfigs(servers, cache, source, location, results, serverTyp addResult(results, 0, 'No existing ' + serverType + ' Servers found', location); return; } - + servers.data.forEach(function(server) { const configurations = shared.addSource(cache, source, ['configurations', 'listByServer', location, server.id]); @@ -303,6 +356,55 @@ function checkServerConfigs(servers, cache, source, location, results, serverTyp }); } +function checkFlexibleServerConfigs(servers, cache, source, location, results, serverType, configProperty, configName) { + if (!servers) return; + + if (servers.err || !servers.data) { + addResult(results, 3, + 'Unable to query for ' + serverType + ' Servers: ' + shared.addError(servers), location); + return; + } + + if (!servers.data.length) { + addResult(results, 0, 'No existing ' + serverType + ' Servers found', location); + return; + } + + servers.data.forEach(function(server) { + const configurations = shared.addSource(cache, source, + ['flexibleServersConfigurations', 'listByPostgresServer', location, server.id]); + + if (!configurations || configurations.err || !configurations.data) { + addResult(results, 3, + 'Unable to query for ' + serverType + ' Server configuration: ' + shared.addError(configurations), location, server.id); + } else { + var configuration = configurations.data.filter(config => { + return (config.name == configProperty && config.value.toLowerCase() == 'on'); + }); + + if (configuration && configuration.length) { + addResult(results, 0, configName + ' is enabled for the ' + serverType + ' Server configuration', location, server.id); + } else { + addResult(results, 2, configName + ' is disabled for the ' + serverType + ' Server configuration', location, server.id); + } + } + }); +} + +function checkMicrosoftDefender(pricings, serviceName, serviceDisplayName, results, location ) { + + let pricingData = pricings.data.find((pricing) => pricing.name.toLowerCase() === serviceName); + if (pricingData) { + if (pricingData.pricingTier.toLowerCase() === 'standard') { + addResult(results, 0, `Azure Defender is enabled for ${serviceDisplayName}`, location, pricingData.id); + } else { + addResult(results, 2, `Azure Defender is not enabled for ${serviceDisplayName}`, location, pricingData.id); + } + } else { + addResult(results, 2, `Azure Defender is not enabled for ${serviceDisplayName}`, location); + } +} + function processCall(config, method, body, baseUrl, resource, callback) { var fullUrl = baseUrl.replace('{resource}', resource); @@ -316,6 +418,55 @@ function processCall(config, method, body, baseUrl, resource, callback) { auth.call(params, callback); } +function remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback) { + var params = { + properties: { + securityRules: [] + }, + location: config.region + }; + // we need this to make sure that invocation of this function call is making this true. it should not be left true from + // some previous calls. + config.ruleChanged = false; + async.each(protocols, function (protocol, ocb) { + async.eachOf(ports, function (port, p, cb) { + //var protocol = protocols[p]; + remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, baseUrl, method, params,function (error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)) { + actions.push(action); + } + cb(); + }); + }, function() { + ocb(); + }); + }, function () { + if (config.ruleChanged) { + // wait for callback + remediatePlugin(config, method, params, baseUrl, resource, remediation_file, putCall, pluginName, function (err) { + if (err) errors.push(err); + + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); + settings.remediation_file = remediation_file; + callback(errors, null); + } else if (actions && actions.length) { + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; + settings.remediation_file = remediation_file; + callback(null, actions); + } else { + callback('No action taken'); + } + }); + } + else { + callback(); + } + }); +} + function remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, callback) { processCall(config, method, body, baseUrl, resource, function(err) { if (err) { @@ -329,9 +480,7 @@ function remediatePlugin(config, method, body, baseUrl, resource, remediation_fi }) } -function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, baseUrl, method, cb) { - - +function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, baseUrl, method, params, cb) { var failingPermissions = []; var passingSecurityRules = []; var sgName; @@ -339,6 +488,7 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, var sgNameArr = resource.split('/'); sgName = sgNameArr[sgNameArr.length -1]; config.region = settings.regions[resource]; + params.location = config.region; } else { return cb('No resource provided'); } @@ -369,7 +519,7 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, let portRange = portToCheck.split("-"); let startPort = portRange[0]; let endPort = portRange[1]; - if (parseInt(startPort) <= port && parseInt(endPort) >= port && protocolToCheck && protocolToCheck === protocol) { + if (parseInt(startPort) <= port && parseInt(endPort) >= port && protocolToCheck && (protocolToCheck === protocol || protocol === '*')) { if (passingPermission) { passingPermission = false; } @@ -379,8 +529,8 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, passingSecurityRules.push(rule); } } else if (portToCheck && - portToCheck.toString().indexOf(port) > -1) { - if (portToCheck <= port && portToCheck >= port && protocolToCheck && protocolToCheck === protocol) { + portToCheck.toString().indexOf(port.toString()) > -1) { + if (portToCheck <= port && portToCheck >= port && protocolToCheck && (protocolToCheck === protocol || protocol === '*' )) { if (passingPermission) { passingPermission = false; } @@ -395,6 +545,7 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, var failingRulePortIndex = {}; securityGroup.securityRules.forEach(rule => { + passingPermission = true; if (rule.properties['destinationPortRange']) { findPortRange(rule.properties['destinationPortRange'], rule.properties.protocol, rule); } else if (rule.properties['destinationPortRanges'] && rule.properties['destinationPortRanges'].length) { @@ -406,18 +557,25 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, break; } } - - if (!passingPermission) { - failingPermissions.push(rule); - } else { - passingSecurityRules.push(rule); - } - - + } + if (passingPermission) { + passingSecurityRules.push(rule); } }); - - if (!failingPermissions.length) return cb(); + if (passingSecurityRules.length){ + for ( var i in passingSecurityRules){ + var foundPass = params.properties.securityRules.findIndex(rule => rule.name === passingSecurityRules[i].name); + if (foundPass === -1){ + params.properties.securityRules.push(passingSecurityRules[i]); + } + } + } + if (!failingPermissions.length) { + return cb(); + } + else { + config.ruleChanged = true; + } // because this changed to async need a way to aggregate errors and actions without stopping the whole function var errors = []; @@ -441,7 +599,9 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, // I had to parse > stringify because it was using the final state instead of the current state of failingPermission.properties remediation_file['pre_remediate']['actions'][pluginName][resource].push(JSON.parse(JSON.stringify(failingPermission.properties))); - sourceAddressArr = failingPermission.properties.sourceAddressPrefixes; + if ( failingPermission.properties.sourceAddressPrefixes ) { + sourceAddressArr = failingPermission.properties.sourceAddressPrefixes; + } if (failingPermission.properties.sourceAddressPrefix) sourceAddressArr.push(failingPermission.properties.sourceAddressPrefix); function checkIp(inputKey, ipType, publicString) { @@ -452,17 +612,20 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, sourceAddressArr.push(settings.input[inputKey]); sourceAddressArr.splice(sourceAddressArr.indexOf(publicString), 1); - // this if the input specified already exists + // this if the input specified already exists } else if (settings.input && settings.input[inputKey] && sourceAddressArr.indexOf(settings.input[inputKey]) > -1) { ipType === 'ipv4' ? localIpExists = true : localIpV6Exists = true; sourceAddressArr.splice(sourceAddressArr.indexOf(publicString), 1); - // this is if there is no input and the failing port is in an array (destinationPortRanges). Will remove the port from the array + // this is if there is no input and the failing port is in an array (destinationPortRanges). Will remove the port from the array } else if ((!settings.input || !settings.input[inputKey]) && (failingRulePortIndex[failingPermission.name]) && !spliced) { spliced = true; - failingPermission.properties['destinationPortRanges'].splice([failingRulePortIndex[failingPermission.name]], 1) - - // this is if there is no input and the failing port is not an array + failingPermission.properties['destinationPortRanges'].splice([failingRulePortIndex[failingPermission.name]], 1); + if ( failingPermission.properties['destinationPortRanges'].length === 0 ){ + sourceAddressArr = []; + } + // this is if there is no input and the failing port is not an array,we will remove the public + // string. thus in below section nothing will be added in param and this rule will be deleted eventually } else if (!settings.input || !settings.input[inputKey]) { sourceAddressArr.splice(sourceAddressArr.indexOf(publicString), 1); } @@ -486,78 +649,87 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, return fpCb(); } - if (!openIpv6Range && !openIpRange) return fpCb(); + if (!openIpv6Range && !openIpRange) { + // here we need to clear the ip addressprefix changes we made earlier as this rule does not have any violating ips + if (sourceAddressArr && sourceAddressArr.length && sourceAddressArr.length === 1) { + failingPermission.properties.sourceAddressPrefix = sourceAddressArr.join(', '); + if (failingPermission.properties.sourceAddressPrefixes) delete failingPermission.properties.sourceAddressPrefixes; + } else if (sourceAddressArr && sourceAddressArr.length && sourceAddressArr.length > 1) { + failingPermission.properties.sourceAddressPrefixes = sourceAddressArr + if (failingPermission.properties.sourceAddressPrefix) delete failingPermission.properties.sourceAddressPrefix; + } + return fpCb(); + } if (openIpRange && openIpv6Range) return fpCb('Invalid format, only IP or IPv6 can be remediated at one time'); - var params = { - properties: { - securityRules: passingSecurityRules - }, - location: config.region - }; - + // checking if we are modifying the same rule. if so we can add the further changes in same rule,rather than adding it as separate + var found = params.properties.securityRules.findIndex(rule => rule.name === failingPermission.name); // this ensures that the failing security rule actually has an ip address. if not we do not pass it and it gets deleted if (sourceAddressArr && sourceAddressArr.length && sourceAddressArr.length === 1) { failingPermission.properties.sourceAddressPrefix = sourceAddressArr.join(', '); if (failingPermission.properties.sourceAddressPrefixes) delete failingPermission.properties.sourceAddressPrefixes; + if (found > -1){ + // remove the previous one and add the latest one with new adjustments + params.properties.securityRules.splice(found, 1); + } params.properties.securityRules.push(failingPermission); } else if (sourceAddressArr && sourceAddressArr.length && sourceAddressArr.length > 1) { failingPermission.properties.sourceAddressPrefixes = sourceAddressArr if (failingPermission.properties.sourceAddressPrefix) delete failingPermission.properties.sourceAddressPrefix; + if (found > -1){ + // remove the previous one and add the latest one with new adjustments. + params.properties.securityRules.splice(found, 1); + } params.properties.securityRules.push(failingPermission); + } else { + // we are here means there are no source ips at all,remove the rule from the params. It will be deleted. + if (found > -1){ + params.properties.securityRules.splice(found, 1); + } + } + // Remediation file savings + if (openIpv6Range && !localIpV6Exists && settings.input && settings.input[ipv6InputKey]) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': settings.input[ipv6InputKey], + 'action': 'ADDED' + }); + } else if (openIpv6Range && localIpV6Exists && settings.input && settings.input[ipv6InputKey]) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': settings.input[ipv6InputKey], + 'action': 'Already Exists' + }); } - remediatePlugin(config, method, params, baseUrl, resource, remediation_file, putCall, pluginName, function(err) { - if (err) { - errors.push(err); - return fpCb(err); - } else { - if (openIpv6Range && !localIpV6Exists && settings.input && settings.input[ipv6InputKey]) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': settings.input[ipv6InputKey], - 'action': 'ADDED' - }); - } else if (openIpv6Range && localIpV6Exists && settings.input && settings.input[ipv6InputKey]) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': settings.input[ipv6InputKey], - 'action': 'Already Exists' - }); - } - - if (openIpv6Range) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '::/0', - 'action': 'DELETED' - }); - } - - if (openIpRange && !localIpExists && settings.input && settings.input[ipv4InputKey]) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': settings.input[ipv4InputKey], - 'action': 'ADDED' - }); - - } else if (openIpRange && localIpExists && settings.input && settings.input[ipv4InputKey]){ - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': settings.input[ipv4InputKey], - 'action': 'Already Exists' - }); - } + if (openIpv6Range) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '::/0', + 'action': 'DELETED' + }); + } - if (openIpRange) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '0.0.0.0/0', - 'action': 'DELETED' - }); - } + if (openIpRange && !localIpExists && settings.input && settings.input[ipv4InputKey]) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': settings.input[ipv4InputKey], + 'action': 'ADDED' + }); - actions.push(params); - return fpCb(); - } - }); + } else if (openIpRange && localIpExists && settings.input && settings.input[ipv4InputKey]){ + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': settings.input[ipv4InputKey], + 'action': 'Already Exists' + }); + } + if (openIpRange) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '0.0.0.0/0', + 'action': 'DELETED' + }); + } + actions.push(params); + fpCb(); }, function(err) { if (errors && errors.length) { cb(errors.join(', ')); @@ -569,6 +741,120 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, }); } +function checkSecurityGroup(securityGroups) { + var openPrefix = ['*', '0.0.0.0', '0.0.0.0/0', '', '/0', '::/0', 'internet']; + + const allRules = securityGroups.flatMap(nsg => + [ + ...(nsg.securityRules ? nsg.securityRules.map(rule => ({ + ...rule, + nsgName: nsg.name + })) : []), + ...(nsg.defaultSecurityRules ? nsg.defaultSecurityRules.map(rule => ({ + ...rule, + nsgName: nsg.name + })) : []) + ] + ); + + // sorting by priority + const sortedRules = allRules.sort((a, b) => a.properties.priority - b.properties.priority); + + // The most restrictive rule takes precedence + for (const rule of sortedRules) { + if (rule.properties.direction === "Inbound" && openPrefix.includes(rule.properties.sourceAddressPrefix)) { + if (rule.properties.access === "Deny") { + return {exposed: false}; + } + if (rule.properties.access === "Allow") { + return {exposed: true, nsg: rule.nsgName}; + } + } + } + + return {exposed: true}; +} + +function checkNetworkExposure(cache, source, networkInterfaces, securityGroups, location, results, attachedResources, resource) { + let exposedPath = ''; + + const isFunctionApp = resource && resource.kind && + resource.kind.toLowerCase().includes('functionapp'); + + if (!isFunctionApp) { + if (securityGroups && securityGroups.length) { + // Scenario 1: check if security group allow all inbound traffic + let exposedSG = checkSecurityGroup(securityGroups); + if (exposedSG && exposedSG.exposed) { + if (exposedSG.nsg) { + return `nsg ${exposedSG.nsg}` + } else { + return ''; + } + } + } + } + + + const { applicationGateways, lbNames, frontDoors } = attachedResources; + + if (lbNames && lbNames.length) { + const loadBalancers = shared.addSource(cache, source, + ['loadBalancers', 'listAll', location]); + + if (loadBalancers && !loadBalancers.err && loadBalancers.data && loadBalancers.data.length) { + let resourceLBs = loadBalancers.data.filter(lb => lbNames.includes(lb.id)); + if (resourceLBs && resourceLBs.length) { + for (let lb of resourceLBs) { + let isPublic = false; + if (lb.frontendIPConfigurations && lb.frontendIPConfigurations.length) { + isPublic = lb.frontendIPConfigurations.some(ipConfig => ipConfig.properties + && ipConfig.properties.publicIPAddress && ipConfig.properties.publicIPAddress.id); + if (isPublic && ((lb.inboundNatRules && lb.inboundNatRules.length) || (lb.loadBalancingRules && lb.loadBalancingRules.length))) { + exposedPath += exposedPath.length ? `, lb ${lb.name}` : `lb ${lb.name}`; + } + } + } + } + } + } + + + if (applicationGateways && applicationGateways.length) { + for (const ag of applicationGateways) { + if (ag.frontendIPConfigurations && ag.frontendIPConfigurations.some(config => config.publicIPAddress && config.publicIPAddress.id)) { + exposedPath += exposedPath.length ? `, ag ${ag.name}` : `ag ${ag.name}`; + } + } + } + + if (frontDoors && frontDoors.length) { + for (const fd of frontDoors) { + if (!fd.associatedWafPolicies || !fd.associatedWafPolicies.length) { + exposedPath += exposedPath.length ? `, fd ${fd.name}` : `fd ${fd.name}`; + continue; + } + + // Check WAF policies + let hasSecureWaf = false; + for (const policy of fd.associatedWafPolicies) { + if (policy.policySettings && + policy.policySettings.enabledState === 'Enabled' && + policy.policySettings.mode === 'Prevention') { + hasSecureWaf = true; + break; + } + } + + if (!hasSecureWaf) { + exposedPath += exposedPath.length ? `, fd ${fd.name}` : `fd ${fd.name}`; + } + } + } + + + return exposedPath; +} module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, @@ -578,5 +864,11 @@ module.exports = { checkServerConfigs: checkServerConfigs, remediatePlugin: remediatePlugin, processCall: processCall, - remediateOpenPorts: remediateOpenPorts -}; \ No newline at end of file + remediateOpenPorts: remediateOpenPorts, + remediateOpenPortsHelper: remediateOpenPortsHelper, + checkMicrosoftDefender: checkMicrosoftDefender, + checkFlexibleServerConfigs:checkFlexibleServerConfigs, + checkNetworkExposure: checkNetworkExposure + +}; + diff --git a/helpers/azure/locations.js b/helpers/azure/locations.js index 426fe4f3e..9cd3dcff0 100644 --- a/helpers/azure/locations.js +++ b/helpers/azure/locations.js @@ -1,48 +1,60 @@ // Source: https://azure.microsoft.com/en-us/global-infrastructure/services/ +// Source: az account list-locations -o table var locations = [ - 'eastasia', - 'southeastasia', - 'centralus', - 'eastus', - 'eastus2', - 'westus', - 'northcentralus', - 'southcentralus', - 'northeurope', - 'westeurope', - 'germanycentral', - 'germanynortheast', - 'germanywestcentral', - 'germanynorth', - 'switzerlandnorth', - 'switzerlandwest', - 'norwaywest', - 'norwayeast', - 'japanwest', - 'japaneast', - 'brazilsouth', - 'australiaeast', - 'australiasoutheast', - 'southindia', - 'centralindia', - 'westindia', - 'canadacentral', - 'canadaeast', - 'uksouth', - 'ukwest', - 'westcentralus', - 'westus2', - 'koreacentral', - 'koreasouth', - 'francecentral', - 'francesouth', - 'australiacentral', - 'australiacentral2', - 'southafricanorth', - 'southafricawest', - 'uaenorth', - 'uaecentral' + 'eastus', // (US) East US + 'eastus2', // (US) East US 2 + 'southcentralus', // (US) South Central US + 'westus2', // (US) West US 2 + 'westus3', // (US) West US 3 + 'centralus', // (US) Central US + 'northcentralus', // (US) North Central US + 'westus', // (US) West US + 'eastus2euap', // (US) East US 2 EUAP + 'westcentralus', // (US) West Central US + 'centraluseuap', // (US) Central US EUAP + 'australiaeast', // (Asia Pacific) Australia East + 'southeastasia' , // (Asia Pacific) Southeast Asia + 'centralindia', // (Asia Pacific) Central India + 'eastasia', // (Asia Pacific) East Asia + 'japaneast', // (Asia Pacific) Japan East + 'jioindiawest', // (Asia Pacific) Jio India West + 'koreacentral', // (Asia Pacific) Korea Central + 'australiacentral', // (Asia Pacific) Australia Central + 'australiacentral2', // (Asia Pacific) Australia Central 2 + 'australiasoutheast', // (Asia Pacific) Australia Southeast + 'japanwest', // (Asia Pacific) Japan West + 'koreasouth', // (Asia Pacific) Korea South + 'jioindiacentral', // (Asia Pacific) Jio India Central + 'southindia', // (Asia Pacific) South India + 'westindia', // (Asia Pacific) West India + 'westeurope', // (Europe) West Europe + 'northeurope', // (Europe) North Europe + 'swedencentral', // (Europe) Sweden Central + 'uksouth', // (Europe) UK South + 'francecentral', // (Europe) France Central + 'germanywestcentral', // (Europe) Germany West Central + 'germanycentral', // (Europe) Germany Central + 'germanynortheast', // (Europe) Germany Northeast + 'germanynorth', // (Europe) Germany North + 'norwayeast', // (Europe) Norway East + 'switzerlandnorth', // (Europe) Switzerland North + 'francesouth', // (Europe) France South + 'norwaywest', // (Europe) Norway West + 'ukwest', // (Europe) UK West + 'switzerlandwest', // (Europe) Switzerland West + 'southafricawest', // (Africa) South Africa West + 'southafricanorth', // (Africa) South Africa North + 'uaenorth', // (Middle East) UAE North + 'uaecentral', // (Middle East) UAE Central + 'brazilsouth', // (South America) Brazil South + 'brazilsoutheast', // (South America) Brazil Southeast + 'canadacentral', // (Canada) Canada Central + 'canadaeast', // (Canada) Canada East + 'qatarcentral', // (Middle East) Qatar + 'polandcentral', // (Europe) Poland Central + 'italynorth', // (Europe) Italy North + 'israelcentral', // (Middle East) Israel Central ]; module.exports = { @@ -50,17 +62,24 @@ module.exports = { resources: locations, storageAccounts: locations, virtualMachines: locations, + snapshots: locations, disks: locations, activityLogAlerts: ['global'], vaults: locations, policyAssignments: locations.concat(['global']), + recoveryServiceVaults: locations, + backupPolicies: locations, + backupProtectedItems: locations, webApps: locations, + appServiceCertificates: locations, networkSecurityGroups: locations, servers: locations, logProfiles: ['global'], profiles: ['global'], managementLocks: ['global'], + blobServices: locations, networkWatchers: locations, + networkInterfaces: locations, managedClusters: locations, virtualMachineScaleSets: locations, autoProvisioningSettings: ['global'], @@ -70,12 +89,53 @@ module.exports = { loadBalancers: locations, availabilitySets: locations, virtualNetworks: locations, + virtualNetworkPeerings: locations, + virtualNetworkGateways: locations, + networkGatewayConnections: locations, + natGateways: locations, users: ['global'], registries: locations, + redisCaches: locations, pricings: ['global'], roleDefinitions: ['global'], + aad: ['global'], + groups: ['global'], + servicePrincipals: ['global'], autoscaleSettings: locations, resourceGroups: locations, policyDefinitions: locations, - diagnosticSettingsOperations: ['global'] + diagnosticSettingsOperations: ['global'], + databaseAccounts: locations, + securityCenter: ['global'], + advisor: ['global'], + publicIPAddresses: locations, + privateDnsZones: ['global'], + privateEndpoints: locations, + securityContactv2: ['global'], + images: locations, + vmScaleSet: locations, + applicationGateway: locations, + wafPolicies: locations, + routeTables: locations, + bastionHosts: locations, + applications: ['global'], + eventGrid: locations, + eventHub: locations, + mediaServices: locations, + serviceBus: locations, + classicFrontDoors: ['global'], + afdWafPolicies: ['global'], + appConfigurations: locations, + automationAccounts: locations, + openAI: locations, + logAnalytics: locations, + publicIpAddresses: locations, + computeGalleries: locations, + databricks: locations, + containerApps: locations, + batchAccounts: locations, + machineLearning: locations, + apiManagementService: locations, + synapse: locations, + managedInstances: locations }; diff --git a/helpers/azure/locations_gov.js b/helpers/azure/locations_gov.js index 359ce8450..2f71aa31d 100644 --- a/helpers/azure/locations_gov.js +++ b/helpers/azure/locations_gov.js @@ -10,5 +10,81 @@ var locations = [ ]; module.exports = { - all: locations + all: locations, + resources: locations, + storageAccounts: ['global'], + virtualMachines: locations, + snapshots: locations, + disks: locations, + activityLogAlerts: ['global'], + vaults: locations, + policyAssignments: locations.concat(['global']), + recoveryServiceVaults: locations, + backupPolicies: locations, + backupProtectedItems: locations, + webApps: locations, + appServiceCertificates: locations, + networkSecurityGroups: locations, + servers: locations, + logProfiles: ['global'], + profiles: ['global'], + managementLocks: ['global'], + blobServices: locations, + networkWatchers: locations, + networkInterfaces: locations, + managedClusters: locations, + virtualMachineScaleSets: locations, + autoProvisioningSettings: ['global'], + securityContacts: ['global'], + usages: ['global'], + subscriptions: ['global'], + loadBalancers: locations, + availabilitySets: locations, + virtualNetworks: locations, + virtualNetworkPeerings: locations, + virtualNetworkGateways: locations, + networkGatewayConnections: locations, + natGateways: locations, + users: ['global'], + registries: locations, + redisCaches: locations, + pricings: ['global'], + roleDefinitions: ['global'], + aad: ['global'], + groups: ['global'], + servicePrincipals: ['global'], + autoscaleSettings: locations, + resourceGroups: locations, + policyDefinitions: locations, + diagnosticSettingsOperations: ['global'], + databaseAccounts: locations, + securityCenter: ['global'], + advisor: ['global'], + publicIPAddresses: locations, + privateDnsZones: ['global'], + privateEndpoints: locations, + securityContactv2: ['global'], + images: locations, + vmScaleSet: locations, + applicationGateway: locations, + wafPolicies: locations, + routeTables: locations, + bastionHosts: locations, + applications: ['global'], + eventGrid: locations, + eventHub: locations, + mediaServices: locations, + serviceBus: locations, + classicFrontDoors: ['global'], + afdWafPolicies: ['global'], + appConfigurations: locations, + automationAccounts: locations, + openAI: locations, + logAnalytics: locations, + publicIpAddresses: locations, + computeGalleries: locations, + databricks: locations, + containerApps: locations, + apiManagementService: locations, + synapse: locations }; diff --git a/helpers/azure/resources.js b/helpers/azure/resources.js new file mode 100644 index 000000000..7c05970ef --- /dev/null +++ b/helpers/azure/resources.js @@ -0,0 +1,313 @@ +// This file contains a list of ARN paths for each API call type +// that are used to extract ARNs for resources + +module.exports = { + activityLogAlerts: { + listBySubscriptionId: 'id' + }, + advancedThreatProtectionSettings: { + listByServer: 'id' + }, + advancedThreatProtection: { + get: 'id' + }, + advisor: { + recommendationsList: 'id' + }, + autoscaleSettings: { + listBySubscription: 'id' + }, + autoProvisioningSettings: { + list: 'id' + }, + availabilitySets: { + listByResourceGroup: 'id' + }, + appServiceCertificates: { + list: 'id' + }, + backupPolicies: { + listByVault: 'id' + }, + backupProtectedItems: { + listByVault: 'id' + }, + backupShortTermRetentionPolicies: { + listByDatabase: 'id' + }, + blobServices: { + list: 'id', + getServiceProperties: 'id' + }, + blobContainers: { + list: 'id' + }, + configurations: { + listByServer: 'id' + }, + databases: { + listByServer: 'id' + }, + databaseBlobAuditingPolicies:{ + get: 'id' + }, + databaseAccounts: { + list: 'id', + }, + diagnosticSettings: { + listByKeyVault: '', + listByEndpoint: 'id', + listByLoadBalancer: 'id', + listByNetworkSecurityGroup: 'id', + listByServiceBusNamespaces: 'id', + listByPostgresFlexibleServers: 'id', + listByPostgresServers: 'id', + listByDatabase: 'id', + listByApplicationGateways: 'id', + listByOpenAIAccounts: 'id' + }, + diagnosticSettingsOperations: { + list: 'id' + }, + disks: { + list: 'id' + }, + encryptionProtectors: { + listByServer: 'id' + }, + endpoints: { + listByProfile: 'id' + }, + failoverGroups: { + listByServer: 'id' + }, + fileShares: { + list: 'id' + }, + firewallRules: { + listByServer: 'id', + listByFlexibleServerPostgres: 'id' + }, + fileService: { + listSharesSegmented: '', + getShareAcl: '' + }, + flowLogs: { + list: 'id' + }, + loadBalancers: { + listAll: 'id' + }, + logProfiles: { + list: 'id' + }, + managementLocks: { + listAtSubscriptionLevel: '' + }, + managedClusters: { + list: 'id', + getUpgradeProfile: 'id' + }, + natGateways: { + listBySubscription: 'id' + }, + networkInterfaces: { + listAll: 'id' + }, + networkSecurityGroups: { + listAll: 'id', + }, + networkGatewayConnections: { + listByResourceGroup: 'id' + }, + networkWatchers: { + listAll: 'id' + }, + profiles: { + list: 'id' + }, + policyAssignments: { + list: 'id' + }, + pricings: { + list: 'id' + }, + queueService: { + listQueuesSegmented: '', + getQueueAcl: '' + }, + registries: { + list: 'id' + }, + redisCaches: { + listBySubscription: 'id' + }, + resources: { + list: 'id' + }, + resourceGroups: { + list: 'id' + }, + recoveryServiceVaults: { + listBySubscriptionId: 'id' + }, + roleDefinitions: { + list: 'id' + }, + aad: { + listRoleAssignments: 'id', + listDenyAssignments: 'id' + }, + groups: { + list: 'id' + }, + servicePrincipals: { + list: 'id' + }, + securityContacts: { + list: 'id' + }, + securityCenter: { + list: 'id' + }, + servers: { + listSql: 'id', + listPostgres: 'id', + listMysql: 'id', + listPostgresFlexibleServer: 'id', + }, + serverAdministrators: { + list: 'id' + }, + serverSecurityAlertPolicies: { + listByServer: 'id' + }, + serverBlobAuditingPolicies: { + get: 'id' + }, + serverAutomaticTuning: { + get: 'id' + }, + serverAzureADAdministrators: { + listByServer: 'id' + }, + snapshots: { + list: 'id' + }, + storageAccounts: { + list: 'id', + listKeys: '' + }, + subscriptions: { + listLocations: 'id' + }, + tableService: { + listTablesSegmented: '', + getTableAcl: '' + }, + users: { + list: 'mail' + }, + usages: { + list: '' + }, + vaults: { + list: 'id', + getKeys: '', + getSecrets: '', + getCertificates: 'id', + getCertificatePolicy: 'id' + }, + virtualNetworks: { + listAll: 'id' + }, + virtualNetworkGateways: { + listByResourceGroup: 'id' + }, + virtualNetworkPeerings: { + list: 'id' + }, + virtualMachines: { + listAll: 'id', + get: 'id' + }, + virtualMachineExtensions: { + list: 'id' + }, + virtualMachineScaleSets: { + listAll: 'id' + }, + virtualMachineScaleSetVMs: { + list: 'id' + }, + vulnerabilityAssessments: { + listByServer: 'id' + }, + webApps: { + list: 'id', + listConfigurations: 'id', + listAppSettings: 'id', + getAuthSettings: '', + getBackupConfiguration: 'id', + }, + syncGroups: { + list: 'id' + }, + ledgerDigestUploads: { + list: 'id' + }, + transparentDataEncryption: { + list: 'id' + }, + dataMaskingPolicies: { + list: 'id' + }, + devOpsAuditingSettings:{ + list: 'id' + }, + appConfigurations: { + list: 'id' + }, + serviceBus:{ + listNamespacesBySubscription: 'id' + }, + flexibleServersConfigurations:{ + listByPostgresServer: 'id' + }, + afdWafPolicies: { + listAll: 'id' + }, + classicFrontDoors: { + list: 'id' + }, + afdSecurityPolicies: { + listByProfile: 'id' + }, + automationAccounts:{ + list: 'id' + }, + openAI: { + listAccounts: 'id' + }, + currentSensitivityLabels: { + list: 'id' + }, + connectionPolicies:{ + listByServer:'id' + }, + publicIpAddresses: { + list: 'id' + }, + databricks: { + listWorkspaces: 'id' + }, + containerApps: { + list: 'id' + }, + machineLearning: { + listWorkspaces: 'id' + }, + apiManagementService: { + list: 'id' + } +}; diff --git a/helpers/google/api.js b/helpers/google/api.js new file mode 100644 index 000000000..31c467b61 --- /dev/null +++ b/helpers/google/api.js @@ -0,0 +1,1179 @@ +/* + enabled: send integration is enable or not + isSingleSource: whether resource is single source or not + +----------Bridge Side Data---------- + BridgeServiceName: it should be the api service name which we are storing in json file in s3 collection bucket. + BridgeCall: it should be the api call which we are storing in json file in s3 collection bucket. + BridgePluginCategoryName: it should be equivalent to Plugin Category Name. + BridgeProvider: it should be the cloud provider + Eg. 'aws', 'Azure', 'Google' + + BridgeArnIdentifier: no need to pass. + + BridgeIdTemplate: this should be the template for creating the resource id. + supported values: name, region, cloudAccount, project, id + Eg. 'projects/{cloudAccount}/regions/{region}/clusters/{name}' + + + BridgeResourceType: this should be type of the resource, fetch it from the id. + Eg. 'servers' + + BridgeResourceNameIdentifier: it should be the key of resource name/id data which we are storing in json file in s3 collection bucket. + Eg. 'Name/name' or 'Id/id'. + + Note: if there is no name then we have to pass the id. + + BridgeExecutionService: it should be equivalent to service name which we are sending from executor in payload data. + BridgeCollectionService: it should be equivalent to service name which we are sending from collector in payload data. + DataIdentifier: it should be the parent key field of data which we want to collect in json file in s3 collection bucket. + +----------Processor Side Data---------- +These fields should be according to the user and product manager, what they want to show in Inventory UI. + InvAsset: 'Pub/Sub' + InvService: 'Pub/Sub' + InvResourceCategory: 'cloud_resources' + Note: For specific category add the category name otherwise it should be 'cloud_resource' + + InvResourceType: 'Pub/Sub' + If you need that your resource type to be two words with capital letter only on first letter of the word (for example: Key Vaults), you should supply the resource type with a space delimiter. + If you need that your resource type to be two words and the the first word should be in capital letters (for example: CDN Profiles), you should supply the resource type with snake case delimiter + + + Take the reference from the below map +*/ + +// Note: In Below service map add only single source resources. +// and service name should be plugin category. + +var serviceMap = { + 'Pub/Sub': + { + enabled: true, isSingleSource: true, InvAsset: 'Pub/Sub', InvService: 'Pub/Sub', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Pub/Sub', BridgeServiceName: 'topics', + BridgePluginCategoryName: 'gcp-Pub/Sub', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'topics', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Pub/Sub', + BridgeCollectionService: 'gcp-topics', DataIdentifier: 'data', + }, + 'DNS': + { + enabled: true, isSingleSource: true, InvAsset: 'Managed Zone', InvService: 'DNS', + InvResourceCategory: 'cloud_resources', InvResourceType: 'DNS', BridgeServiceName: 'managedzones', + BridgePluginCategoryName: 'gcp-DNS', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/zones/{name}', + BridgeResourceType: 'zones', BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-DNS', + BridgeCollectionService: 'gcp-managedZones', DataIdentifier: 'data', + }, + 'VPC Network': + { + enabled: true, isSingleSource: true, InvAsset: 'VPC Network', InvService: 'VPC Network', + InvResourceCategory: 'cloud_resources', InvResourceType: 'VPC Network', BridgeServiceName: 'networks', + BridgePluginCategoryName: 'gcp-VPC Network', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'networks', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-VPC Network', + BridgeCollectionService: 'gcp-networks', DataIdentifier: 'data', + }, + 'Cryptographic Keys': + { + enabled: true, isSingleSource: true, InvAsset: 'Cryptographic Key', InvService: 'Cryptographic Keys', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Cryptographic Key', BridgeServiceName: 'cryptokeys', + BridgePluginCategoryName: 'gcp-Cryptographic Keys', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'cryptoKeys', + BridgeResourceNameIdentifier: '', BridgeExecutionService: 'gcp-Cryptographic Keys', + BridgeCollectionService: 'gcp-cryptoKeys', DataIdentifier: 'data', + }, + 'CLB': + { + enabled: true, isSingleSource: true, InvAsset: 'Url Map', InvService: 'CLB', + InvResourceCategory: 'cloud_resources', InvResourceType: 'CLB', BridgeServiceName: 'urlmaps', + BridgePluginCategoryName: 'gcp-CLB', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'urlMaps', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-CLB', + BridgeCollectionService: 'gcp-urlMaps', DataIdentifier: 'data', + }, + 'Deployment Manager': + { + enabled: true, isSingleSource: true, InvAsset: 'Deployment', InvService: 'Deployment Manager', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Deployment Manager', BridgeServiceName: 'deployments', + BridgePluginCategoryName: 'gcp-Deployment Manager', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'deployments', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Deployment Manager', + BridgeCollectionService: 'gcp-deployments', DataIdentifier: 'data', + }, + 'Logging': + { + enabled: true, isSingleSource: true, InvAsset: 'Alert Policy', InvService: 'Logging', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Logging', BridgeServiceName: 'alertpolicies', + BridgePluginCategoryName: 'gcp-Logging', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'alertPolicies', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Logging', + BridgeCollectionService: 'gcp-alertPolicies', DataIdentifier: 'data', + }, + 'Dataproc': + { + enabled: true, isSingleSource: true, InvAsset: 'Cluster', InvService: 'Dataproc', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Dataproc', BridgeServiceName: 'dataproc', + BridgePluginCategoryName: 'gcp-Dataproc', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/regions/{region}/clusters/{name}', + BridgeResourceType: 'clusters', BridgeResourceNameIdentifier: 'clusterName', BridgeExecutionService: 'gcp-Dataproc', + BridgeCollectionService: 'gcp-dataproc', DataIdentifier: 'data', + }, + 'Dataflow': + { + enabled: true, isSingleSource: true, InvAsset: 'job', InvService: 'Dataflow', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Dataflow Job', BridgeServiceName: 'jobs', + BridgePluginCategoryName: 'gcp-Dataflow', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/jobs/{id}', BridgeResourceType: 'jobs', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Dataflow', + BridgeCollectionService: 'gcp-jobs', DataIdentifier: 'data', + }, + 'API': + { + enabled: true, isSingleSource: true, InvAsset: 'API', InvService: 'API', + InvResourceCategory: 'cloud_resources', InvResourceType: 'API', BridgeServiceName: 'apikeys', + BridgePluginCategoryName: 'gcp-API', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'keys', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-API', + BridgeCollectionService: 'gcp-apiKeys', DataIdentifier: 'data', + }, + 'BigQuery': + { + enabled: true, isSingleSource: true, InvAsset: 'dataset', InvService: 'BigQuery', + InvResourceCategory: 'database', InvResourceType: 'BigQuery', BridgeServiceName: 'datasets', + BridgePluginCategoryName: 'gcp-BigQuery', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/datasets/{name}', BridgeResourceType: 'datasets', + BridgeResourceNameIdentifier: 'datasetId', BridgeExecutionService: 'gcp-BigQuery', + BridgeCollectionService: 'gcp-datasets', DataIdentifier: 'data', + }, + 'BigTable': + { + enabled: true, isSingleSource: true, InvAsset: 'Instance', InvService: 'BigTable', + InvResourceCategory: 'database', InvResourceType: 'BigTable', BridgeServiceName: 'bigtable', + BridgePluginCategoryName: 'gcp-BigTable', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'instances', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-BigTable', + BridgeCollectionService: 'gcp-bigtable', DataIdentifier: 'data', + }, + 'Spanner': + { + enabled: true, isSingleSource: true, InvAsset: 'Instance', InvService: 'Spanner', + InvResourceCategory: 'database', InvResourceType: 'Spanner', BridgeServiceName: 'spanner', + BridgePluginCategoryName: 'gcp-Spanner', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'instances', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Spanner', + BridgeCollectionService: 'gcp-spanner', DataIdentifier: 'data', + }, + 'SQL': + { + enabled: true, isSingleSource: true, InvAsset: 'sql', InvService: 'sql', + InvResourceCategory: 'database', InvResourceType: 'sql', BridgeServiceName: 'sql', + BridgePluginCategoryName: 'gcp-SQL', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'instances', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-SQL', + BridgeCollectionService: 'gcp-sql', DataIdentifier: 'data', + }, + 'Storage': + { + enabled: true, isSingleSource: true, InvAsset: 'storage', InvService: 'storage', + InvResourceCategory: 'storage', InvResourceType: 'bucket', BridgeServiceName: 'buckets', + BridgePluginCategoryName: 'gcp-Storage', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'b', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Storage', + BridgeCollectionService: 'gcp-buckets', DataIdentifier: 'data', + }, + 'AI & ML': + { + enabled: true, isSingleSource: true, InvAsset: 'models', InvService: 'vertexAI', + InvResourceCategory: 'ai&ml', InvResourceType: 'VertexAI models', BridgeServiceName: 'vertexAI', + BridgePluginCategoryName: 'gcp-AI & ML', BridgeProvider: 'Google', BridgeCall: 'listModels', + BridgeArnIdentifier: '', BridgeIdTemplate: '{name}', BridgeResourceType: 'models', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-AI & ML', + BridgeCollectionService: 'gcp-vertexai', DataIdentifier: 'data', + }, + 'CloudBuild': + { + enabled: true, isSingleSource: true, InvAsset: 'trigger', InvService: 'CloudBuild', + InvResourceCategory: 'cloud_resources', InvResourceType: 'trigger', BridgeServiceName: 'cloudbuild', + BridgePluginCategoryName: 'gcp-CloudBuild', BridgeProvider: 'Google', BridgeCall: 'triggers', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/locations/{region}/triggers/{name}', BridgeResourceType: 'triggers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-CloudBuild', + BridgeCollectionService: 'gcp-cloudbuild', DataIdentifier: 'data', + }, + 'Cloud Composer': + { + enabled: true, isSingleSource: true, InvAsset: 'environment', InvService: 'Cloud Composer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'composer_environment', BridgeServiceName: 'composer', + BridgePluginCategoryName: 'gcp-Cloud Composer', BridgeProvider: 'Google', BridgeCall: 'environments', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'environments', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Cloud Composer', + BridgeCollectionService: 'gcp-composer', DataIdentifier: 'data', + }, + 'Resource Manager': + { + enabled: true, isSingleSource: true, InvAsset: 'organization', InvService: 'Resource Manager', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Organization', BridgeServiceName: 'organizations', + BridgePluginCategoryName: 'gcp-Resource Manager', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'organizations', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Resource Manager', + BridgeCollectionService: 'gcp-organizations', DataIdentifier: 'data', + } +}; +var calls = { + disks: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/disks', + location: 'zone', + pagination: true + }, + aggregatedList: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/aggregated/disks', + location: null, + pagination: true + } + }, + composer: { + environments: { + url: 'https://composer.googleapis.com/v1/projects/{projectId}/locations/{locationId}/environments', + location: 'region', + pagination: true, + paginationKey: 'pageToken', + dataFilterKey: 'environments' + }, + sendIntegration: serviceMap['Cloud Composer'] + }, + repositories: { + list: { + url: 'https://artifactregistry.googleapis.com/v1/projects/{projectId}/locations/{locationId}/repositories', + location: 'region', + pagination: true + }, + sendIntegration: { + enabled: true + } + }, + apiGateways: { + list: { + url: 'https://apigateway.googleapis.com/v1/projects/{projectId}/locations/{locationId}/gateways', + location: 'region', + dataKey: 'gateways', + isDataArray: true + } + }, + api: { + list: { + url: 'https://apigateway.googleapis.com/v1/projects/{projectId}/locations/{locationId}/apis', + location: 'region', + dataKey: 'apis', + isDataArray: true + } + }, + images: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/images', + location: null, + pagination: true, + ignoreMiscData: true + } + }, + snapshots: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/snapshots', + location: null, + pagination: true + } + }, + securityPolicies: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/securityPolicies', + location: null, + pagination: true + } + }, + resourcePolicies: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/regions/{locationId}/resourcePolicies', + location: 'region', + pagination: true + } + }, + firewalls: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/firewalls', + location: null, + pagination: true + } + }, + compute: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/instances', + location: 'zone', + ignoreMiscData: true, + pagination: true + }, + aggregatedList: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/aggregated/instances', + location: null, + pagination: true + }, + sendIntegration: { + enabled: true, + integrationReliesOn: { + serviceName: ['resourceRecordSets', 'firewalls', 'projects'] + } + }, + }, + sql: { + list: { + url: 'https://sqladmin.googleapis.com/sql/v1beta4/projects/{projectId}/instances', + location: null, + pagination: true + }, + sendIntegration: serviceMap['SQL'] + }, + spanner: { + list: { + url: 'https://spanner.googleapis.com/v1/projects/{projectId}/instances', + location: null, + pagination: true, + paginationKey: 'pageSize', + dataFilterKey: 'instances' + }, + sendIntegration: serviceMap['Spanner'] + }, + bigtable: { + list: { + url: 'https://bigtableadmin.googleapis.com/v2/projects/{projectId}/instances', + location: null, + pagination: true, + paginationKey: 'pageToken', + dataFilterKey: 'instances' + }, + sendIntegration: serviceMap['BigTable'] + }, + instanceTemplates: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/instanceTemplates', + location: null, + pagination: true + } + }, + instanceGroups: { + aggregatedList: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/aggregated/instanceGroups', + location: null, + pagination: true + }, + list : { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/instanceGroups', + location: 'zone', + pagination: true, + ignoreMiscData: true + + } + }, + instanceGroupManagers: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/instanceGroupManagers', + location: 'zone', + pagination: true + } + }, + functions: { + list : { + url: 'https://cloudfunctions.googleapis.com/v1/projects/{projectId}/locations/{locationId}/functions', + location: 'region', + paginationKey: 'pageSize', + pagination: true + }, + sendIntegration: { + enabled: true + } + }, + functionsv2: { + list: { + url: 'https://cloudfunctions.googleapis.com/v2/projects/{projectId}/locations/{locationId}/functions', + location: 'region', + paginationKey: 'pageSize', + pagination: true, + dataFilterKey: 'functions' + }, + sendIntegration: { + enabled: true + } + }, + keyRings: { + list: { + url: 'https://cloudkms.googleapis.com/v1/projects/{projectId}/locations/{locationId}/keyRings', + location: 'region', + paginationKey: 'pageSize', + pagination: true + }, + }, + networks: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/networks', + location: null, + pagination: true + }, + sendIntegration: serviceMap['VPC Network'] + }, + backendServices: { + list: { + globalURL: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/backendServices', + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/backendServices', + location: 'region', + pagination: true, + ignoreMiscData: true + }, + }, + + forwardingRules: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/forwardingRules', + globalURL: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/forwardingRules', + location: 'region', + pagination: true, + ignoreMiscData: true + }, + }, + + healthChecks: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/healthChecks', + location: null, + pagination: true + } + }, + buckets: { + list: { + url: 'https://storage.googleapis.com/storage/v1/b?project={projectId}', + location: null, + pagination: true + }, + sendIntegration: serviceMap['Storage'] + }, + targetHttpProxies: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/targetHttpProxies', + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/targetHttpProxies', + location: 'region', + pagination: true, + ignoreMiscData: true, + } + }, + targetHttpsProxies: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/targetHttpsProxies', + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/targetHttpsProxies', + location: 'region', + pagination: true, + ignoreMiscData: true + } + }, + autoscalers: { + aggregatedList: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/aggregated/autoscalers', + location: null, + pagination: true + } + }, + subnetworks: { + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/regions/{locationId}/subnetworks', + location: 'region', + pagination: true + } + }, + projects: { + list:{ + url: 'https://cloudresourcemanager.googleapis.com/v1/projects', + pagination: true, + }, + get: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}', + pagination: false + }, + getIamPolicy: { + url: 'https://cloudresourcemanager.googleapis.com/v3/projects/{projectId}:getIamPolicy', + location: null, + method: 'POST', + pagination: false, + body: {options:{requestedPolicyVersion: 3}} + }, + getWithNumber: { + url: 'https://cloudresourcemanager.googleapis.com/v1/projects/{projectId}' + } + }, + kubernetes: { + list: { + url: 'https://container.googleapis.com/v1/projects/{projectId}/locations/-/clusters', + location: null, + pagination: false + }, + sendIntegration: { + enabled: true + } + }, + dataproc: { + list: { + url: 'https://dataproc.googleapis.com/v1/projects/{projectId}/regions/{locationId}/clusters', + location: 'region', + pagination: true + }, + sendIntegration: serviceMap['Dataproc'] + }, + cloudbuild: { + triggers: { + url: 'https://cloudbuild.clients6.google.com/v1/projects/{projectId}/locations/{locationId}/triggers', + location: 'region', + dataFilterKey: 'triggers' + }, + sendIntegration: serviceMap['CloudBuild'] + }, + managedZones: { + list: { + url: 'https://dns.googleapis.com/dns/v1/projects/{projectId}/managedZones', + location: null, + pagination: true + }, + sendIntegration: serviceMap['DNS'] + }, + metrics: { + list: { + url: 'https://logging.googleapis.com/v2/projects/{projectId}/metrics', + location: null, + pagination: true, + paginationKey: 'pageSize' + } + }, + alertPolicies: { + list: { + url: 'https://monitoring.googleapis.com/v3/projects/{projectId}/alertPolicies', + location: null, + pagination: true, + paginationKey: 'pageSize' + }, + sendIntegration: serviceMap['Logging'] + }, + serviceAccounts: { + list: { + url: 'https://iam.googleapis.com/v1/projects/{projectId}/serviceAccounts', + location: null, + pagination: true, + paginationKey: 'pageSize' + } + }, + sinks: { + list: { + url: 'https://logging.googleapis.com/v2/projects/{projectId}/sinks', + location: null, + pagination: true, + paginationKey: 'pageSize' + } + }, + datasets: { + list: { + url: 'https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets', + location: null, + pagination: true, + reqParams: 'maxResults=1000' + }, + sendIntegration: serviceMap['BigQuery'] + }, + policies: { + list: { + url: 'https://dns.googleapis.com/dns/v1/projects/{projectId}/policies', + location: null, + pagination: true + }, + projectDenyPolicies: { //GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Fprojects%252Fprojectid/denypolicies + url: 'https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Fprojects%252F{projectId}/denypolicies', + pagination: true + }, + }, + topics: { + list: { + url: 'https://pubsub.googleapis.com/v1/projects/{projectId}/topics', + location: null, + pagination: true, + paginationKey: 'pageSize' + }, + sendIntegration: serviceMap['Pub/Sub'] + }, + subscriptions: { + list: { + url: 'https://pubsub.googleapis.com/v1/projects/{projectId}/subscriptions', + location: null, + pagination: true, + paginationKey: 'pageSize' + } + }, + jobs: { + list: { //https://dataflow.googleapis.com/v1b3/projects/{projectId}/jobs:list + url: 'https://dataflow.googleapis.com/v1b3/projects/{projectId}/locations/{locationId}/jobs', + location: 'region', + pagination: true, + paginationKey: 'pageSize' + }, + sendIntegration: serviceMap['Dataflow'] + }, + deployments: { // https://www.googleapis.com/deploymentmanager/v2/projects/project/global/deployments + list: { + url: 'https://www.googleapis.com/deploymentmanager/v2/projects/{projectId}/global/deployments', + location: null, + pagination: true, + }, + sendIntegration: serviceMap['Deployment Manager'] + }, + organizations:{ // https://cloudresourcemanager.googleapis.com/v1beta1/organizations + list: { + url: 'https://cloudresourcemanager.googleapis.com/v1beta1/organizations', + pagination: false + } + }, + urlMaps: { // https://compute.googleapis.com/compute/v1/projects/{projectid}/global/urlMaps + list: { + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/urlMaps', + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/urlMaps', + location: 'region', + pagination: true, + nameRequired: true, + ignoreMiscData: true + }, + sendIntegration: serviceMap['CLB'] + }, + apiKeys: { + list: { + url: 'https://apikeys.googleapis.com/v2/projects/{projectId}/locations/global/keys', + location: null + }, + sendIntegration: serviceMap['API'] + }, + resourceRecordSets: { + list: { + url: 'https://dns.googleapis.com/dns/v1/projects/{projectId}/managedZones/{id}/rrsets', + reliesOnService: ['managedZones'], + reliesOnCall: ['list'], + properties: ['id'], + pagination: true + } + }, + accessApproval: { + settings: { + url: 'https://accessapproval.googleapis.com/v1/projects/{projectId}/accessApprovalSettings', + pagination: true, + paginationKey: 'pageSize' + } + }, + networkRoutes:{ + list: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/routes', + location: null, + pagination: true + } + }, + vertexAI: { + listDatasets: { + url: 'https://{locationId}-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{locationId}/datasets', + location: 'region', + dataKey: 'datasets', + isDataArray: true + }, + listModels: { + url: 'https://{locationId}-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{locationId}/models', + location: 'region', + dataKey: 'models' + }, + sendIntegration: serviceMap['AI & ML'] + }, + + roles: { + list: { + url: 'https://iam.googleapis.com/v1/projects/{projectId}/roles', + location: null, + pagination: true, + paginationKey: 'nextPageToken' + }, + predefined_list: { + url: 'https://iam.googleapis.com/v1/roles', + location: null, + pagination: true, + paginationKey: 'nextPageToken' + } + }, +}; + +var postcalls = { + roles: { + get: { + url: 'https://iam.googleapis.com/v1/{name}', + location: null, + reliesOnService: ['roles'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + }, + predefined_get: { + url: 'https://iam.googleapis.com/v1/{name}', + location: null, + reliesOnService: ['roles'], + reliesOnCall: ['predefined_list'], + properties: ['name'], + pagination: false + }, + + }, + compute: { + getIamPolicy: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/instances/{id}/getIamPolicy', + location: 'zone', + reliesOnService: ['compute'], + reliesOnCall: ['list'], + properties: ['id'], + pagination: false + } + }, + cryptoKeys: { + list: { + url: 'https://cloudkms.googleapis.com/v1/{name}/cryptoKeys', + location: 'region', + reliesOnService: ['keyRings'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: true, + paginationKey: 'pageSize' + }, + sendIntegration: serviceMap['Cryptographic Keys'] + }, + buckets: { + getIamPolicy: { + url: 'https://storage.googleapis.com/storage/v1/b/{name}/iam', + location: null, + reliesOnService: ['buckets'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + }, + sendIntegration: { + integrationReliesOn: { + serviceName: ['buckets'] + } + }, + }, + topics: { + getIamPolicy: { + url: 'https://pubsub.googleapis.com/v1/{name}:getIamPolicy', + location: null, + reliesOnService: ['topics'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + }, + }, + keys: { + list: { + url: 'https://iam.googleapis.com/v1/{name}/keys', + reliesOnService: ['serviceAccounts'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + } + }, + users: { + list: { + url: 'https://sqladmin.googleapis.com/sql/v1beta4/projects/{projectId}/instances/{name}/users', + location: null, + reliesOnService: ['sql'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: true //needs to be verified with multiple users + }, + sendIntegration: { + integrationReliesOn: { + serviceName: ['sql'] + } + }, + }, + backupRuns: { + list: { + url: 'https://sqladmin.googleapis.com/sql/v1beta4/projects/{projectId}/instances/{name}/backupRuns', + location: null, + reliesOnService: ['sql'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: true + }, + sendIntegration: { + integrationReliesOn: { + serviceName: ['sql'] + } + }, + }, + apiConfigs: { + list: { + url: 'https://apigateway.googleapis.com/v1/{name}/configs', + location: 'region', + reliesOnService: ['api'], + reliesOnCall: ['list'], + properties: ['name'], + } + }, + apiGateways: { + getIamPolicy: { + url: 'https://apigateway.googleapis.com/v1/{name}:getIamPolicy', + location: 'region', + reliesOnService: ['apiGateways'], + reliesOnCall: ['list'], + properties: ['name'], + } + }, + datasets: { + get: { + url: 'https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}', + location: null, + reliesOnService: ['datasets'], + reliesOnCall: ['list'], + properties: ['datasetId'], + subObj: ['datasetReference'], + pagination: true + } + }, + bigqueryTables: { + list: { + url: 'https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}/tables', + location: null, + reliesOnService: ['datasets'], + reliesOnCall: ['list'], + properties: ['datasetId'], + subObj: ['datasetReference'], + pagination: true, + dataKey: 'tables', + reqParams: 'maxResults=1000', + maxLimit: 50000 + } + }, + functions: { + getIamPolicy : { + url: 'https://cloudfunctions.googleapis.com/v1/{name}:getIamPolicy', + location: null, + reliesOnService: ['functions'], + reliesOnCall: ['list'], + properties: ['name'] + } + }, + functionsv2: { + getIamPolicy: { + url: 'https://cloudfunctions.googleapis.com/v2/{name}:getIamPolicy', + location: null, + method: 'POST', + reliesOnService: ['functionsv2'], + reliesOnCall: ['list'], + properties: ['name'], + body: { options: { requestedPolicyVersion: 3 } } + } + }, + jobs: { + get: { //https://dataflow.googleapis.com/v1b3/projects/{projectId}/jobs/{jobId} + url: 'https://dataflow.googleapis.com/v1b3/projects/{projectId}/locations/{locationId}/jobs/{id}', + reliesOnService: ['jobs'], + reliesOnCall: ['list'], + location: 'region', + properties: ['id'], + pagination: false, + } + }, + instanceGroups: { + listInstances: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/{location}/instanceGroups/{name}/listInstances', + method: 'POST', + reliesOnService: ['instanceGroups'], + reliesOnCall: ['aggregatedList'], + properties: ['location','name'], + filterObjKey: 'instanceGroups' + }, + get: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/{location}/instanceGroups/{name}', + reliesOnService: ['instanceGroups'], + reliesOnCall: ['aggregatedList'], + properties: ['location','name'], + filterObjKey: 'instanceGroups' + + } + }, + organizations: { //https://cloudresourcemanager.googleapis.com/v1beta1/{resource=organizations/*}:getIamPolicy + getIamPolicy: { + url:'https://cloudresourcemanager.googleapis.com/v1/organizations/{organizationId}:getIamPolicy', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + method: 'POST', + pagination: false + }, + listOrgPolicies: { + url: 'https://cloudresourcemanager.googleapis.com/v1/organizations/{organizationId}:listOrgPolicies', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + method: 'POST', + pagination: true, + paginationKey: 'pageSize' + }, + getCmekSettings: { + url: 'https://logging.googleapis.com/v2/organizations/{organizationId}/cmekSettings', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'] + }, + essentialContacts: { + url: 'https://essentialcontacts.googleapis.com/v1/organizations/{organizationId}/contacts', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + pagination: true, + paginationKey: 'pageSize' + }, + listAccessPolicies: { + url: 'https://accesscontextmanager.googleapis.com/v1/accessPolicies?parent=organizations/{organizationId}', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + pagination: true, + paginationKey: 'pageSize', + dataKey: 'accessPolicies' + }, + sendIntegration: serviceMap['Resource Manager'] + }, + folders:{ // https://cloudresourcemanager.googleapis.com/v2/folders + list: { + url: 'https://cloudresourcemanager.googleapis.com/v2/folders?parent=organizations/{organizationId}', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + pagination: true, + paginationKey: 'pageSize' + } + }, + apiKeys: { + get: { + url: 'https://apikeys.googleapis.com/v2/{name}', + reliesOnService: ['apiKeys'], + reliesOnCall: ['list'], + properties: ['name'] + } + }, + images: { + getIamPolicy: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/images/{name}/getIamPolicy', + reliesOnService: ['images'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + } + }, + services: { + listEnabled: { + url: 'https://serviceusage.googleapis.com/v1/projects/{projectNumber}/services', + reliesOnService: ['projects'], + reliesOnCall: ['getWithNumber'], + properties: ['projectNumber'], + pagination: true, + paginationKey: 'pageSize', + reqParams: 'filter=state:ENABLED' + } + }, + groups: { + list: { + url: 'https://cloudidentity.googleapis.com/v1/groups?parent=customers/{directoryCustomerId}', + location: null, + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['directoryCustomerId'], + subObj: 'owner', + pagination: false + } + }, + policies: { + getProjectDenyPolicies: {// GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Fprojects%2projectId/denypolicies/policyId + url:'https://iam.googleapis.com/v2/{name}', + reliesOnService: ['policies'], + reliesOnCall: ['projectDenyPolicies'], + properties: ['name'], + method: 'GET', + pagination: false + }, + orgDenyPolicies: {// GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Forganizations%252ForganizationId/denypolicies + url: 'https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252F{name}/denypolicies', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['name'], + encodeProperty: true, + method: 'GET', + pagination: false + }, + }, + bigtable: { + getIamPolicy: {//POST https://bigtableadmin.googleapis.com/v2/{resource=projects/*/instances/*}:getIamPolicy + url: 'https://bigtableadmin.googleapis.com/v2/{name}:getIamPolicy', + reliesOnService: ['bigtable'], + reliesOnCall: ['list'], + properties: ['name'], + method: 'POST', + pagination: false + }, + }, + spanner: { + getIamPolicy: {//POST https://spanner.googleapis.com/v1/{resource=projects/*/instances/*}:getIamPolicy + url: 'https://spanner.googleapis.com/v1/{name}:getIamPolicy', + reliesOnService: ['spanner'], + reliesOnCall: ['list'], + properties: ['name'], + method: 'POST', + pagination: false + }, + }, + deployments: { + getIamPolicy: {//GET https://www.googleapis.com/deploymentmanager/v2/projects/project/global/deployments/resource/getIamPolicy + url: 'https://www.googleapis.com/deploymentmanager/v2/projects/{projectId}/global/deployments/{name}/getIamPolicy', + reliesOnService: ['deployments'], + reliesOnCall: ['list'], + properties: ['name'], + method: 'GET', + pagination: false + }, + }, + dataproc: { + getIamPolicy: {//POST https://dataproc.googleapis.com/v1/{resource=projects/*/regions/*/operations/*}:getIamPolicy + url: 'https://dataproc.googleapis.com/v1/projects/{projectId}/regions/{locationId}/clusters/{clusterName}:getIamPolicy', + reliesOnService: ['dataproc'], + reliesOnCall: ['list'], + properties: ['clusterName'], + method: 'POST', + pagination: false + }, + }, +}; + +var tertiarycalls = { + cryptoKeys: { + getIamPolicy: { + url: 'https://cloudkms.googleapis.com/v1/{name}:getIamPolicy', + location: 'region', + reliesOnService: ['cryptoKeys'], + reliesOnCall: ['list'], + properties: ['name'], + } + }, + bigqueryTables: { + get: { + url: 'https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}/tables/{tableId}', + location: null, + reliesOnService: ['bigqueryTables'], + reliesOnCall: ['list'], + properties: ['datasetId', 'tableId'], + subObj: ['tableReference'], + pagination: true, + maxLimit: 50000 + } + }, + groups: { + get: { + url: 'https://cloudidentity.googleapis.com/v1/{name}', + location: null, + reliesOnService: ['groups'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: false + } + }, + memberships: { + list: { + url: 'https://cloudidentity.googleapis.com/v1/{name}/memberships', + location: null, + reliesOnService: ['groups'], + reliesOnCall: ['list'], + properties: ['name'], + pagination: true, + paginationKey: 'nextPageToken' + } + }, + folders: { //https://cloudresourcemanager.googleapis.com/v2/{resource=folders/!*}:getIamPolicy + getIamPolicy: { + url: 'https://cloudresourcemanager.googleapis.com/v2/{name}:getIamPolicy', + // name = resource name of the Folder. Its format is folders/{folder_id}, for example: "folders/1234". + reliesOnService: ['folders'], + reliesOnCall: ['list'], + properties: ['name'], + method: 'POST', + pagination: false + }, + }, + policies: { // GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Forganizations%252ForganizationId/denypolicies/policyId + getOrgDenyPolicies: { + url: 'https://iam.googleapis.com/v2/{name}', + reliesOnService: ['policies'], + reliesOnCall: ['orgDenyPolicies'], + properties: ['name'], + method: 'GET', + pagination: false + }, + folderDenyPolicies: {// GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Ffolders%252FfolderId/denypolicies + url: 'https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252F{name}/denypolicies', + reliesOnService: ['folders'], + reliesOnCall: ['list'], + properties: ['name'], + encodeProperty: true, + method: 'GET', + pagination: false + }, + }, + organizations: { + servicePerimeters: { + url: 'https://accesscontextmanager.googleapis.com/v1/{name}/servicePerimeters', + reliesOnService: ['organizations'], + reliesOnCall: ['listAccessPolicies'], + properties: ['name'], + method: 'GET', + pagination: true, + dataKey: 'servicePerimeters' + } + } +}; + +var additionalCalls = { + policies: { + getFolderDenyPolicies: {// GET https://iam.googleapis.com/v2/policies/cloudresourcemanager.googleapis.com%252Ffolders%252FfolderId/denypolicies/policyId + url: 'https://iam.googleapis.com/v2/{name}', + reliesOnService: ['policies'], + reliesOnCall: ['folderDenyPolicies'], + properties: ['name'], + method: 'GET', + pagination: false + }, + }, +}; + +var specialcalls = { + iam: { + list: { + pagination: true, + reliesOnService: ['projects','folders','organizations','memberships','policies'], + reliesOnCall: ['getIamPolicy','getProjectDenyPolicies','getOrgDenyPolicies','getFolderDenyPolicies'] + }, + sendIntegration: { + integrationReliesOn: { + serviceName: ['roles','projects','folders','organizations','memberships','policies'] + }, + enabled: true + } + } +}; + +module.exports = { + calls: calls, + postcalls: postcalls, + tertiarycalls: tertiarycalls, + specialcalls: specialcalls, + additionalCalls:additionalCalls, + serviceMap: serviceMap +}; diff --git a/helpers/google/functions.js b/helpers/google/functions.js index 9701eab74..f7e0a90b2 100644 --- a/helpers/google/functions.js +++ b/helpers/google/functions.js @@ -1,35 +1,100 @@ +var async = require('async'); var shared = require(__dirname + '/../shared.js'); +var disabledKeywords = ['has not been used', 'it is disabled']; -function addResult(results, status, message, region, resource, custom){ - // Override unknown results for regions that are opt-in - results.push({ - status: status, - message: message, - region: region || 'global', - resource: resource || null, - custom: custom || false - }); +function addResult(results, status, message, region, resource, custom, err, required) { + var pushResult = function(status, message, region, resource, custom) { + results.push({ + status: status, + message: message, + region: region || 'global', + resource: resource || null, + custom: custom || false + }); + }; + + var processError = function(errorObj) { + if (errorObj && + errorObj.code && + errorObj.code == 404) { + pushResult(0, 'Project is deleted or pending deletion.', region, resource, custom); + } else if (errorObj && + errorObj.code && + errorObj.code == 403 && + errorObj.message && + disabledKeywords.some(substring=>errorObj.message.includes(substring))) { + pushResult(required ? 2 : 0, required ? 'Service is not enabled, but it is recommended to run a secure workload in GCP.' : 'Service is not enabled', region, resource, custom); + } else if (errorObj && + errorObj.code && + errorObj.code == 403 && + errorObj.errors && + errorObj.errors.length) { + errorObj.errors.forEach(function(errError){ + if (errError && + errError.message && + disabledKeywords.some(substring=>errError.message.includes(substring))){ + pushResult(required ? 2 : 0, required ? 'Service is not enabled, but it is recommended to run a secure workload in GCP.' : 'Service is not enabled', region, resource, custom); + } else { + pushResult(3, (errError.message ? errError.message : message), region, resource, custom); + } + }); + + } else { + pushResult(3, (errorObj.message ? errorObj.message : 'Unable to query the API: ' + errorObj), region, resource, custom); + } + }; + + if (err && + err.code) { + processError(err); + } else if (err && + err[region] && + err[region].length) { + err[region].forEach(function(errRegion) { + if (errRegion && + err[region][errRegion] && + err[region][errRegion].code) { + processError(err[region][errRegion]); + } else { + pushResult(3, (err[region][errRegion].message ? err[region][errRegion].message : message), region, resource, custom); + } + }); + } else if (message && + disabledKeywords.some(substring=>message.includes(substring))) { + pushResult(required ? 2 : 0, required ? 'Service is not enabled, but it is recommended to run a secure workload in GCP.' : 'Service is not enabled', region, resource, custom); + } else { + pushResult(status, message, region, resource, custom); + } } -function findOpenPorts(ngs, protocols, service, location, results) { - let found = false; +function findOpenPorts(ngs, protocols, service, location, results, cache, source) { + let projects = shared.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + addResult(results, 3, + 'Unable to query for projects: ' + shared.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return; + } + + var project = projects.data[0].name; for (let sgroups of ngs) { let strings = []; - let resource = sgroups.id; + let resource = createResourceName('firewalls', sgroups.name, project, 'global'); if (sgroups.allowed && sgroups.allowed.length) { let firewallRules = sgroups.allowed; let sourceAddressPrefix = sgroups.sourceRanges; if (!sourceAddressPrefix || !sourceAddressPrefix.length) continue; - + for (let firewallRule of firewallRules) { for (let protocol in protocols) { let ports = protocols[protocol]; - + for (let port of ports) { - if (sgroups['direction'] && (sgroups['direction'] === 'INGRESS') && - firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === protocol) && + if (sgroups['direction'] && (sgroups['direction'] === 'INGRESS') && + firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === protocol) && !sgroups['disabled'] && (sourceAddressPrefix.includes('*') || sourceAddressPrefix.includes('') || sourceAddressPrefix.includes('0.0.0.0/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('internet'))) { var sourcefilter = (sourceAddressPrefix === '0.0.0.0/0' ? 'any IP' : sourceAddressPrefix); @@ -41,15 +106,13 @@ function findOpenPorts(ngs, protocols, service, location, results) { let endPort = portRange[1]; if (parseInt(startPort) < port && parseInt(endPort) > port) { var string = `` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + - ` port ` + port + ` open to ` + sourcefilter; strings.push(string); + ` port ` + port + ` open to ` + sourcefilter; strings.push(string); if (strings.indexOf(string) === -1) strings.push(string); - found = true; } - } else if (portRange === port) { + } else if (parseInt(portRange) === port) { var string = `` + (protocol === '*' ? `All protocols` : protocol.toUpperCase()) + - ` port ` + port + ` open to ` + sourcefilter; + ` port ` + port + ` open to ` + sourcefilter; if (strings.indexOf(string) === -1) strings.push(string); - found = true; } }); } @@ -64,30 +127,43 @@ function findOpenPorts(ngs, protocols, service, location, results) { ') has ' + service + ': ' + strings.join(' and '), location, resource); } + else { + shared.addResult(results, 0, + 'Firewall Rule:(' + sgroups.name + + ') does not have ' + service + ' port open ' , location, + resource); + } } +} + +function findOpenAllPorts(ngs, location, results, cache, source) { + let projects = shared.addSource(cache, source, + ['projects','get', 'global']); - if (!found) { - shared.addResult(results, 0, 'No public open ports found', location); + if (!projects || projects.err || !projects.data || !projects.data.length) { + addResult(results, 3, + 'Unable to query for projects: ' + shared.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return; } -} -function findOpenAllPorts(ngs, location, results) { - let found = false; + + var project = projects.data[0].name; let protocols = {'tcp': '*', 'udp' : '*'}; for (let sgroups of ngs) { let strings = []; - let resource = sgroups.id; + let resource = createResourceName('firewalls', sgroups.name, project, 'global'); if (sgroups.allowed && sgroups.allowed.length) { let firewallRules = sgroups.allowed; let sourceAddressPrefix = sgroups.sourceRanges; + if (!sourceAddressPrefix || !sourceAddressPrefix.length) continue; for (let firewallRule of firewallRules) { for (let protocol in protocols) { if (sgroups['direction'] && (sgroups['direction'] === 'INGRESS') && - firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === protocol) && - sgroups['disabled'] && (sgroups['disabled'] === false) && - sourceAddressPrefix && - (sourceAddressPrefix.includes('*') || sourceAddressPrefix.includes('') || sourceAddressPrefix.includes('0.0.0.0/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('internet'))) { + firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === protocol) && + !sgroups['disabled'] && + sourceAddressPrefix && + (sourceAddressPrefix.includes('*') || sourceAddressPrefix.includes('') || sourceAddressPrefix.includes('0.0.0.0/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('internet'))) { if (firewallRule['ports']) { firewallRule['ports'].forEach((portRange) => { if (portRange.includes("-")) { @@ -97,24 +173,90 @@ function findOpenAllPorts(ngs, location, results) { if (parseInt(startPort) === 0 && parseInt(endPort) === 65535) { var string = 'all ports open to the public'; if (strings.indexOf(string) === -1) strings.push(string); - found = true; } } else if (portRange === 'all') { var string = 'all ports open to the public'; if (strings.indexOf(string) === -1) strings.push(string); - found = true; } }); } } else if (sgroups['direction'] && (sgroups['direction'] === 'INGRESS') && firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === 'all') && - sgroups['disabled'] && (sgroups['disabled'] === false) && + !sgroups['disabled'] && sourceAddressPrefix && (sourceAddressPrefix.includes('*') || sourceAddressPrefix.includes('') || sourceAddressPrefix.includes('0.0.0.0/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.includes('internet'))) { var string = 'all ports open to the public'; if (strings.indexOf(string) === -1) strings.push(string); - found = true; + } + } + } + } + if (strings.length) { + shared.addResult(results, 2, + 'Firewall Rule:(' + sgroups.name + + ') has ' + strings.join(' and '), location, + resource); + } + else { + shared.addResult(results, 0, + 'Firewall Rule:(' + sgroups.name + + ') does not have all ports open to the public', location, + resource); + } + } +} + +function findOpenAllPortsEgress(ngs, location, results, cache, source) { + let projects = shared.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + addResult(results, 3, + 'Unable to query for projects: ' + shared.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return; + } + + var project = projects.data[0].name; + let protocols = {'tcp': '*', 'udp' : '*'}; + for (let sgroups of ngs) { + let strings = []; + let resource = createResourceName('firewalls', sgroups.name, project, 'global'); + if (sgroups.allowed && sgroups.allowed.length) { + let firewallRules = sgroups.allowed; + let destAddressPrefix = sgroups.destinationRanges; + + if (!destAddressPrefix || !destAddressPrefix.length) continue; + for (let firewallRule of firewallRules) { + for (let protocol in protocols) { + if (sgroups['direction'] && (sgroups['direction'] === 'EGRESS') && + firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === protocol) && + !sgroups['disabled'] && + destAddressPrefix && + (destAddressPrefix.includes('*') || destAddressPrefix.includes('') || destAddressPrefix.includes('0.0.0.0/0') || destAddressPrefix.includes('/0') || destAddressPrefix.includes('/0') || destAddressPrefix.includes('internet'))) { + if (firewallRule['ports']) { + firewallRule['ports'].forEach((portRange) => { + if (portRange.includes("-")) { + portRange = portRange.split("-"); + let startPort = portRange[0]; + let endPort = portRange[1]; + if (parseInt(startPort) === 0 && parseInt(endPort) === 65535) { + var string = 'all ports open to the public'; + if (strings.indexOf(string) === -1) strings.push(string); + } + } else if (portRange === 'all') { + var string = 'all ports open to the public'; + if (strings.indexOf(string) === -1) strings.push(string); + } + }); + } + } else if (sgroups['direction'] && (sgroups['direction'] === 'EGRESS') && + firewallRule['IPProtocol'] && (firewallRule['IPProtocol'] === 'all') && + !sgroups['disabled'] && + destAddressPrefix && + (destAddressPrefix.includes('*') || destAddressPrefix.includes('') || destAddressPrefix.includes('0.0.0.0/0') || destAddressPrefix.includes('/0') || destAddressPrefix.includes('/0') || destAddressPrefix.includes('internet'))) { + var string = 'all ports open to the public'; + if (strings.indexOf(string) === -1) strings.push(string); } } } @@ -125,14 +267,342 @@ function findOpenAllPorts(ngs, location, results) { ') has ' + strings.join(' and '), location, resource); } + else { + shared.addResult(results, 0, + 'Firewall Rule:(' + sgroups.name + + ') does not have all ports open to the public', location, + resource); + } } +} - if (!found) { - shared.addResult(results, 0, 'No public open ports found', location); +function hasBuckets(buckets){ + if(buckets.length && + Object.keys(buckets[0]).length>1) { + return true; + } else { + return false; + } +} + +function createResourceName(resourceType, resourceId, project, locationType, location) { + let resourceName = ''; + if (project) resourceName = `projects/${project}/`; + switch(locationType) { + case 'global': + resourceName = `${resourceName}global/${resourceType}/${resourceId}`; + break; + case 'region': + resourceName = `${resourceName}regions/${location}/${resourceType}/${resourceId}`; + break; + case 'zone': + resourceName = `${resourceName}zones/${location}/${resourceType}/${resourceId}`; + break; + case 'location': + resourceName = `${resourceName}locations/${location}/${resourceType}/${resourceId}`; + break; + default: + resourceName = `${resourceName}${resourceType}/${resourceId}`; + } + return resourceName; +} + +function getProtectionLevel(cryptographickey, encryptionLevels) { + if (cryptographickey && cryptographickey.versionTemplate && cryptographickey.versionTemplate.protectionLevel) { + if (cryptographickey.versionTemplate.protectionLevel == 'SOFTWARE') return encryptionLevels.indexOf('cloudcmek'); + else if (cryptographickey.versionTemplate.protectionLevel == 'HSM') return encryptionLevels.indexOf('cloudhsm'); + else if (cryptographickey.versionTemplate.protectionLevel == 'EXTERNAL') return encryptionLevels.indexOf('external'); + } + + return encryptionLevels.indexOf('unspecified'); +} + +function listToObj(resultObj, listData, onKey) { + async.each(listData, function(entry, cb){ + if (entry[onKey]) resultObj[entry[onKey]] = entry; + cb(); + }); +} + +function createResourceName(resourceType, resourceId, project, locationType, location) { + let resourceName = ''; + if (project) resourceName = `projects/${project}/`; + switch(locationType) { + case 'global': + resourceName = `${resourceName}global/${resourceType}/${resourceId}`; + break; + case 'region': + resourceName = `${resourceName}regions/${location}/${resourceType}/${resourceId}`; + break; + case 'zone': + resourceName = `${resourceName}zones/${location}/${resourceType}/${resourceId}`; + break; + case 'location': + resourceName = `${resourceName}locations/${location}/${resourceType}/${resourceId}`; + break; + default: + resourceName = `${resourceName}${resourceType}/${resourceId}`; + } + return resourceName; +} + +function checkOrgPolicy(orgPolicies, constraintName, constraintType, shouldBeEnabled, ifNotFound, displayName, results, resource) { + let isEnabled = false; + if (orgPolicies && orgPolicies.policies) { + let policyToCheck = orgPolicies.policies.find(policy => ( + policy.constraint && + policy.constraint.includes(constraintName))); + if (policyToCheck) { + if (constraintType == 'listPolicy' && policyToCheck.listPolicy) { + if (policyToCheck.listPolicy.allValues) { + isEnabled = policyToCheck.listPolicy.allValues == 'ALLOW' ? false : true; + } else if ((policyToCheck.listPolicy.allowedValues && policyToCheck.listPolicy.allowedValues.length) || (policyToCheck.listPolicy.deniedValues && policyToCheck.listPolicy.deniedValues.length)) { + isEnabled = true; + } + } else if (constraintType == 'booleanPolicy' && policyToCheck.booleanPolicy && policyToCheck.booleanPolicy.enforced) { + isEnabled = true; + } + } else { + isEnabled = ifNotFound; + } + } + let successMessage = `"${displayName}" constraint is enforced at the organization level.`; + let failureMessage = `"${displayName}" constraint is not enforced at the organization level.`; + let status, message; + if (isEnabled) { + status = shouldBeEnabled ? 0 : 2; + message = shouldBeEnabled ? successMessage : failureMessage; + } else { + status = shouldBeEnabled ? 2 : 0; + message = shouldBeEnabled ? failureMessage : successMessage; + } + + shared.addResult(results, status, message, 'global', resource); + +} + +function checkIAMRole(iamPolicy, roles, region, results, project, notFoundMessage) { + let roleExists = false; + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role && roles.includes(roleBinding.role)) { + roleExists = true; + roleBinding.members.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let memberType = member.startsWith('serviceAccount') ? 'serviceAccounts' : 'users'; + let resource = createResourceName(memberType, accountName, project); + shared.addResult(results, 2, + `The account has the pre-defined role: ${roleBinding.role}`, region, resource); + }); + } + }); + } + if (!roleExists) { + shared.addResult(results, 0, notFoundMessage, region); + } +} + +function checkFirewallRules(firewallRules) { + firewallRules.sort((a, b) => (a.priority || 1000) - (b.priority || 1000)); + + for (const firewallRule of firewallRules) { + if (firewallRule.direction !== 'INGRESS' || firewallRule.disabled) { + continue; + } + + const networkName = firewallRule.network ? firewallRule.network.split('/').pop() : ''; + + let allSources = firewallRule.sourceRanges? firewallRule.sourceRanges.some(sourceAddressPrefix => + sourceAddressPrefix === '*' || + sourceAddressPrefix === '0.0.0.0/0' || + sourceAddressPrefix === '::/0' || + sourceAddressPrefix.includes('/0') || + sourceAddressPrefix.toLowerCase() === 'internet' || + sourceAddressPrefix.includes('/0') + ): false; + + var allowed = firewallRule.allowed? firewallRule.allowed.some(allow => !!allow.IPProtocol): false; + var denied = firewallRule.denied? firewallRule.denied.some(deny => deny.IPProtocol === 'all'): false; + if (allSources && allowed) { + return {exposed: true, networkName: `vpc ${networkName}`}; + } + + if (allSources && denied) { + return {exposed: false}; + } + } + + return {exposed: true}; + + +} + +function getForwardingRules(cache, source, region, resource) { + let rules = []; + + let forwardingRules = getAllDataForService(cache, source, 'forwardingRules', 'list', region); + let backendServices = getAllDataForService(cache, source, 'backendServices', 'list', region); + let targetHttpProxies = getAllDataForService(cache, source, 'targetHttpProxies', 'list', region); + let targetHttpsProxies = getAllDataForService(cache, source, 'targetHttpsProxies', 'list', region); + let urlMaps = getAllDataForService(cache, source, 'urlMaps', 'list', region); + + if (!forwardingRules || !forwardingRules.length || !backendServices || !backendServices.length) { + return []; + } + + if (resource.httpsTrigger && resource.httpsTrigger.url) { + backendServices = backendServices.filter(service => { + if (service.backends && service.backends.length) { + return service.backends.some(backend => backend.target && backend.target.includes(resource.httpsTrigger.url)); + } + }); + } else { + backendServices = backendServices.filter(service => { + if (service.backends && service.backends.length) { + return service.backends.some(backend => { + let group = backend.group.replace(/^.*?(\/projects\/.*)$/, '$1'); + return (resource.selfLink && resource.selfLink.includes(group)); + }); + } + }); + } + + if (backendServices && backendServices.length) { + forwardingRules.forEach(rule => { + let rulePath = `FR ${rule.name}`; + let targetProxyLink = ''; + let urlMapLink = ''; + let backendServiceLink = ''; + + if (rule.target && (rule.target.includes('targetHttpProxies') || rule.target.includes('targetHttpsProxies'))) { + let target = rule.target.replace(/^.*?(\/projects\/.*)$/, '$1'); + let targetProxy = rule.target.includes('targetHttpProxies') + ? targetHttpProxies.find(proxy => proxy.selfLink.includes(target)) + : targetHttpsProxies.find(proxy => proxy.selfLink.includes(target)); + + if (targetProxy) { + rulePath += ` > TP ${targetProxy.name}`; + targetProxyLink = targetProxy.selfLink; + + if (targetProxy.urlMap) { + let urlMap = urlMaps.find(map => map.selfLink.includes(targetProxy.urlMap.replace(/^.*?(\/projects\/.*)$/, '$1'))); + if (urlMap && urlMap.defaultService) { + rulePath += ` > UM ${urlMap.name}`; + urlMapLink = urlMap.selfLink; + + let serviceName = urlMap.defaultService.replace(/^.*?(\/projects\/.*)$/, '$1'); + let matchedBackendService = backendServices.find(service => service.selfLink.includes(serviceName)); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + } else { + let matchedBackendService = backendServices.find(service => targetProxy.selfLink.includes(service.selfLink.replace(/^.*?(\/projects\/.*)$/, '$1'))); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + } + } else if (rule.backendService) { + let serviceName = rule.backendService.replace(/^.*?(\/projects\/.*)$/, '$1'); + let matchedBackendService = backendServices.find(service => service.selfLink.includes(serviceName)); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + + if (backendServiceLink) { + rules.push({ ...rule, rulePath }); + } + }); + } + return rules; +} + + +function getAllDataForService(cache, source, service, call, region) { + let allData = []; + + let globalData = shared.addSource(cache, source, [service, call, 'global']); + let regionalData = shared.addSource(cache, source, [service, call, region]); + + + if (globalData && !globalData.err && globalData.data && globalData.data.length) { + allData = allData.concat(globalData.data); + } + + if (regionalData && !regionalData.err && regionalData.data && regionalData.data.length) { + allData = allData.concat(regionalData.data); + } + return allData; +} + +function checkClusterExposure(cluster) { + const privateClusterConfig = cluster.privateClusterConfig || {}; + const masterAuthorizedNetworksConfig = cluster.masterAuthorizedNetworksConfig || {}; + + const cidrBlocks = masterAuthorizedNetworksConfig.cidrBlocks || []; + + const publicCidrPatterns = ['0.0.0.0/0', '::/0', '*', '::0']; + + const hasPublicCIDR = cidrBlocks.some(block => publicCidrPatterns.includes(block)); + + return ( + (!privateClusterConfig.enablePrivateEndpoint && privateClusterConfig.publicEndpoint) || // Public endpoint with no private endpoint + masterAuthorizedNetworksConfig.gcpPublicCidrsAccessEnabled || // Google Cloud public IPs are allowed + hasPublicCIDR // If there is a public CIDR like 0.0.0.0/0, ::/0, or * + ); +} + +function checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules) { + let exposedPath = ''; + + if (firewallRules && firewallRules.length) { + // Scenario 1: check if any firewall rule allows all inbound traffic + let isExposed = checkFirewallRules(firewallRules); + if (isExposed.exposed) { + if (isExposed.networkName) { + return isExposed.networkName; + } + } else { + return ''; + } + } + + // load balancing flow + if (forwardingRules && forwardingRules.length) { + for (let rule of forwardingRules) { + let ipAddress = rule.IPAddress; + if ((rule.loadBalancingScheme === 'EXTERNAL' || rule.loadBalancingScheme === 'EXTERNAL_MANAGED') && + (ipAddress && !ipAddress.startsWith('10.') && !ipAddress.startsWith('192.168.') && !ipAddress.startsWith('172.'))) { + exposedPath = rule.rulePath || rule.name; + break; + + } + } } + return exposedPath } module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, - findOpenAllPorts: findOpenAllPorts -}; \ No newline at end of file + findOpenAllPorts: findOpenAllPorts, + hasBuckets: hasBuckets, + createResourceName: createResourceName, + getProtectionLevel: getProtectionLevel, + listToObj: listToObj, + createResourceName: createResourceName, + checkOrgPolicy: checkOrgPolicy, + checkIAMRole: checkIAMRole, + findOpenAllPortsEgress: findOpenAllPortsEgress, + checkNetworkExposure: checkNetworkExposure, + getForwardingRules: getForwardingRules, + checkClusterExposure: checkClusterExposure, + checkFirewallRules: checkFirewallRules +}; diff --git a/helpers/google/index.js b/helpers/google/index.js index 29ff05d1b..7654dec0f 100644 --- a/helpers/google/index.js +++ b/helpers/google/index.js @@ -2,7 +2,6 @@ var shared = require(__dirname + '/../shared.js'); var functions = require('./functions.js'); var regRegions = require('./regions.js'); -const {google} = require('googleapis'); const {JWT} = require('google-auth-library'); var async = require('async'); @@ -23,17 +22,17 @@ var authenticate = async function(GoogleConfig) { var processCall = function(GoogleConfig, collection, settings, regions, call, service, client, serviceCb) { // Loop through each of the service's functions if (call.manyApi) { - async.eachOfLimit(call, 10, function(callInt, item, itemsCb) { + async.eachOfLimit(call, 5, function(callInt, item, itemsCb) { var myEngine = item; - async.eachOfLimit(callInt, 10, function(callObj, callKey, callCb) { + async.eachOfLimit(callInt, 5, function(callObj, callKey, callCb) { if (settings.api_calls && settings.api_calls.indexOf(service + ':' + myEngine + ':' + callKey) === -1) return callCb(); if (!collection[service]) collection[service] = {}; if (!collection[service][myEngine]) collection[service][myEngine] = {}; if (!collection[service][myEngine][callKey]) collection[service][myEngine][callKey] = {}; - async.eachLimit(regions[service][myEngine], 10, function(region, regionCb) { + async.eachLimit(regions[service][myEngine], 5, function(region, regionCb) { if (callObj.location == 'zone') { - async.each(regions.zones[region], function(zone, zoneCb) { + async.eachLimit(regions.zones[region], 5, function(zone, zoneCb) { run(GoogleConfig, collection, settings, service, callObj, callKey, zone, zoneCb, client, myEngine); }, function() { regionCb(); @@ -51,14 +50,14 @@ var processCall = function(GoogleConfig, collection, settings, regions, call, se serviceCb(); }); } else { - async.eachOfLimit(call, 10, function(callObj, callKey, callCb) { + async.eachOfLimit(call, 5, function(callObj, callKey, callCb) { if (settings.api_calls && settings.api_calls.indexOf(service + ':' + callKey) === -1) return callCb(); if (!collection[service]) collection[service] = {}; if (!collection[service][callKey]) collection[service][callKey] = {}; - async.eachLimit(regions[service], 10, function(region, regionCb) { + async.eachLimit(regions[service], 5, function(region, regionCb) { if (callObj.location == 'zone') { - async.each(regions.zones[region], function(zone, zoneCb) { + async.eachLimit(regions.zones[region], 5,function(zone, zoneCb) { run(GoogleConfig, collection, settings, service, callObj, callKey, zone, zoneCb, client); }, function() { regionCb(); @@ -76,106 +75,129 @@ var processCall = function(GoogleConfig, collection, settings, regions, call, se } }; + + var run = function(GoogleConfig, collection, settings, service, callObj, callKey, region, regionCb, client, myEngine) { + if (settings.skip_regions && settings.skip_regions.indexOf(region) > -1) return regionCb(); - var LocalGoogleConfig = JSON.parse(JSON.stringify(GoogleConfig)); LocalGoogleConfig[callObj.location] = region; LocalGoogleConfig.service = service; LocalGoogleConfig.auth = client; - - if (callObj.parent && !callObj.reliesOnService) { - if (!callObj.params) callObj.params = {}; - callObj.params.parent = addParent(GoogleConfig, region, callObj); - } - callObj.params = JSON.parse(JSON.stringify(GoogleConfig)); callObj.params[callObj.location] = region; callObj.params.service = service; - - callObj.params.auth = client; callObj.auth = client; var options = { - version: callObj.version, params : {} }; var records; - if (myEngine) { if (!collection[service][myEngine][callKey][region]) { collection[service][myEngine][callKey][region] = {}; collection[service][myEngine][callKey][region].data = []; } - + if (callObj.reliesOnService) { - if (!callObj.reliesOnService.length) return regionCb(); + if (!callObj.reliesOnService.length) { + return regionCb(); + } // Ensure multiple pre-requisites are met for (var reliedService in callObj.reliesOnService) { if (callObj.reliesOnService[reliedService] && !collection[callObj.reliesOnService[reliedService]]) return regionCb(); if (callObj.reliesOnService[reliedService] && (!collection[callObj.reliesOnService[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]] || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region] || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); records = collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data; + if (callObj.subObj) records = records.filter(record => !!record[callObj.subObj]); async.eachLimit(records, 10, function(record) { - for (var filter in callObj.filterKey) { - callObj.params[callObj.filterKey[filter]] = record[callObj.filterValue[filter]]; + callObj.urlToCall = callObj.url; + for (var property in callObj.properties) { + callObj.urlToCall = callObj.urlToCall.replace(`{${callObj.properties[property]}}`, !callObj.subObj ? record[callObj.properties[property]] : record[callObj.subObj][callObj.properties[property]]); } - execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, options, myEngine); + execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, client, options, myEngine, true, record); }, function() { regionCb(); }); } - callObj.params[callObj.filterKey[reliedService]] = [callObj.filterValue[reliedService]]; + callObj.params[callObj.properties[reliedService]] = [callObj.filterValue[reliedService]]; } else { - execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, options, myEngine); + + execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, client, options, myEngine); } } else { + if (!collection[service][callKey][region]) { collection[service][callKey][region] = {}; collection[service][callKey][region].data = []; } - if (callObj.parent && !callObj.reliesOnService) { - if (!callObj.params) callObj.params = {}; - callObj.params.parent = addParent(GoogleConfig, region, callObj); - } + let collectionItems = collection[service][callKey][region]; if (callObj.reliesOnService && !callObj.reliesOnSubService) { - if (!callObj.reliesOnService.length) return regionCb(); + if (!callObj.reliesOnService.length) { + return regionCb(); + } // Ensure multiple pre-requisites are met for (reliedService in callObj.reliesOnService) { - if (callObj.reliesOnService[reliedService] && !collection[callObj.reliesOnService[reliedService]]) return regionCb(); + if (callObj.reliesOnService[reliedService] && !collection[callObj.reliesOnService[reliedService]]) { + return regionCb(); + } if (callObj.reliesOnService[reliedService] && (!collection[callObj.reliesOnService[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]] || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region] || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); records = collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data; - async.eachLimit(records, 10, function(record, recordCb) { - for (var filter in callObj.filterKey) { - callObj.params[callObj.filterKey[filter]] = record[callObj.filterValue[filter]]; - options.version = callObj.version; + if (callObj.filterObjKey && records.length === 1) records = records[0]; + + if (records && typeof records === 'object' && !Array.isArray(records) && callObj.filterObjKey) { + let callObjData = []; + Object.keys(records).map(key => { + if (records[key] && records[key][callObj.filterObjKey]) { + if (Array.isArray(records[key][callObj.filterObjKey])) { + let recordsToPush = records[key][callObj.filterObjKey]; + recordsToPush = recordsToPush.map(obj => { + obj.location = key; + return obj; + + }); + callObjData.push(...recordsToPush); + } + } + }); + records = callObjData; + } + if (callObj.subObj) records = records.filter(record => !!record[callObj.subObj]); + async.eachLimit(records, callObj.maxLimit ? 35 : 10, function(record, recordCb) { + callObj.urlToCall = callObj.url; + for (var property in callObj.properties) { + let data = !callObj.subObj ? record[callObj.properties[property]] : record[callObj.subObj][callObj.properties[property]]; + if (callObj.encodeProperty){ + data = encodeURIComponent(data); + } + callObj.urlToCall = callObj.urlToCall.replace(`{${callObj.properties[property]}}`, data); } - if (callObj.parent) { - callObj.params.parent = addParent(GoogleConfig, region, callObj); + if (!callObj.maxLimit || (callObj.maxLimit && collectionItems.data && collectionItems.data.length < callObj.maxLimit)) { + execute(LocalGoogleConfig, collection, service, callObj, callKey, region, recordCb, client, options, myEngine, true, record); + } else { + recordCb(); } - execute(LocalGoogleConfig, collection, service, callObj, callKey, region, recordCb, options); }, function() { regionCb(); }); } - callObj.params[callObj.filterKey[reliedService]] = [callObj.filterValue[reliedService]]; } else if (callObj.reliesOnService && callObj.reliesOnSubService) { if (!callObj.reliesOnService.length) return regionCb(); @@ -191,110 +213,126 @@ var run = function(GoogleConfig, collection, settings, service, callObj, callKey !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnSubService[reliedService]][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); records = collection[callObj.reliesOnService[reliedService]][callObj.reliesOnSubService[reliedService]][callObj.reliesOnCall[reliedService]][region].data; + if (callObj.subObj) records = records.filter(record => !!record[callObj.subObj]); async.eachLimit(records, 10, function(record, recordCb) { - for (var filter in callObj.filterKey) { - callObj.params[callObj.filterKey[filter]] = record[callObj.filterValue[filter]]; - options.version = callObj.version; + callObj.urlToCall = callObj.url; + for (var property in callObj.properties) { + callObj.urlToCall = callObj.urlToCall.replace(`{${callObj.properties[property]}}`, !callObj.subObj ? record[callObj.properties[property]] : record[callObj.subObj][callObj.properties[property]]); + } + if (!callObj.maxLimit || (callObj.maxLimit && collectionItems.data && collectionItems.data.length < callObj.maxLimit)) { + execute(LocalGoogleConfig, collection, service, callObj, callKey, region, recordCb, client, options); + } else { + recordCb(); } - execute(LocalGoogleConfig, collection, service, callObj, callKey, region, recordCb, options); }, function() { regionCb(); }); } } else { - execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, options); + execute(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, client, options); } } }; - -var addParent = function(GoogleConfig, region, callObj) { - if (callObj.location && callObj.location == 'global') { - return `projects/${GoogleConfig.project}/locations/-`; - } else if (callObj.location && callObj.location == 'region') { - return `projects/${GoogleConfig.project}/locations/${region}`; - } else if (callObj.serviceAccount) { - return `projects/${GoogleConfig.project}/serviceAccounts/${callObj.params.id}`; - } else { - return `projects/${GoogleConfig.project}`; - } -}; - -var execute = function(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, options, myEngine) { - var executor = new google[callObj.api](options); - var executorCb = function(err, data) { - if (myEngine) { - if (err) { - collection[service][myEngine][callKey][region].err = err; +var handleErrors = function(err) { + if (err.code) { + if (err.code == 400) { + return 'Invalid argument, please contact support.'; + } else if (err.code == 401) { + if (err.response && err.response.data && err.response.data.error_description) { + return err.response.data.error_description; + } else { + return 'Unauthenticated request, please contact support.'; } - - if (!data) return regionCb(); - if (callObj.property && !data[callObj.property]) return regionCb(); - if (callObj.secondProperty && !data[callObj.secondProperty]) return regionCb(); - - if (callObj.secondProperty) { - collection[service][myEngine][callKey][region].data = data[callObj.property][callObj.secondProperty]; + } else if (err.code == 403) { + if (err.response) { + if (err.config) delete err.config; + if (err.response && err.response.config) delete err.response.config; + if (err.response && err.response.headers) delete err.response.headers; + return err; } else { - if (data.data.items) { - collection[service][myEngine][callKey][region].data = data.data.items; - } else if (data.data[service]) { - collection[service][myEngine][callKey][region].data = data.data[service]; - } else { - collection[service][myEngine][callKey][region].data = []; - } + return 'Permission denied, please check the permissions on the service account.'; } - - if (callObj.rateLimit) { - setTimeout(function() { - regionCb(); - }, callObj.rateLimit); + } else if (err.code == 404) { + if (err.response) { + if (err.config) delete err.config; + if (err.response && err.response.config) delete err.response.config; + if (err.response && err.response.headers) delete err.response.headers; + return err; } else { - regionCb(); + return 'Resource not found.'; } - } else { - if (err) { - collection[service][callKey][region].err = err; + } else if (err.code == 429) { + return 'Rate limit exceeded.'; + } else if (err.code == 500) { + if (err.response && err.response.data && err.response.data.error_description) { + return err.response.data.error_description; + } else { + return '500 Error from Google'; } - - if (!data) return regionCb(); - if (callObj.property && !data[callObj.property]) return regionCb(); - if (callObj.secondProperty && !data[callObj.secondProperty]) return regionCb(); - - if (callObj.secondProperty) { - collection[service][callKey][region].data = data[callObj.property][callObj.secondProperty]; + } else if (err.code == 503) { + if (err.response && err.response.data && err.response.data.error_description) { + return err.response.data.error_description; } else { - if (data.data.items) { - if (data.data.items.constructor.name === 'Array') { - collection[service][callKey][region].data = collection[service][callKey][region].data.concat(data.data.items); - } else { - collection[service][callKey][region].data = data.data.items; - } - } else if (data.data[service]) { - if (data.data[service].constructor.name === 'Array') { - collection[service][callKey][region].data = collection[service][callKey][region].data.concat(data.data[service]); - } else { - collection[service][callKey][region].data.push(data.data[service]); - } - } - else if (data.data.accounts) { - if (data.data.accounts.constructor.name === 'Array') { - collection[service][callKey][region].data = collection[service][callKey][region].data.concat(data.data.accounts); - } else { - collection[service][callKey][region].data.push(data.data.accounts); - } - } else if (data.data) { - if (data.data.constructor.name === 'Array') { - collection[service][callKey][region].data.concat(data.data); - } else if (Object.keys(data.data).length){ - collection[service][callKey][region].data.push(data.data); - } else { - collection[service][callKey][region].data = []; - } - } else { - collection[service][callKey][region].data = []; - } + return '503 Error from Google'; } + } else if (err.code === 'ERR_OSSL_PEM_NO_START_LINE') { + return 'Invalid Certificate'; + } else { + console.log(`[ERROR] Unhandled error from Google API: Error: ${JSON.stringify(err)}`); + return 'Unknown error response from Google'; + } + } else { + console.log(`[ERROR] Unhandled error from Google API: Error: ${JSON.stringify(err)}`); + return 'Unspecified Google error, please contact support'; + } +}; +var execute = async function(LocalGoogleConfig, collection, service, callObj, callKey, region, regionCb, client, options, myEngine, isPostCall = false, parentRecord = {}) { + var executorCb = function(err, data, url, postCall, parent) { + if (err) { + let errMessage = handleErrors(err); + myEngine ? collection[service][myEngine][callKey][region].err = errMessage : collection[service][callKey][region].err = errMessage; + } + if (!data) return regionCb(); + if ((myEngine && callObj.property && !data[callObj.property]) || (callObj.property && data.data && !data.data[callObj.property])) return regionCb(); + let collectionItems = []; + let resultItems = []; + collectionItems = myEngine ? collection[service][myEngine][callKey][region] : collection[service][callKey][region]; + let set = true; + if (data.data.items) { + resultItems = setData(collectionItems, data.data.items, postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (callObj.dataKey && data.data[callObj.dataKey]) { + resultItems = setData(collectionItems, data.data[callObj.dataKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (data.data.clusters && ['kubernetes', 'dataproc'].includes(service)) { + resultItems = setData(collectionItems, data.data['clusters'], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (callObj.dataKey && data.data && data.data.length && callObj.isDataArray) { + resultItems = setData(collectionItems, data.data[0][callObj.dataKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (callObj.dataFilterKey && data.data[callObj.dataFilterKey]) { + resultItems = setData(collectionItems, data.data[callObj.dataFilterKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (data.data[service]) { + resultItems = setData(collectionItems, data.data[service], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (!myEngine && data.data.accounts) { + resultItems = setData(collectionItems, data.data.accounts, postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (!myEngine && data.data.keys) { + resultItems = setData(collectionItems, data.data.keys, postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else if (callObj.ignoreMiscData) { + set = false; + myEngine ? collection[service][myEngine][callKey][region].data = [] : collection[service][callKey][region].data = []; + } else if (!myEngine && data.data) { + resultItems = setData(collection[service][callKey][region], data.data, postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); + } else { + set = false; + myEngine ? collection[service][myEngine][callKey][region].data = [] : collection[service][callKey][region].data = []; + } + if (set) { + if (myEngine) collection[service][myEngine][callKey][region] = resultItems; + else collection[service][callKey][region] = resultItems; + } + if (data.data && callObj.pagination && data.data.nextPageToken && (!callObj.maxLimit + || (callObj.maxLimit && collectionItems.data && collectionItems.data.length < callObj.maxLimit))) { + makeApiCall(client, url, executorCb, data.data.nextPageToken, { pagination: callObj.pagination, paginationKey: callObj.paginationKey, reqParams: callObj.reqParams }); + } else { if (callObj.rateLimit) { setTimeout(function() { regionCb(); @@ -304,37 +342,183 @@ var execute = function(LocalGoogleConfig, collection, service, callObj, callKey, } } }; - var parentParams; - if (callObj.nested && callObj.parent) { - parentParams = {auth: callObj.params.auth, parent: callObj.params.parent}; - executor['projects']['locations'][service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if(callObj.nested) { - parentParams = {auth: callObj.params.auth, parent: callObj.params.parent}; - executor['projects']['locations']['keyRings'][service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if (callObj.resource) { - parentParams = {auth: callObj.auth, resource_: LocalGoogleConfig.project}; - executor[service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if (callObj.serviceAccount) { - parentParams = {auth: callObj.params.auth, name: callObj.params.parent}; - executor['projects']['serviceAccounts'][service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if (callObj.parent && callObj.parent === 'name') { - parentParams = {auth: callObj.params.auth, name: callObj.params.parent}; - executor['projects'][service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if (callObj.parent) { - parentParams = {auth: callObj.params.auth, parent: callObj.params.parent}; - executor['projects'][service][callKey](parentParams, LocalGoogleConfig, executorCb); - } else if (callObj.params) { - executor[service][callKey](callObj.params, LocalGoogleConfig, executorCb); - } else { - executor[service][callKey](LocalGoogleConfig, executorCb); - } + + if (callObj.url || callObj.urlToCall) { + let url = callObj.urlToCall ? callObj.urlToCall : callObj.url; + if (region === 'global' && callObj.globalURL) { + url = callObj.globalURL; + } + url = url.replace('{projectId}', LocalGoogleConfig.project); + if (callObj.location && callObj.location == 'zone') { + url = url.replace('{locationId}', callObj.params.zone); + } else if (callObj.location && callObj.location == 'region') { + url = url.replace(/{locationId}/g, callObj.params.region); + } + makeApiCall(client, url, executorCb, null, {method: callObj.method, isPostCall, parentRecord, pagination: callObj.pagination, paginationKey: callObj.paginationKey, reqParams: callObj.reqParams, dataKey: callObj.dataKey, body: callObj.body}); + } }; +var isRateError = function(err) { + let isError = false; + var rateError = {message: 'rate', statusCode: 429}; + if (err && err.code && rateError.statusCode == err.code){ + isError = true; + } else if (err && rateError && rateError.message && err.message && + err.message.toLowerCase().indexOf(rateError.message.toLowerCase()) > -1){ + isError = true; + } + + return isError; +}; + +function makeApiCall(client, originalUrl, callCb, nextToken, config) { + let retries = []; + var apiRetryAttempts = 3; + var apiRetryBackoff = 500; + var apiRetryCap = 1000; + + let url = originalUrl; + let queryParams = ''; + if (config && config.pagination) { + queryParams = `${nextToken ? `?pageToken=${nextToken}` : ''}`; + } + if (config && config.reqParams) { + queryParams = queryParams ? `${queryParams}&${config.reqParams}` : `?${config.reqParams}`; + } + url = `${originalUrl}${queryParams}`; + async.retry({ + times: apiRetryAttempts, + interval: function(retryCount){ + let retryExponential = 3; + let retryLeveler = 3; + let timestamp = parseInt(((new Date()).getTime()).toString().slice(-1)); + let retry_temp = Math.min(apiRetryCap, (apiRetryBackoff * (retryExponential + timestamp) ** retryCount)); + let retry_seconds = Math.round(retry_temp/retryLeveler + Math.random(0, retry_temp) * 5000); + + console.log(`Trying again in: ${retry_seconds/1000} seconds`); + retries.push({seconds: Math.round(retry_seconds/1000)}); + return retry_seconds; + }, + errorFilter: function(err) { + return isRateError(err); + } + }, function(cb) { + + let request = { + url, + method: config.method ? config.method : 'GET' + }; + + if (config.body) request.body = JSON.stringify(config.body); + + client.request(request, function(err, res) { + if (err) { + cb(err, null); + } else if (res) { + callCb(null, res, originalUrl, config.isPostCall, config.parentRecord); + } + }); + }, function(err, data){ + callCb(err, data); + }); +} + +function setData(collection, dataToAdd, postCall, parent, serviceInfo) { + console.log(`[PLUGINCHECK] ${JSON.stringify(serviceInfo, null, 2)}`); + if (!serviceInfo.maxLimit || !collection.data || (serviceInfo.maxLimit && collection.data && collection.data.length < serviceInfo.maxLimit)) { + if (postCall && !!parent) { + if (dataToAdd && dataToAdd.length) { + dataToAdd.map(item => { + item.parent = parent; + return item; + }); + } else if (Object.keys(dataToAdd) && Object.keys(dataToAdd).length) { + dataToAdd.parent = parent; + } + } + if (dataToAdd.constructor.name === 'Array') { + if (Array.isArray(collection.data)) { + collection.data = collection.data.concat(dataToAdd); + } else { + collection.data = dataToAdd; + } + } else if (dataToAdd.constructor.name === 'Object' && Object.keys(dataToAdd).length) { + if (Array.isArray(collection.data)) { + collection.data.push(dataToAdd); + } else { + collection.data = [dataToAdd]; + } + } + } + return collection; +} + +function remediateOrgPolicy(config, constraintName, policyType, policyValue, resource, remediation_file, putCall, pluginName, callback) { + + // url to update org policy + var baseUrl = 'https://cloudresourcemanager.googleapis.com/v1/{resource}:setOrgPolicy'; + var method = 'POST'; + + // create the params necessary for the remediation + var body; + if (policyType == 'booleanPolicy') { + body = { + policy: { + constraint: constraintName, + booleanPolicy: { + enforced: policyValue + } + } + }; + } + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + constraintName: policyValue ? 'Disabled' : 'Enabled' + }; + + remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, callback); +} + +function remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, callback) { + makeRemediationCall(config, method, body, baseUrl, resource, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = body; + return callback(null, action); + }); +} + +function makeRemediationCall(GoogleConfig, method, body, baseUrl, resource, callCb) { + authenticate(GoogleConfig).then(client => { + if (resource) { + baseUrl = baseUrl.replace(/\{resource\}/g, resource); + } + client.request({ + url: baseUrl, + method, + data: body + }, function(err, res) { + if (err) { + callCb(err); + } else if (res && res.data) { + callCb(null); + } + }); + }); +} + var helpers = { regions: regions, MAX_REGIONS_AT_A_TIME: 6, authenticate: authenticate, - processCall: processCall + processCall: processCall, + remediatePlugin: remediatePlugin, + run: run, + remediateOrgPolicy: remediateOrgPolicy, + PROTECTION_LEVELS: ['unspecified', 'default', 'cloudcmek', 'cloudhsm', 'external'], }; for (var s in shared) helpers[s] = shared[s]; diff --git a/helpers/google/regions.js b/helpers/google/regions.js index 311b06d9e..8c68e6948 100644 --- a/helpers/google/regions.js +++ b/helpers/google/regions.js @@ -5,32 +5,60 @@ var regions = [ 'us-east4', // North Virginia 'us-west1', // Oregon 'us-west2', // Los Angeles + 'us-west3', // Salt Lake City + 'us-west4', // Las Vegas + 'us-east5', // Columbus + 'us-south1', // Dallas 'us-central1', // Iowa 'northamerica-northeast1', // Montreal + 'northamerica-northeast2', // Toronto 'southamerica-east1', // Sao Paulo + 'southamerica-west1', // Santiago 'europe-west1', // Belgium 'europe-west2', // London 'europe-west3', // Frankfurt 'europe-west4', // Netherlands 'europe-west6', // Zurich 'europe-north1', // Finland + 'europe-central2', // Warsaw + 'europe-west8', // Milan + 'europe-southwest1', // Madrid + 'europe-west9', // Paris 'asia-south1', // Mumbai + 'asia-south2', // Delhi 'asia-southeast1', // Singapore + 'asia-southeast2', // Jakarta 'asia-east1', // Taiwan 'asia-east2', // Hong Kong - 'asia-northeast1', // Tokyo - 'asia-northeast2', // Osaka - 'australia-southeast1', // Sydney + 'asia-northeast1', // Tokyo + 'asia-northeast2', // Osaka + 'asia-northeast3', // Seoul + 'australia-southeast1', // Sydney + 'australia-southeast2', // Melbourne + 'me-west1', // Tel Aviv ]; +var artifactRegions = [ + 'us', + 'europe', + 'asia' +]; + + var zones = { 'us-east1' : ['us-east1-b', 'us-east1-c', 'us-east1-d'], 'us-east4' : ['us-east4-a', 'us-east4-b', 'us-east4-c'], + 'us-east5' : ['us-east5-a', 'us-east5-b', 'us-east5-c'], + 'us-south1' : ['us-south1-a', 'us-south1-b', 'us-south1-c'], 'us-west1' : ['us-west1-a', 'us-west1-b', 'us-west1-c'], 'us-west2' : ['us-west2-a', 'us-west2-b', 'us-west2-c'], + 'us-west3' : ['us-west3-a', 'us-west3-b', 'us-west3-c'], + 'us-west4' : ['us-west4-a', 'us-west4-b', 'us-west4-c'], 'us-central1' : ['us-central1-a', 'us-central1-b', 'us-central1-c', 'us-central1-f'], 'northamerica-northeast1' : ['northamerica-northeast1-a', 'northamerica-northeast1-b', 'northamerica-northeast1-c'], + 'northamerica-northeast2' : ['northamerica-northeast2-a', 'northamerica-northeast2-b', 'northamerica-northeast2-c'], 'southamerica-east1' : ['southamerica-east1-a', 'southamerica-east1-b', 'southamerica-east1-c'], + 'southamerica-west1' : ['southamerica-west1-a', 'southamerica-west1-b', 'southamerica-west1-c'], 'europe-west1' : ['europe-west1-b', 'europe-west1-c', 'europe-west1-d'], 'europe-west2' : ['europe-west2-a', 'europe-west2-b', 'europe-west2-c'], 'europe-west3' : ['europe-west3-a', 'europe-west3-b', 'europe-west3-c'], @@ -38,42 +66,108 @@ var zones = { 'europe-west5' : ['europe-west5-a', 'europe-west5-b', 'europe-west5-c'], 'europe-west6' : ['europe-west6-a', 'europe-west6-b', 'europe-west6-c'], 'europe-north1' : ['europe-north1-a', 'europe-north1-b', 'europe-north1-c'], + 'europe-central2' : ['europe-central2-a', 'europe-central2-b', 'europe-central2-c'], + 'europe-southwest1' : ['europe-southwest1-a', 'europe-southwest1-b', 'europe-southwest1-c'], + 'europe-west8' : ['europe-west8-a', 'europe-west8-b', 'europe-west8-c'], + 'europe-west9' : ['europe-west9-a', 'europe-west9-b', 'europe-west9-c'], 'asia-south1' : ['asia-south1-a', 'asia-south1-b', 'asia-south1-c'], + 'asia-south2' : ['asia-south2-a', 'asia-south2-b', 'asia-south2-c'], 'asia-southeast1' : ['asia-southeast1-a', 'asia-southeast1-b', 'asia-southeast1-c'], + 'asia-southeast2' : ['asia-southeast2-a', 'asia-southeast2-b', 'asia-southeast2-c'], 'asia-east1' : ['asia-east1-a', 'asia-east1-b', 'asia-east1-c'], 'asia-east2' : ['asia-east2-a', 'asia-east2-b', 'asia-east2-c'], 'asia-northeast1' : ['asia-northeast1-a', 'asia-northeast1-b', 'asia-northeast1-c'], 'asia-northeast2' : ['asia-northeast2-a', 'asia-northeast2-b', 'asia-northeast2-c'], - 'australia-southeast1' : ['australia-southeast1-a', 'australia-southeast1-b', 'australia-southeast1-c'] + 'asia-northeast3' : ['asia-northeast3-a', 'asia-northeast3-b', 'asia-northeast3-c'], + 'australia-southeast1' : ['australia-southeast1-a', 'australia-southeast1-b', 'australia-southeast1-c'], + 'australia-southeast2' : ['australia-southeast2-a', 'australia-southeast2-b', 'australia-southeast2-c'], + 'me-west1' : ['me-west1-a', 'me-west1-b', 'me-west1-c'], }; module.exports = { all_regions: regions, zones: zones, - disks: regions, - keyRings: regions, - cryptoKeys: regions, + disks: ['global', ...regions], + images: ['global'], + repositories: [...regions, ...artifactRegions], + keyRings: ['global', ...regions], + cryptoKeys: ['global', ...regions], securityPolicies: ['global'], + resourcePolicies: regions, + snapshots: ['global'], firewalls: ['global'], buckets: ['global'], - instances: { - compute: regions, - sql: ['global'] - }, + compute: regions, + sql: ['global'], + spanner: ['global'], + bigtable:['global'], + composer: [ + 'us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-east1', 'us-east4', 'northamerica-northeast1', 'southamerica-east1', + 'europe-west2', 'europe-west1', 'europe-west6', 'europe-west3', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-east2', 'asia-northeast1', + 'asia-northeast2', 'australia-southeast1', 'asia-northeast3','asia-east1' + ], + instanceGroupManagers: regions, + functions: [ + 'us-east1', 'us-east4', 'us-west1','us-west2', 'us-west3', 'us-west4', 'us-central1', 'northamerica-northeast1', 'southamerica-east1', + 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west6', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-southeast2', + 'asia-east1', 'asia-east2', 'asia-northeast1', 'asia-northeast2', 'asia-northeast3', 'australia-southeast1' + ], + functionsv2: [ + 'us-east1', 'us-east4', 'us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'northamerica-northeast1', 'southamerica-east1', + 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west6', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-southeast2', + 'asia-east1', 'asia-east2', 'asia-northeast1', 'asia-northeast2', 'asia-northeast3', 'australia-southeast1' + ], + cloudbuild: ['global', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-west1', + 'northamerica-northeast1', 'northamerica-northeast2', 'southamerica-east1', 'southamerica-west1', 'europe-west1', 'europe-west2', + 'europe-west3', 'europe-west4', 'europe-west6', 'europe-central2', 'europe-north1', 'asia-south1', 'asia-south2', 'asia-southeast1', 'asia-southeast2', + 'asia-east1', 'asia-east2', 'asia-northeast1', 'asia-northeast2', 'asia-northeast3', 'australia-southeast1', 'australia-southeast2' + ], + instanceTemplates: ['global'], networks: ['global'], - backendServices: ['global'], + backendServices: ['global', ...regions], + forwardingRules: ['global', ...regions], healthChecks: ['global'], - targetHttpProxies: ['global'], + targetHttpProxies: ['global', ...regions], + targetHttpsProxies: ['global', ...regions], instanceGroups: ['global'], autoscalers: ['global'], subnetworks: regions, projects: ['global'], - clusters: ['global'], + dataproc: regions, + kubernetes: ['global'], managedZones: ['global'], metrics: ['global'], alertPolicies: ['global'], serviceAccounts: ['global'], keys: ['global'], sinks: ['global'], - users: ['global'] + users: ['global'], + backupRuns: ['global'], + datasets: ['global'], + bigqueryTables: ['global'], + policies: ['global'], + topics: ['global'], + subscriptions: ['global'], + jobs: regions, + organizations: ['global'], + folders: ['global'], + groups: ['global'], + memberships: ['global'], + iam: ['global'], + deployments: ['global'], + urlMaps: ['global',...regions], + apiKeys: ['global'], + resourceRecordSets: ['global'], + services: ['global'], + accessApproval: ['global'], + networkRoutes: ['global'], + roles: ['global'], + apiGateways: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], + api: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], + apiConfigs: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], + vertexAI: ['us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-east1', 'us-east4', 'us-south1', + 'northamerica-northeast1', 'northamerica-northeast2', 'southamerica-east1', 'southamerica-west1', 'europe-west1', + 'europe-west2', 'europe-west3', 'europe-west4', 'europe-west6', 'europe-west8', 'europe-west9', 'europe-north1', 'europe-central2', + 'europe-southwest1', 'asia-south1', 'asia-southeast1', 'asia-southeast2', 'asia-east1', 'asia-east2', 'asia-northeast1', + 'asia-northeast2', 'australia-southeast2', 'australia-southeast1', 'asia-northeast3', 'me-west1'] }; diff --git a/helpers/google/resources.js b/helpers/google/resources.js new file mode 100644 index 000000000..005624471 --- /dev/null +++ b/helpers/google/resources.js @@ -0,0 +1,142 @@ +// This file contains a list of ARN paths for each API call type +// that are used to extract ARNs for resources + +module.exports = { + alertPolicies: { + list: 'name' + }, + apiKeys: { + list: 'name', + get: 'name' + }, + autoscalers: { + aggregatedList: '' + }, + buckets: { + list: '', + getIamPolicy: '' + }, + backendServices: { + list: 'id' + }, + backupRuns: { + list: '' + }, + bigtable: { + list: 'name' + }, + compute: { + list: '', + aggregatedList: '' + }, + cryptoKeys: { + list: 'name' + }, + datasets: { + list: '', + get: '' + }, + dataproc: { + list: '' + }, + deployments: { + list: '' + }, + disks: { + aggregatedList: '', + list: '' + }, + firewalls: { + list: '' + }, + functions: { + list: 'name' + }, + functionsv2: { + list: 'name', + getIamPolicy: 'name' + }, + instanceGroups: { + aggregatedList: '' + }, + instanceGroupManagers: { + list: '' + }, + images: { + list: '', + getIamPolicy: '' + }, + jobs: { + list: '', + get: '' + }, + keys: { + list: 'name' + }, + keyRings: { + list: 'name', + }, + kubernetes: { + list: '' + }, + managedZones: { + list: '' + }, + metrics: { + list: 'metricDescriptor.name' + }, + networks: { + list: '' + }, + organizations: { + list: '', + listOrgPolicies: '' + }, + policies: { + list: '' + }, + projects:{ + getIamPolicy: '', + get: '' + }, + resourcePolicies: { + list: 'id' + }, + serviceAccounts: { + list: 'name' + }, + sinks: { + list: '' + }, + sql: { + list: 'name' + }, + spanner: { + list: '' + }, + snapshots: { + list: '' + }, + subnetworks: { + list: '' + }, + subscriptions: { + list: 'name' + }, + targetHttpProxies: { + list: 'id' + }, + topics: { + list: 'name' + }, + urlMaps: { + list: 'id' + }, + users: { + list: '' + }, + vertexAI: { + listDatasets: 'name', + listModels: 'name' + } +}; \ No newline at end of file diff --git a/helpers/oracle/api.js b/helpers/oracle/api.js new file mode 100644 index 000000000..d00da3a85 --- /dev/null +++ b/helpers/oracle/api.js @@ -0,0 +1,761 @@ +/* + enabled: send integration is enable or not + isSingleSource: whether resource is single source or not + +----------Bridge Side Data---------- + BridgeServiceName: it should be the api service name which we are storing in json file in s3 collection bucket. // this is the name of the service from the collection object Eg. 'vcn' + BridgeCall: it should be the api call which we are storing in json file in s3 collection bucket. // Eg. 'list' + BridgePluginCategoryName: it should be equivalent to Plugin Category Name as shown in plugin + BridgeProvider: it should be the cloud provider + Eg. 'aws', 'Azure', 'Google', 'Oracle' + + BridgeArnIdentifier: this is where the identifier is stored. it should be 'id' for Oracle. + + BridgeIdTemplate: no need to pass here + + BridgeResourceType: this should be type of the resource, fetch it from the id. + Eg. 'securitylist' // this comes from the id Eg. ocid1.securitylist.oc1.il-jerusalem-1.aaaaaaaa56vz65hmjox6vgzflw4lo5vcpyfgbffvlk6bt4aiac7bb4iir5ua + + BridgeResourceNameIdentifier: it should be the key of resource name/id data which we are storing in json file in s3 collection bucket. + Eg. displayName for Oracle + + Note: if there is no name then we have to pass the id. + + BridgeExecutionService: it should be equivalent to service name which we are sending from executor in payload data. // This is the service we send in the executor for plugins + BridgeCollectionService: it should be equivalent to service name which we are sending from collector in payload data. // this is how we save it in the collection Eg. 'instance' + DataIdentifier: it should be the parent key field of data which we want to collect in json file in s3 collection bucket. // should always be 'data' + +----------Processor Side Data---------- +These fields should be according to the user and product manager, what they want to show in Inventory UI. + InvAsset: 'networking' this should be the category + InvService: 'Networking' category but capitalized + InvResourceCategory: 'cloud_resources' // keep all as cloud_resources for now + InvResourceType: 'virtual_cloud_network' the specific type of resource, could also be vcn + +Note: For specific category add the category name otherwise it should be 'cloud_resource' + + Take the reference from the below map +*/ + +// Note: In Below service map add only single source resources. +// and service name should be plugin category. + +var serviceMap = { + 'Networking':[ + { + enabled: true, isSingleSource: true, InvAsset: 'networking', InvService: 'Networking', + InvResourceCategory: 'cloud_resources', InvResourceType: 'virtual_cloud_network', BridgeServiceName: 'vcn', + BridgePluginCategoryName: 'oracle-Networking', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'vcn', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Networking', + BridgeCollectionService: 'oracle-vcn', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'networking', InvService: 'Networking', + InvResourceCategory: 'cloud_resources', InvResourceType: 'security_list', BridgeServiceName: 'securitylist', + BridgePluginCategoryName: 'oracle-Networking', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'securitylist', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Networking', + BridgeCollectionService: 'oracle-securityList', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'networking', InvService: 'Networking', + InvResourceCategory: 'cloud_resources', InvResourceType: 'network_security_group', BridgeServiceName: 'networksecuritygroup', + BridgePluginCategoryName: 'oracle-Networking', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'networksecuritygroup', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Networking', + BridgeCollectionService: 'oracle-networkSecurityGroup', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'networking', InvService: 'Networking', + InvResourceCategory: 'cloud_resources', InvResourceType: 'load_balancer', BridgeServiceName: 'loadbalancer', + BridgePluginCategoryName: 'oracle-Networking', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'loadbalancer', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Networking', + BridgeCollectionService: 'oracle-loadBalancer', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'networking', InvService: 'Networking', + InvResourceCategory: 'cloud_resources', InvResourceType: 'subnet', BridgeServiceName: 'subnet', + BridgePluginCategoryName: 'oracle-Networking', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'subnet', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Networking', + BridgeCollectionService: 'oracle-subnet', DataIdentifier: 'data', + }, + ], + 'Compute': [ + { + enabled: true, isSingleSource: true, InvAsset: 'compute', InvService: 'Compute', + InvResourceCategory: 'vm', InvResourceType: 'Compute', BridgeServiceName: 'instance', + BridgePluginCategoryName: 'oracle-Compute', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'instance', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Compute', + BridgeCollectionService: 'oracle-instance', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'compute', InvService: 'Compute', + InvResourceCategory: 'vm', InvResourceType: 'Compute', BridgeServiceName: 'instancepool', + BridgePluginCategoryName: 'oracle-Compute', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'instancepool', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Compute', + BridgeCollectionService: 'oracle-instancePool', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'compute', InvService: 'Compute', + InvResourceCategory: 'cloud_resources', InvResourceType: 'boot_volume', BridgeServiceName: 'bootvolume', + BridgePluginCategoryName: 'oracle-Compute', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'bootvolume', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Compute', + BridgeCollectionService: 'oracle-bootVolume', DataIdentifier: 'data', + }, + ], + 'Block Storage': [ + { + enabled: true, isSingleSource: true, InvAsset: 'storage', InvService: 'Storage', + InvResourceCategory: 'storage', InvResourceType: 'block_volume', BridgeServiceName: 'volume', + BridgePluginCategoryName: 'oracle-Block Storage', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'volume', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Block Storage', + BridgeCollectionService: 'oracle-volume', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'storage', InvService: 'Storage', + InvResourceCategory: 'storage', InvResourceType: 'volume_group', BridgeServiceName: 'volumegroup', + BridgePluginCategoryName: 'oracle-Block Storage', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'volumegroup', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Block Storage', + BridgeCollectionService: 'oracle-volumeGroup', DataIdentifier: 'data', + }, + ], + 'File Storage': + { + enabled: true, isSingleSource: true, InvAsset: 'storage', InvService: 'Storage', + InvResourceCategory: 'storage', InvResourceType: 'file_system', BridgeServiceName: 'filesystem', + BridgePluginCategoryName: 'oracle-File Storage', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'filesystem', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-File Storage', + BridgeCollectionService: 'oracle-fileSystem', DataIdentifier: 'data', + }, + 'Object Store': + { + enabled: true, isSingleSource: true, InvAsset: 'storage', InvService: 'Storage', + InvResourceCategory: 'storage', InvResourceType: 'object_store', BridgeServiceName: 'bucket', + BridgePluginCategoryName: 'oracle-Object Store', BridgeProvider: 'Oracle', BridgeCall: 'get', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'bucket', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Object Store', + BridgeCollectionService: 'oracle-bucket', DataIdentifier: 'data', + }, + 'Vaults': + { + enabled: true, isSingleSource: true, InvAsset: 'vaults', InvService: 'Vaults', + InvResourceCategory: 'cloud_resources', InvResourceType: 'key', BridgeServiceName: 'keys', + BridgePluginCategoryName: 'oracle-Vaults', BridgeProvider: 'Oracle', BridgeCall: 'get', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'key', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Vaults', + BridgeCollectionService: 'oracle-keys', DataIdentifier: 'data', + }, + 'Identity': [ + { + enabled: true, isSingleSource: true, isIdentity: true, InvAsset: 'identity', InvService: 'Identity', + InvResourceCategory: 'identity', InvResourceType: 'user', BridgeServiceName: 'user', + BridgePluginCategoryName: 'oracle-Identity', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '{id}', BridgeResourceType: 'user', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'oracle-Identity', + BridgeCollectionService: 'oracle-user', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'identity', InvService: 'Identity', + InvResourceCategory: 'identity', InvResourceType: 'group', BridgeServiceName: 'group', + BridgePluginCategoryName: 'oracle-Identity', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '{id}', BridgeResourceType: 'group', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'oracle-Identity', + BridgeCollectionService: 'oracle-group', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'identity', InvService: 'Identity', + InvResourceCategory: 'identity', InvResourceType: 'policy', BridgeServiceName: 'policy', + BridgePluginCategoryName: 'oracle-Identity', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '{id}', BridgeResourceType: 'policy', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'oracle-Identity', + BridgeCollectionService: 'oracle-policy', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'identity', InvService: 'Identity', + InvResourceCategory: 'identity', InvResourceType: 'rule', BridgeServiceName: 'rules', + BridgePluginCategoryName: 'oracle-Identity', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'eventrule', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Identity', + BridgeCollectionService: 'oracle-rules', DataIdentifier: 'data', + } + ], + 'OKE': + { + enabled: true, isSingleSource: true, InvAsset: 'oke', InvService: 'OKE', + InvResourceCategory: 'k8s_resource', InvResourceType: 'oke_cluster', BridgeServiceName: 'cluster', + BridgePluginCategoryName: 'oracle-OKE', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'cluster', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'oracle-OKE', + BridgeCollectionService: 'oracle-cluster', DataIdentifier: 'data', + }, + 'Database': [ + { + enabled: true, isSingleSource: true, InvAsset: 'database', InvService: 'Database', + InvResourceCategory: 'database', InvResourceType: 'database', BridgeServiceName: 'database', + BridgePluginCategoryName: 'oracle-Database', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'database', + BridgeResourceNameIdentifier: 'dbName', BridgeExecutionService: 'oracle-Database', + BridgeCollectionService: 'oracle-database', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'database', InvService: 'Database', + InvResourceCategory: 'database', InvResourceType: 'db_system', BridgeServiceName: 'dbsystem', + BridgePluginCategoryName: 'oracle-Database', BridgeProvider: 'Oracle', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'dbsystem', + BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'oracle-Database', + BridgeCollectionService: 'oracle-dbSystem', DataIdentifier: 'data', + }, + ] +}; +var calls = { + // Do not use regionSubscription in Plugins + // It will be loaded automatically by the + // Oracle Collector + regionSubscription: { + list: { + api: 'iam', + filterKey: ['tenancyId'], + filterValue: ['tenancyId'], + } + }, + vcn: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Networking'][0] + + }, + logGroup: { + list: { + api: 'logging', + restVersion: '/20200531', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'] + } + }, + publicIp: { + list: { + api: 'core', + filterKey: ['compartmentId', 'scope'], + filterValue: ['compartmentId', 'REGION'], + filterLiteral: [false, true], + } + }, + instance: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Compute'][0] + }, + loadBalancer: { + list: { + api: 'loadBalance', + restVersion: '/20170115', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'] + }, + sendIntegration: serviceMap['Networking'][3] + }, + cluster: { + list: { + api: 'oke', + restVersion: '/20180222', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'] + }, + sendIntegration: serviceMap['OKE'] + }, + user: { + list: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Identity'][0] + }, + authenticationPolicy: { + get: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['tenancyId'], + filterConfig: [true] + } + }, + namespace: { + get: { + api: 'objectStore', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '', + filterConfig: [true] + } + }, + cloudguardConfiguration: { + get: { + api: 'cloudguard', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20200131', + } + }, + group: { + list: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Identity'][1] + }, + exportSummary: { + list: { + api: 'fileStorage', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20171215', + } + }, + fileSystem: { + list: { + api: 'fileStorage', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20171215', + }, + sendIntegration: serviceMap['File Storage'] + }, + mountTarget: { + list: { + api: 'fileStorage', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20171215', + } + }, + // Do not use compartment:get in Plugins + // It will be loaded automatically by the + // Oracle Collector + compartment: { + get: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + defaultTags: { + list: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20160918' + } + }, + // waasPolicy: { + // list: { + // api: 'waas', + // restVersion: '/20181116', + // filterKey: ['compartmentId'], + // filterValue: ['compartmentId'], + // } + // }, + rules: { + list: { + api: 'events', + restVersion: '/20181201', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Identity'][4] + }, + topics: { + list: { + api: 'notification', + restVersion: '/20181201', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + subscriptions: { + list: { + api: 'notification', + restVersion: '/20181201', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + policy: { + list: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Identity'][2] + }, + dbHome: { + list: { + api: 'database', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + instancePool: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Compute'][1] + }, + autoscaleConfiguration: { + list: { + api: 'autoscale', + restVersion: '/20181001', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + bootVolume: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Compute'][2] + }, + volume: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Block Storage'][0] + }, + availabilityDomain: { + list: { + api: 'iam', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + bootVolumeBackup: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + volumeBackup: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + bootVolumeAttachment: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + }, + volumeBackupPolicy: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + volumeGroup: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Block Storage'][1] + }, + volumeGroupBackup: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + } + }, + configuration: { + get: { + api: 'audit', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + filterConfig: [true] + } + }, + networkSecurityGroup: { + list: { + api: 'core', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Networking'][2] + }, + dbSystem: { + list: { + api: 'database', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + }, + sendIntegration: serviceMap['Database'][1] + }, + vault: { + list: { + api: 'kms', + filterKey: ['compartmentId'], + filterValue: ['compartmentId'], + restVersion: '/20180608', + } + }, +}; + +// Important Note: All relies must be passed in an array format [] +var postcalls = { + vcn: { + get: { + api: 'core', + reliesOnService: ['vcn'], + reliesOnCall: ['list'], + filterKey: ['vcnId'], + filterValue: ['id'], + } + }, + subnet: { + list: { + api: 'core', + reliesOnService: ['vcn'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'vcnId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + }, + sendIntegration: serviceMap['Networking'][4] + }, + securityList: { + list: { + api: 'core', + reliesOnService: ['vcn'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'vcnId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + }, + sendIntegration: serviceMap['Networking'][1] + }, + userGroupMembership: { + list: { + api: 'iam', + reliesOnService: ['group'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'groupId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + apiKey: { + list: { + api: 'iam', + reliesOnService: ['user'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'userId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + authToken: { + list: { + api: 'iam', + reliesOnService: ['user'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'userId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + customerSecretKey: { + list: { + api: 'iam', + reliesOnService: ['user'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'userId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + bucket: { + list: { + api: 'objectStore', + reliesOnService: ['namespace'], + reliesOnCall: ['get'], + filterKey: ['compartmentId','namespaceName'], + filterValue: ['compartmentId','namespaceName'], + filterConfig: [true, false], + restVersion: '', + limit: 900 + } + }, + + waasPolicy: { + get: { + api: 'waas', + restVersion: '/20181116', + reliesOnService: ['waasPolicy'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'waasPolicyId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + database: { + list: { + api: 'database', + restVersion: '/20160918', + reliesOnService: ['dbHome'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'dbHomeId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + }, + sendIntegration: serviceMap['Database'][0] + }, + securityRule: { + list: { + api: 'core', + reliesOnService: ['networkSecurityGroup'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'networkSecurityGroupId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + volumeBackupPolicyAssignment: { + volume: { + api: 'core', + reliesOnService: ['volume'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'assetId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + }, + bootVolume: { + api: 'core', + reliesOnService: ['bootVolume'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'assetId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + } + }, + keys: { + list: { + api: 'kms', + reliesOnService: ['vault'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'managementEndpoint'], + filterValue: ['compartmentId', 'managementEndpoint'], + restVersion: '/20180608' + } + }, + log: { + list: { + api: 'logging', + reliesOnService: ['logGroup'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'id'], + filterValue: ['compartmentId', 'id'], + restVersion: '/20200531' + } + }, + cluster: { + get: { + api: 'oke', + reliesOnService: ['cluster'], + reliesOnCall: ['list'], + restVersion: '/20180222', + filterKey: ['id'], + filterValue: ['id'], + filterConfig: [false] + }, + } +}; + +// Important Note: All relies must be passed in an array format [] +var finalcalls = { + bucket: { + get: { + api: 'objectStore', + reliesOnService: ['bucket'], + reliesOnCall: ['list'], + filterKey: ['bucketName', 'namespaceName'], + filterValue: ['name','namespace'], + restVersion: '', + }, + sendIntegration: serviceMap['Object Store'] + }, + keys: { + get: { + api: 'kms', + reliesOnService: ['keys'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'id'], + filterValue: ['compartmentId', 'id'], + restVersion: '/20180608' + }, + sendIntegration: serviceMap['Vaults'] + }, + keyVersions: { + list: { + api: 'kms', + reliesOnService: ['keys'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'id'], + filterValue: ['compartmentId', 'id'], + restVersion: '/20180608' + } + }, + exprt: { + get: { + api: 'fileStorage', + reliesOnService: ['exportSummary'], + reliesOnCall: ['list'], + filterKey: ['compartmentId', 'exportId'], + filterValue: ['compartmentId', 'id'], + filterConfig: [true, false], + restVersion: '/20171215', + } + }, + preAuthenticatedRequest: { + list: { + api: 'objectStore', + reliesOnService: ['bucket','namespace'], + reliesOnCall: ['list', 'get'], + filterKey: ['bucketName', 'namespaceName'], + filterValue: ['name','namespace'], + restVersion: '' + } + }, +}; + +module.exports = { + calls: calls, + postcalls: postcalls, + finalcalls: finalcalls, + serviceMap: serviceMap +}; diff --git a/helpers/oracle/functions.js b/helpers/oracle/functions.js index e8d984a3f..c074030f9 100644 --- a/helpers/oracle/functions.js +++ b/helpers/oracle/functions.js @@ -1,4 +1,5 @@ var shared = require(__dirname + '/../shared.js'); +var async = require('async'); var ipProtocol = { "tcp" : { @@ -19,7 +20,6 @@ var ipProtocol = { }; function findOpenPorts(groups, ports, service, region, results, isSecurityRule, securityGroups) { - var found = false; if (isSecurityRule) { for (var p in groups) { var messages = []; @@ -44,7 +44,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to 0.0.0.0/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } else if (permission.isValid && @@ -67,7 +66,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to 0.0.0.0/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } @@ -92,7 +90,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to ::/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } else if (permission.isValid && @@ -115,7 +112,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to ::/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } @@ -154,7 +150,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to 0.0.0.0/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } else if (permission.udpOptions && permission.source === '0.0.0.0/0') { @@ -171,7 +166,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to 0.0.0.0/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } @@ -190,7 +184,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to ::/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } else if (permission.udpOptions && permission.source === '::/0') { @@ -207,7 +200,6 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, var message = portIndex.toUpperCase() + ' port ' + port + ' open to ::/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } } @@ -219,22 +211,18 @@ function findOpenPorts(groups, ports, service, region, results, isSecurityRule, ' has ' + service + ': ' + messages.join(' and '), region, resource); } - + else { + shared.addResult(results, 0, + 'The Security List: ' + sgroups.displayName + + ' does not have ' + service + ' port open', region, + resource); + } } } - if (!found && - isSecurityRule) { - shared.addResult(results, 0, 'No open ports found in Network Security Groups', region); - } else { - shared.addResult(results, 0, 'No open ports found in Security Lists', region); - - } - return; } function findOpenPortsAll(groups, ports, service, region, results) { - var found = false; for (g in groups) { var messages = []; var sgroups = groups[g]; @@ -251,7 +239,6 @@ function findOpenPortsAll(groups, ports, service, region, results) { permission.source === '0.0.0.0/0') { message = 'all protocols open to 0.0.0.0/0'; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } else if (permission.source && permission.source === '0.0.0.0/0' && @@ -262,7 +249,6 @@ function findOpenPortsAll(groups, ports, service, region, results) { !permission.tcpOptions.destinationPortRange))) { message = `all ${ipProtocol.tcp.name} ports open to 0.0.0.0/0`; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } else if (permission.source && permission.source === '0.0.0.0/0' && @@ -273,7 +259,6 @@ function findOpenPortsAll(groups, ports, service, region, results) { !permission.udpOptions.destinationPortRange))) { message = `all ${ipProtocol.udp.name} ports open to 0.0.0.0/0`; if (messages.indexOf(message) === -1) messages.push(message); - found = true; } } @@ -283,12 +268,48 @@ function findOpenPortsAll(groups, ports, service, region, results) { ' has ' + service + ': ' + messages.join(' and '), region, resource); } + else { + shared.addResult(results, 0, + 'The Security List: ' + sgroups.displayName + + ' does not have all ports open to the public', region, + resource); + } } +} - if (!found) { - shared.addResult(results, 0, 'No public open ports found', region); +function checkEventRules(rules, eventsToCheck, displayName, compartment, region, results) { + let enabledRules = []; + let rulesFound = false; + rules.map(rule => { + if (rule.lifecycleState === 'ACTIVE' && rule.isEnabled && rule.condition) { + try { + const conditions = JSON.parse(rule.condition); + if (conditions && conditions.eventType && conditions.eventType.length) { + enabledRules = [...enabledRules, ...conditions.eventType]; + } + } + catch (err) { + return []; + } + } + }); + rulesFound = eventsToCheck.every(event => enabledRules.includes(event.value)); + let activeRules = []; + let inactiveRules = []; + if (!rulesFound) { + activeRules = eventsToCheck.filter(event => enabledRules.includes(event.value)).map(rule => rule.displayName); + inactiveRules = eventsToCheck.filter(event => !enabledRules.includes(event.value)).map(rule => rule.displayName); } + if (!rulesFound && !activeRules.length) { + shared.addResult(results, 2, `No event rules are configured for ${displayName} changes`, region, compartment); + } + else if (!rulesFound && inactiveRules.length) { + shared.addResult(results, 2, `Event rules are missing for ${displayName} ${inactiveRules.join(', ')} events`, region, compartment); + } + else if (rulesFound) { + shared.addResult(results, 0, `Event rules are configured for all ${displayName} changes`, region, compartment); + } } function checkRegionSubscription (cache, source, results, region) { @@ -312,8 +333,90 @@ function checkRegionSubscription (cache, source, results, region) { } } +function normalizePolicyStatement(policyStatement) { + let statement = policyStatement.toLowerCase(); + statement = statement.replace(' ', ' '); + let statementArr = statement.split(' '); + var statementObj = {}; + + if (statementArr[1] === 'any-user') { + statementObj['subject'] = 'any-user'; + statementObj['subjectType'] = ''; + } else { + statementObj['subject'] = statementArr.slice(2, statementArr.indexOf('to')); + statementObj['subject'] = statementObj['subject'].join(' '); + statementObj['subjectType'] = statementArr[1] + ' '; + statementObj['subject'] = statementObj['subject'].replace(',', ''); + } + + statementObj['verb'] = statementArr[statementArr.indexOf('to') + 1]; + statementObj['resourceType'] = statementArr[statementArr.indexOf('to') + 2]; + + if (statementArr[statementArr.indexOf('in') + 1] === 'tenancy') { + statementObj['location'] = statementArr[statementArr.indexOf('in') + 1] + } else { + statementObj['location'] = 'compartment ' + statementArr[statementArr.indexOf('in') + 2] + } + + if (statementArr.indexOf('where') > -1) statementObj['condition'] = statementArr.slice(statementArr.indexOf('where') + 1, statementArr.length) + return statementObj; +} + +function getProtectionLevel(cryptographickey, encryptionLevels) { + if (cryptographickey && cryptographickey.protectionMode) { + if (cryptographickey.protectionMode.toUpperCase() == 'SOFTWARE') return encryptionLevels.indexOf('cloudcmek'); + else if (cryptographickey.protectionMode.toUpperCase() == 'HSM') return encryptionLevels.indexOf('cloudhsm'); + } + + return encryptionLevels.indexOf('unspecified'); +} + +function listToObj(resultObj, listData, onKey) { + async.each(listData, function (entry, cb) { + if (entry[onKey]) resultObj[entry[onKey]] = entry; + cb(); + }); +} + +function testStatement(statementObj, resourceTypes, policyAdmins, verbs) { + let whereNames = ['request.user.id', 'request.user.name', 'request.groups.id', 'request.group.name', 'request.networkSource.name', 'target.user.name', 'request.instance.compartment.id', 'request.ad']; + + if (resourceTypes.indexOf('all-resources') === -1) resourceTypes.push('all-resources'); + + let subjectArr = statementObj['subject'].split(' '); + policyAdmins = policyAdmins.toLowerCase(); + subjectArr.forEach(subject => { + if (policyAdmins.indexOf(subject) > -1) subjectArr.splice(subjectArr.indexOf(subject), 1); + }) + + if (!subjectArr.length) return true; + statementObj['subject'] = subjectArr.join(', ') + + if (verbs && verbs.indexOf(statementObj['verb']) === -1) return true; + else if (statementObj['verb'] !== 'manage') return true; + + if (resourceTypes.indexOf(statementObj['resourceType']) === -1) return true; + + if (statementObj['condition']) { + let passingCondition = false; + whereNames.forEach(conditionName => { + if (statementObj['condition'].indexOf(conditionName) > -1) { + passingCondition = true; + } + }) + + return passingCondition; + } + + return false; +} module.exports = { findOpenPorts: findOpenPorts, findOpenPortsAll: findOpenPortsAll, - checkRegionSubscription: checkRegionSubscription + checkRegionSubscription: checkRegionSubscription, + normalizePolicyStatement: normalizePolicyStatement, + testStatement: testStatement, + getProtectionLevel: getProtectionLevel, + listToObj: listToObj, + checkEventRules: checkEventRules }; diff --git a/helpers/oracle/index.js b/helpers/oracle/index.js index 38d75f677..aac670b64 100644 --- a/helpers/oracle/index.js +++ b/helpers/oracle/index.js @@ -5,7 +5,6 @@ var govLocations = require('./regions_gov.js'); var async = require('async'); var sshpk = require('sshpk'); -var assert = require('assert-plus'); // REST Oracle var oci = require( '../../other_modules/oci' ); @@ -51,8 +50,9 @@ function OracleExecutor(OracleConfig) { } try { OracleConfig.privateKey = sshpk.parsePrivateKey(OracleConfig.keyValue, 'pem'); - assert.ok(sshpk.PrivateKey.isPrivateKey(OracleConfig.privateKey, [1, 2]), - 'options.key must be a sshpk.PrivateKey'); + if (!sshpk.PrivateKey.isPrivateKey(OracleConfig.privateKey, [1, 2])) { + throw 'options.key must be a sshpk.PrivateKey'; + } (!OracleConfig.RESTversion ? OracleConfig.RESTversion = '/20160918' : false ); } catch (e) { console.log('Could not read the Oracle Private Key.'); @@ -64,6 +64,9 @@ function OracleExecutor(OracleConfig) { if (callObj.limit) { parameters['limit'] = callObj.limit; } + + parameters['lifecycleState'] = 'AVAILABLE'; + oci(callObj.api, oracleService, callKey, OracleConfig, parameters, function(result) { if (result.code) { aggregatedErrors.push(result); @@ -93,7 +96,7 @@ function OracleExecutor(OracleConfig) { ociMany(callObj, OracleConfig); } else { for (var filter in callObj.filterKey){ - if(callObj.filterLiteral && callObj.filterLiteral[filter]) { + if (callObj.filterLiteral && callObj.filterLiteral[filter]) { parameters[callObj.filterKey[filter]] = callObj.filterValue[filter]; } else { parameters[callObj.filterKey[filter]] = OracleConfig[callObj.filterValue[filter]]; @@ -102,8 +105,9 @@ function OracleExecutor(OracleConfig) { try { OracleConfig.privateKey = sshpk.parsePrivateKey(OracleConfig.keyValue, 'pem'); - assert.ok(sshpk.PrivateKey.isPrivateKey(OracleConfig.privateKey, [1, 2]), - 'options.key must be a sshpk.PrivateKey'); + if (!sshpk.PrivateKey.isPrivateKey(OracleConfig.privateKey, [1, 2])) { + throw 'options.key must be a sshpk.PrivateKey'; + } (!OracleConfig.RESTversion ? OracleConfig.RESTversion = '/20160918' : false ); } catch (e) { @@ -133,7 +137,8 @@ function OracleExecutor(OracleConfig) { var helpers = { regions: regions, OracleExecutor: OracleExecutor, - MAX_REGIONS_AT_A_TIME: 6 + MAX_REGIONS_AT_A_TIME: 6, + PROTECTION_LEVELS: ['unspecified', 'default', 'cloudcmek', 'cloudhsm'], }; for (var s in shared) helpers[s] = shared[s]; diff --git a/helpers/oracle/regions.js b/helpers/oracle/regions.js index a83e52e5b..d36ef9306 100644 --- a/helpers/oracle/regions.js +++ b/helpers/oracle/regions.js @@ -2,6 +2,7 @@ var regions = [ 'us-ashburn-1', + 'il-jerusalem-1', 'us-phoenix-1', 'eu-frankfurt-1', 'uk-london-1', @@ -13,26 +14,50 @@ var regions = [ 'sa-saopaulo-1', 'ap-osaka-1', 'eu-zurich-1', + 'ap-melbourne-1', + 'sa-vinhedo-1', + 'ca-montreal-1', + 'sa-santiago-1', + 'ap-hyderabad-1', + 'eu-amsterdam-1', + 'me-jeddah-1', + 'ap-chuncheon-1', + 'me-dubai-1', + 'uk-cardiff-1', + 'us-sanjose-1', + 'mx-queretaro-1', + 'eu-marseille-1', + 'eu-milan-1', + 'ap-singapore-1', + 'af-johannesburg-1', + 'eu-stockholm-1' ]; module.exports = { - default: ['us-ashburn-1'], + default: ['default'], all: regions, vcn: regions, group: regions, publicIp: regions, securityList: regions, loadBalancer: regions, - user: ['us-ashburn-1'], + user: ['default'], userGroupMembership: regions, authenticationPolicy: regions, exprt: regions, exportSummary: regions, + fileSystem: regions, compartment: regions, bucket: regions, waasPolicy: regions, - policy: regions, + rules: regions, + topics: regions, + subscriptions: regions, + policy: ['default'], + defaultTags: ['default'], subnet: regions, + logGroup: regions, + log: regions, dbHome: regions, database: regions, instance: regions, @@ -45,14 +70,22 @@ module.exports = { volume: regions, volumeBackup: regions, bootVolumeAttachment: regions, - availabilityDomain: ['us-ashburn-1'], + availabilityDomain: ['default'], volumeGroup: regions, volumeGroupBackup: regions, volumeBackupPolicy: regions, volumeBackupPolicyAssignment: regions, preAuthenticatedRequest: regions, - configuration: ['us-ashburn-1'], + configuration: ['default'], dbSystem: regions, mountTarget: regions, - namespace: regions + namespace: regions, + apiKey: ['default'], + authToken: ['default'], + customerSecretKey: ['default'], + vault: regions, + keys: regions, + cluster: regions, + cloudguardConfiguration: ['default'], + keyVersions: regions, }; \ No newline at end of file diff --git a/helpers/oracle/regions_gov.js b/helpers/oracle/regions_gov.js index 6db670603..dab8dafcb 100644 --- a/helpers/oracle/regions_gov.js +++ b/helpers/oracle/regions_gov.js @@ -1,7 +1,6 @@ -// Source: https://azure.microsoft.com/en-us/global-infrastructure/services/ - var regions = [ - 'us-gov-ashburn-1' + 'us-gov-ashburn-1', + 'us-gov-phoenix-1' ]; module.exports = { diff --git a/helpers/oracle/resources.js b/helpers/oracle/resources.js new file mode 100644 index 000000000..a69bdcfd5 --- /dev/null +++ b/helpers/oracle/resources.js @@ -0,0 +1,117 @@ +// This file contains a list of ARN paths for each API call type +// that are used to extract ARNs for resources + + +module.exports = { + keys:{ + list:'id' + }, + vcn: { + list: 'id' + }, + logGroup: { + list: '' + }, + publicIp: { + list: '' + }, + instance: { + list: 'id' + }, + loadBalancer: { + list: '' + }, + cluster: { + list: 'name' + }, + user: { + list: 'id' + }, + authenticationPolicy: { + get: '' + }, + namespace: { + get: '' + }, + cloudguardConfiguration: { + get: '' + }, + group: { + list: '' + }, + exportSummary: { + list: '' + }, + fileSystem: { + list: '' + }, + mountTarget: { + list: '' + }, + defaultTags: { + list: '' + }, + waasPolicy: { + list: '' + }, + rules: { + list: '' + }, + topics: { + list: 'topicId' + }, + policy: { + list: 'id' + }, + dbHome: { + list: 'id' + }, + instancePool: { + list: 'id' + }, + autoscaleConfiguration: { + list: 'resource.id' + }, + bootVolume: { + list: 'id' + }, + volume: { + list: 'id' + }, + availabilityDomain: { + list: '' + }, + bootVolumeBackup: { + list: '' + }, + volumeBackup: { + list: 'volumeId' + }, + bootVolumeAttachment: { + list: 'bootVolumeId' + }, + volumeBackupPolicy: { + list: '' + }, + volumeGroup: { + list: 'id' + }, + volumeGroupBackup: { + list: 'volumeGroupId' + }, + configuration: { + get: 'id' + }, + networkSecurityGroup: { + list: '' + }, + dbSystem: { + list: 'id' + }, + vault: { + list: 'id' + }, + database: { + list: 'id' + } +}; \ No newline at end of file diff --git a/helpers/shared.js b/helpers/shared.js index ad5585082..1d10bf089 100644 --- a/helpers/shared.js +++ b/helpers/shared.js @@ -1,12 +1,312 @@ +var async = require('async'); + var ONE_DAY = 24*60*60*1000; +var ONE_HOUR = 60*60*1000; var daysBetween = function(date1, date2) { return Math.round(Math.abs((new Date(date1).getTime() - new Date(date2).getTime())/(ONE_DAY))); }; +var hoursBetween = function(date1, date2) { + return Math.round(Math.abs((new Date(date1).getTime() - new Date(date2).getTime())/(ONE_HOUR))); +}; + +var minutesBetween = function(date1, date2) { + return Math.round(Math.abs((new Date(date1).getTime() - new Date(date2).getTime())/(60*1000))); +}; + +var processIntegration = function(serviceName, settings, collection, calls, postcalls, debugMode, iCb) { + let localEvent = {}; + let localSettings = {}; + localSettings = settings; + + if (settings.govcloud) { + localEvent.awsOrGov = 'aws-us-gov'; + } + + localEvent.scanTriggeredFromEventsFlow = settings.scanTriggeredFromEventsFlow; + localEvent.collection = {}; + localEvent.previousCollection = {}; + localEvent.cloud_account_identifier = settings.identifier?.cloud_account_identifier; + localEvent.lastScanId = settings.lastScanId; + + localEvent.collection[serviceName.toLowerCase()] = {}; + localEvent.previousCollection[serviceName.toLowerCase()] = {}; + + localEvent.collection[serviceName.toLowerCase()] = collection[serviceName.toLowerCase()] ? collection[serviceName.toLowerCase()] : {}; + localEvent.previousCollection[serviceName.toLowerCase()] = settings.previousCollection && settings.previousCollection[serviceName.toLowerCase()] ? settings.previousCollection[serviceName.toLowerCase()] : {}; + + if (!localSettings.identifier) localSettings.identifier = {}; + localSettings.identifier.service = serviceName.toLowerCase(); + + // Single Source Fields + if (calls[serviceName] && calls[serviceName].sendIntegration && calls[serviceName].sendIntegration.isSingleSource) { + localEvent.data = calls[serviceName].sendIntegration; + } + let singleSourceArr = []; + + for (let postcall of postcalls) { + if (postcall[serviceName] && postcall[serviceName].sendIntegration && Array.isArray(postcall[serviceName].sendIntegration)) { + singleSourceArr = postcall[serviceName].sendIntegration; + break; + } else if (postcall[serviceName] && postcall[serviceName].sendIntegration && postcall[serviceName].sendIntegration.isSingleSource) { + localEvent.data = postcall[serviceName].sendIntegration; + break; + } + } + + if (singleSourceArr && singleSourceArr.length) { + async.eachLimit(singleSourceArr, 1, function(singleSourceObj, sCb) { + localEvent.data = singleSourceObj; + + processIntegrationAdditionalData(serviceName, settings, collection, calls, postcalls, localEvent.collection, function(collectionReturned){ + localEvent.collection = collectionReturned; + + processIntegrationAdditionalData(serviceName, settings, settings.previousCollection, calls, postcalls, localEvent.previousCollection, function(previousCollectionReturned){ + localEvent.previousCollection = previousCollectionReturned; + localSettings.integration(localEvent, function() { + if (debugMode) console.log(`Processed Event: ${JSON.stringify(localEvent)}`); + + return sCb(); + }); + }); + }); + }, function() { + return iCb(); + }); + } else { + processIntegrationAdditionalData(serviceName, settings, collection, calls, postcalls, localEvent.collection, function(collectionReturned){ + localEvent.collection = collectionReturned; + + processIntegrationAdditionalData(serviceName, settings, settings.previousCollection, calls, postcalls, localEvent.previousCollection, function(previousCollectionReturned){ + localEvent.previousCollection = previousCollectionReturned; + localSettings.integration(localEvent, function() { + if (debugMode) console.log(`Processed Event: ${JSON.stringify(localEvent)}`); + + return iCb(); + }); + }); + }); + + } +}; + +var processIntegrationAdditionalData = function(serviceName, localSettings, localCollection, calls, postcalls, localEventCollection, callback){ + if (!localCollection || + !Object.keys(localCollection).length || + !localCollection[serviceName.toLowerCase()] || + !Object.keys(localCollection[serviceName.toLowerCase()]).length) { + return callback(null); + } + + + let callsMap = calls[serviceName] ? Object.keys(calls[serviceName]) : null; + let foundData=[]; + + if (callsMap && callsMap.find(mycall => mycall == 'sendIntegration') && + reliesOnFound(calls, localCollection, serviceName)) { + foundData = reliesOnData(calls, localCollection, serviceName); + } + + if (callsMap && callsMap.find(mycall => mycall == 'sendIntegration') && + integrationReliesOnFound(calls, localCollection, serviceName)) { + foundData = integrationReliesOnData(calls, localCollection, serviceName); + + if (foundData && + Object.keys(foundData).length){ + for (let d of Object.keys(foundData)){ + localEventCollection[d]=foundData[d]; + } + } + } + + for (let postcall of postcalls) { + if (!postcall[serviceName]) continue; + let postCallsMap = Object.keys(postcall[serviceName]); + + foundData=[]; + + if (postCallsMap.find(mycall => mycall == 'sendIntegration') && + reliesOnFound(postcall, localCollection, serviceName)){ + foundData = reliesOnData(postcall, localCollection, serviceName); + } + + if (postCallsMap.find(mycall => mycall == 'sendIntegration') && + integrationReliesOnFound(postcall, localCollection, serviceName)){ + foundData = integrationReliesOnData(postcall, localCollection, serviceName); + + if (foundData && + Object.keys(foundData).length){ + for (let d of Object.keys(foundData)){ + localEventCollection[d]=foundData[d]; + } + } + } + } + + localSettings.identifier.service = serviceName.toLowerCase(); + return callback(localEventCollection); +}; + +var reliesOnFound = function(calls, localCollection, serviceName){ + let callsMap = Object.keys(calls[serviceName]); + + if (callsMap.find(mycall => mycall == 'sendIntegration')) { + if (calls[serviceName] && + calls[serviceName].sendIntegration && + calls[serviceName].sendIntegration.enabled && + calls[serviceName].sendIntegration.reliesOnCalls && + calls[serviceName].sendIntegration.reliesOnCalls.length) { + + let allRelies = true; + + for (let rc of calls[serviceName].sendIntegration.reliesOnCalls) { + let svc = rc.split(':')[0]; + let svcCall = rc.split(':')[1]; + if (!(localCollection[svc.toLowerCase()] && + localCollection[svc.toLowerCase()][svcCall] && + Object.keys(localCollection[svc.toLowerCase()][svcCall]) && + Object.keys(localCollection[svc.toLowerCase()][svcCall]).length>0)){ + allRelies = false; + } + } + + return allRelies; + } + } +}; + +var integrationReliesOnFound = function(calls, localCollection, serviceName){ + let callsMap = Object.keys(calls[serviceName]); + + if (callsMap.find(mycall => mycall == 'sendIntegration')) { + if (calls[serviceName] && + calls[serviceName].sendIntegration && + calls[serviceName].sendIntegration.enabled && + calls[serviceName].sendIntegration.integrationReliesOn && + calls[serviceName].sendIntegration.integrationReliesOn.serviceName && + Array.isArray(calls[serviceName].sendIntegration.integrationReliesOn.serviceName) && + calls[serviceName].sendIntegration.integrationReliesOn.serviceName.length) { + return true; + } else { + return false; + } + } +}; + +var reliesOnData = function(calls, localCollection, serviceName){ + let callsMap = Object.keys(calls[serviceName]); + + if (callsMap.find(mycall => mycall == 'sendIntegration')) { + if (calls[serviceName] && + calls[serviceName].sendIntegration && + calls[serviceName].sendIntegration.enabled && + calls[serviceName].sendIntegration.reliesOnCalls && + calls[serviceName].sendIntegration.reliesOnCalls.length) { + + let allRelies = true; + + for (let rc of calls[serviceName].sendIntegration.reliesOnCalls) { + let svc = rc.split(':')[0]; + let svcCall = rc.split(':')[1]; + if (!(localCollection[svc.toLowerCase()] && + localCollection[svc.toLowerCase()][svcCall] && + Object.keys(localCollection[svc.toLowerCase()][svcCall]) && + Object.keys(localCollection[svc.toLowerCase()][svcCall]).length>0)){ + allRelies = false; + } + + return allRelies ? localCollection[svc.toLowerCase()] : []; + } + } + } +}; + +var integrationReliesOnData = function(calls, localCollection, serviceName){ + let callsMap = Object.keys(calls[serviceName]); + + if (callsMap.find(mycall => mycall == 'sendIntegration')) { + if (localCollection && + calls[serviceName] && + calls[serviceName].sendIntegration && + calls[serviceName].sendIntegration.enabled && + calls[serviceName].sendIntegration.integrationReliesOn && + calls[serviceName].sendIntegration.integrationReliesOn.serviceName && + Array.isArray(calls[serviceName].sendIntegration.integrationReliesOn.serviceName) && + calls[serviceName].sendIntegration.integrationReliesOn.serviceName.length) { + + let serviceReliedOn = {}; + for (let serv of calls[serviceName].sendIntegration.integrationReliesOn.serviceName) { + if (localCollection[serv.toLowerCase()]) { + serviceReliedOn[serv.toLowerCase()] = localCollection[serv.toLowerCase()]; + } + } + + return serviceReliedOn; + } else { + return {}; + } + } +}; + +var callsCollected = function(serviceName, localCollection, calls, postcalls) { + var callsFoundMap = {}; + let serviceCallMap = Object.keys(localCollection[serviceName.toLowerCase()]); + + for (let call of serviceCallMap){ + if (!(localCollection[serviceName.toLowerCase()] && + localCollection[serviceName.toLowerCase()][call] && + Object.keys(localCollection[serviceName.toLowerCase()][call]) && + Object.keys(localCollection[serviceName.toLowerCase()][call]).length>0)){ + return false; + } + } + + if (calls[serviceName]) { + let callsMap = Object.keys(calls[serviceName]); + for (let checkCall of serviceCallMap) { + if (callsMap.find(mycall => mycall != 'sendIntegration' && mycall == checkCall)){ + if (reliesOnFound(calls, localCollection, serviceName)==false) return false; + + if (callsMap.find(mycall => mycall != 'sendIntegration' && mycall == checkCall) == serviceCallMap.find(mycall => mycall == checkCall)){ + callsFoundMap[checkCall]=true; + } else { + return false; + } + } + } + } + + for (let postcall of postcalls) { + if (!postcall[serviceName]) continue; + let postCallsMap = Object.keys(postcall[serviceName]); + + for (let checkCall of serviceCallMap) { + if (callsFoundMap[checkCall]) continue; + if (reliesOnFound(postcall, localCollection, serviceName)==false) return false; + + if (postCallsMap.find(mycall => mycall != 'sendIntegration' && mycall == checkCall)){ + if (!(postCallsMap.find(mycall => mycall != 'sendIntegration' && mycall == checkCall) == serviceCallMap.find(mycall => mycall == checkCall))){ + return false; + } + } + } + } + + return true; +}; + module.exports = { + callsCollected: callsCollected, + + processIntegration: processIntegration, + daysBetween: daysBetween, + hoursBetween: hoursBetween, + + minutesBetween: minutesBetween, + daysAgo: function(date) { return daysBetween(date, new Date()); }, @@ -104,5 +404,28 @@ module.exports = { objectFirstKey: function(object) { return Object.keys(object)[0]; + }, + + isValidArray: function(value){ + return (Array.isArray(value) && value.length > 0); + }, + + isValidObject: function(value){ + return (value && (typeof value === 'object') && (value.constructor === Object)); + }, + + compareVersions: function compareVersions(v1, v2) { + var s1 = v1.split('.'); + var s2 = v2.split('.'); + + for (var i = 0; i < Math.max(s1.length , s2.length); i++) { + var n1 = parseInt(s1[i] || 0, 10); + var n2 = parseInt(s2[i] || 0, 10); + + if (n1 > n2) return 1; + if (n2 > n1) return -1; + } + return 0; } }; + diff --git a/index.js b/index.js index c98e21036..05a994c11 100755 --- a/index.js +++ b/index.js @@ -3,24 +3,26 @@ const { ArgumentParser } = require('argparse'); const engine = require('./engine'); + console.log(` - _____ _ _ _____ _ _ _ - / ____| | | |/ ____| | | (_) | - | | | | ___ _ _ __| | (___ _ __ | | ___ _| |_ - | | | |/ _ \\| | | |/ _\` |\\___ \\| '_ \\| |/ _ \\| | __| - | |____| | (_) | |_| | (_| |____) | |_) | | (_) | | |_ - \\_____|_|\\___/ \\__,_|\\__,_|_____/| .__/|_|\\___/|_|\\__| - | | - |_| - - CloudSploit by Khulnasoft Security, Ltd. - Cloud security auditing for AWS, Azure, GCP, Oracle, and GitHub -`); + ________ _______ __ _ __ + / ____/ /___ __ ______/ / ___/____ / /___ (_) /_ + / / / / __ \/ / / / __ /\__ \/ __ \/ / __ \/ / __/ +/ /___/ / /_/ / /_/ / /_/ /___/ / /_/ / / /_/ / / /_ +\____/_/\____/\__,_/\__,_//____/ .___/_/\____/_/\__/ + /_/ + + ☁️ CloudExploit by KhulnaSoft Security, Ltd. + 🔐 Multi‑Cloud Security Auditing Platform + 🚀 AWS • Azure • GCP • Oracle • GitHub + + `); const parser = new ArgumentParser({}); parser.add_argument('--config', { - help: 'The path to a CloudSploit config file containing cloud credentials. See config_example.js' + help: 'The path to a CloudExploit config file containing cloud credentials. See config_example.js. ' + + 'If not provided, logic will use default AWS credential chain and will also override provided cloud' }); parser.add_argument('--compliance', { @@ -64,14 +66,26 @@ parser.add_argument('--suppress', { help: 'Suppress results matching the provided Regex. Format: pluginId:region:resourceId', action: 'append' }); +parser.add_argument('--remediate', { + help: 'Run remediation the provided plugin', + action: 'append' +}); +parser.add_argument('--cloud', { + help: 'The name of cloud to run plugins for. If not provided, logic will assume cloud from config.js file based on provided credentials', + choices: ['aws', 'azure', 'github', 'google', 'oracle','alibaba'], + action: 'append' +}); +parser.add_argument('--run-asl', { + help: 'When set, it will execute custom plugins.', + action: 'store_false' +}); let settings = parser.parse_args(); let cloudConfig = {}; -settings.cloud = 'aws'; - // Now execute the scans using the defined configuration information. if (!settings.config) { + settings.cloud = 'aws'; // AWS will handle the default credential chain without needing a credential file console.log('INFO: No config file provided, using default AWS credential chain.'); return engine(cloudConfig, settings); @@ -88,11 +102,11 @@ if (settings.compliance && settings.compliance.indexOf('cis') > -1) { settings.compliance = settings.compliance.filter(function(e) { return e !== 'cis'; }); } -console.log(`INFO: Using CloudSploit config file: ${settings.config}`); +console.log(`INFO: Using CloudExploit config file: ${settings.config}`); try { var config = require(settings.config); -} catch(e) { +} catch (e) { console.error('ERROR: Config file could not be loaded. Please ensure you have copied the config_example.js file to config.js'); process.exit(1); } @@ -117,13 +131,15 @@ function checkRequiredKeys(obj, keys) { }); } -if (config.credentials.aws.credential_file) { +if (config.credentials.aws.credential_file && (!settings.cloud || (settings.cloud == 'aws'))) { + settings.cloud = 'aws'; cloudConfig = loadHelperFile(config.credentials.aws.credential_file); if (!cloudConfig || !cloudConfig.accessKeyId || !cloudConfig.secretAccessKey) { console.error('ERROR: AWS credential file does not have accessKeyId or secretAccessKey properties'); process.exit(1); } -} else if (config.credentials.aws.access_key) { +} else if (config.credentials.aws.access_key && (!settings.cloud || (settings.cloud == 'aws'))) { + settings.cloud = 'aws'; checkRequiredKeys(config.credentials.aws, ['secret_access_key']); cloudConfig = { accessKeyId: config.credentials.aws.access_key, @@ -131,7 +147,7 @@ if (config.credentials.aws.credential_file) { sessionToken: config.credentials.aws.session_token, region: 'us-east-1' }; -} else if (config.credentials.azure.credential_file) { +} else if (config.credentials.azure.credential_file && (!settings.cloud || (settings.cloud == 'azure'))) { settings.cloud = 'azure'; cloudConfig = loadHelperFile(config.credentials.azure.credential_file); if (!cloudConfig || !cloudConfig.ApplicationID || !cloudConfig.KeyValue || !cloudConfig.DirectoryID || !cloudConfig.SubscriptionID) { @@ -139,7 +155,7 @@ if (config.credentials.aws.credential_file) { process.exit(1); } cloudConfig.location = 'East US'; -} else if (config.credentials.azure.application_id) { +} else if (config.credentials.azure.application_id && (!settings.cloud || (settings.cloud == 'azure'))) { settings.cloud = 'azure'; checkRequiredKeys(config.credentials.azure, ['key_value', 'directory_id', 'subscription_id']); cloudConfig = { @@ -147,12 +163,16 @@ if (config.credentials.aws.credential_file) { KeyValue: config.credentials.azure.key_value, DirectoryID: config.credentials.azure.directory_id, SubscriptionID: config.credentials.azure.subscription_id, - location: 'East US' + location: 'East US', + Govcloud: config.credentials.azure.govcloud, + StorageConnection: config.credentials.azure.storage_connection, + BlobContainer: config.credentials.azure.blob_container }; -} else if (config.credentials.google.credential_file) { +} else if (config.credentials.google.credential_file && (!settings.cloud || (settings.cloud == 'google'))) { settings.cloud = 'google'; cloudConfig = loadHelperFile(config.credentials.google.credential_file); -} else if (config.credentials.google.project) { + cloudConfig.project = cloudConfig.project_id; +} else if (config.credentials.google.project && (!settings.cloud || (settings.cloud == 'google'))) { settings.cloud = 'google'; checkRequiredKeys(config.credentials.google, ['client_email', 'private_key']); cloudConfig = { @@ -161,17 +181,16 @@ if (config.credentials.aws.credential_file) { client_email: config.credentials.google.client_email, private_key: config.credentials.google.private_key, }; -} else if (config.credentials.oracle.credential_file) { +} else if (config.credentials.oracle.credential_file && (!settings.cloud || (settings.cloud == 'oracle'))) { settings.cloud = 'oracle'; cloudConfig = loadHelperFile(config.credentials.oracle.credential_file); - if (!cloudConfig || !cloudConfig.tenancyId || !cloudConfig.compartmentId || !cloudConfig.userId || !cloudConfig.keyValue) { - console.error('ERROR: Oracle credential file does not have tenancyId, compartmentId, userId, or keyValue'); + if (!cloudConfig || !cloudConfig.tenancyId || !cloudConfig.compartmentId || !cloudConfig.userId || !cloudConfig.keyValue || !cloudConfig.region) { + console.error('ERROR: Oracle credential file does not have tenancyId, compartmentId, userId, region, or keyValue'); process.exit(1); } cloudConfig.RESTversion = '/20160918'; - cloudConfig.region = 'us-ashburn-1'; -} else if (config.credentials.oracle.tenancy_id) { +} else if (config.credentials.oracle.tenancy_id && (!settings.cloud || (settings.cloud == 'oracle'))) { settings.cloud = 'oracle'; checkRequiredKeys(config.credentials.oracle, ['compartment_id', 'user_id', 'key_fingerprint', 'key_value']); cloudConfig = { @@ -181,12 +200,12 @@ if (config.credentials.aws.credential_file) { userId: config.credentials.oracle.user_id, keyFingerprint: config.credentials.oracle.key_fingerprint, keyValue: config.credentials.oracle.key_value, - region: 'us-ashburn-1', + region: config.credentials.oracle.region, }; -} else if (config.credentials.github.credential_file) { +} else if (config.credentials.github.credential_file && (!settings.cloud || (settings.cloud == 'github'))) { settings.cloud = 'github'; cloudConfig = loadHelperFile(config.credentials.github.credential_file); -} else if (config.credentials.github.token) { +} else if (config.credentials.github.token && (!settings.cloud || (settings.cloud == 'github'))) { settings.cloud = 'github'; checkRequiredKeys(config.credentials.github, ['url', 'login']); cloudConfig = { @@ -195,10 +214,66 @@ if (config.credentials.aws.credential_file) { organization: config.credentials.github.organization, login: config.credentials.github.login }; +} else if (config.credentials.alibaba.credential_file && (!settings.cloud || (settings.cloud == 'alibaba'))) { + settings.cloud = 'alibaba'; + cloudConfig = loadHelperFile(config.credentials.alibaba.credential_file); +} else if (config.credentials.alibaba.access_key_id && (!settings.cloud || (settings.cloud == 'alibaba'))) { + settings.cloud = 'alibaba'; + checkRequiredKeys(config.credentials.alibaba, ['access_key_secret']); + cloudConfig = { + accessKeyId: config.credentials.alibaba.access_key_id, + accessKeySecret: config.credentials.alibaba.access_key_secret + }; } else { console.error('ERROR: Config file does not contain any valid credential configs.'); process.exit(1); } +if (settings.remediate && settings.remediate.length) { + if (!config.credentials[`${settings.cloud}_remediate`]) { + console.error('ERROR: No credentials provided for remediation.'); + process.exit(1); + } + if (config.credentials.aws_remediate && config.credentials.aws_remediate.credential_file) { + cloudConfig.remediate = loadHelperFile(config.credentials.aws_remediate.credential_file); + if (!cloudConfig.remediate || !cloudConfig.remediate.accessKeyId || !cloudConfig.remediate.secretAccessKey) { + console.error('ERROR: AWS credential file for remediation does not have accessKeyId or secretAccessKey properties'); + process.exit(1); + } + } else if (config.credentials.aws_remediate && config.credentials.aws_remediate.access_key) { + checkRequiredKeys(config.credentials.aws_remediate, ['secret_access_key']); + cloudConfig.remediate = { + accessKeyId: config.credentials.aws_remediate.access_key, + secretAccessKey: config.credentials.aws_remediate.secret_access_key, + sessionToken: config.credentials.aws_remediate.session_token + }; + } else if (config.credentials.azure_remediate && config.credentials.azure_remediate.credential_file) { + cloudConfig.remediate = loadHelperFile(config.credentials.azure_remediate.credential_file); + if (!cloudConfig.remediate || !cloudConfig.remediate.ApplicationID || !cloudConfig.remediate.KeyValue || !cloudConfig.remediate.DirectoryID || !cloudConfig.remediate.SubscriptionID) { + console.error('ERROR: Azure credential file for remediation does not have ApplicationID, KeyValue, DirectoryID, or SubscriptionID'); + process.exit(1); + } + } else if (config.credentials.azure_remediate && config.credentials.azure_remediate.application_id) { + checkRequiredKeys(config.credentials.azure_remediate, ['key_value', 'directory_id', 'subscription_id']); + cloudConfig.remediate = { + ApplicationID: config.credentials.azure_remediate.application_id, + KeyValue: config.credentials.azure_remediate.key_value, + DirectoryID: config.credentials.azure_remediate.directory_id, + SubscriptionID: config.credentials.azure_remediate.subscription_id + }; + } else if (config.credentials.google_remediate && config.credentials.google_remediate.project) { + checkRequiredKeys(config.credentials.google, ['client_email', 'private_key']); + cloudConfig = { + type: 'service_account', + project: config.credentials.google.project, + client_email: config.credentials.google.client_email, + private_key: config.credentials.google.private_key, + }; + } else { + console.error('ERROR: Config file does not contain any valid credential configs for remediation.'); + process.exit(1); + } +} + // Now execute the scans using the defined configuration information. -engine(cloudConfig, settings); +engine(cloudConfig, settings); \ No newline at end of file diff --git a/other_modules/oci/helpers.js b/other_modules/oci/helpers.js index 8802a5ef2..29e0548e9 100644 --- a/other_modules/oci/helpers.js +++ b/other_modules/oci/helpers.js @@ -83,13 +83,18 @@ function call(OracleConfig, options, callback) { request.write(requestToWrite); request.end(); }; - makeCall(newOptions); + + try { + makeCall(newOptions); + } catch (e) { + callback([]); + } } var buildHeaders = function(allowedHeaders, options) { var headers = { 'content-type': 'application/json', - 'user-agent': 'Mozilla/5.0' + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36' }; for (var h in allowedHeaders) { diff --git a/other_modules/oci/index.js b/other_modules/oci/index.js index 3571db3aa..c05a2a133 100644 --- a/other_modules/oci/index.js +++ b/other_modules/oci/index.js @@ -7,6 +7,16 @@ module.exports = function(api, service, key, OracleConfig, parameters, callback) !services[api][service][key]) return callback({code: 'Invalid API'}); var localService = services[api][service][key]; + + //replacing endpoint with managementRndpoint value from vault for keys api + if (api === 'kms' && localService.path === 'keys') { + if (!localService.secondaryPath && key === 'list') { + localService.endpoint = parameters.managementEndpoint.replace('https://', ''); + delete parameters['managementEndpoint']; + } else if (key === 'get' || (localService.secondaryPath && localService.secondaryPath === 'keyVersions')) { + localService.endpoint = localService.endpoint.replace(/^[^\s-]*(?=-)/, parameters.id.split('.')[4]); + } + } var suffix = ''; if (localService.encodedGet) { diff --git a/other_modules/oci/services.json b/other_modules/oci/services.json index 8bf2b25de..2fa331ddb 100644 --- a/other_modules/oci/services.json +++ b/other_modules/oci/services.json @@ -23,6 +23,81 @@ } } }, + "cloudguard": { + "cloudguardConfiguration": { + "get": { + "allowedQueryStrings": ["compartmentId"], + "method": "GET", + "path": "configuration", + "endpoint": "cloudguard-cp-api.{{region}}.oci.oraclecloud.com" + } + } + }, + "oke": { + "cluster": { + "list": { + "allowedQueryStrings": [ + "compartmentId", + "page", + "limit" + ], + "method": "GET", + "path": "clusters", + "endpoint": "containerengine.{{region}}.oci.oraclecloud.com" + }, + "get": { + "encodedGet": "id", + "allowedQueryStrings": [ + "compartmentId", + "page", + "limit" + ], + "method": "GET", + "path": "clusters", + "endpoint": "containerengine.{{region}}.oci.oraclecloud.com" + } + } + }, + "notification": { + "topics": { + "list": { + "allowedQueryStrings": [ + "compartmentId", + "limit", + "page" + ], + "method": "GET", + "path": "topics", + "endpoint": "notification.{{region}}.oraclecloud.com" + } + }, + "subscriptions": { + "list": { + "allowedQueryStrings": [ + "compartmentId", + "limit", + "page" + ], + "method": "GET", + "path": "subscriptions", + "endpoint": "notification.{{region}}.oraclecloud.com" + } + } + }, + "events": { + "rules": { + "list": { + "allowedQueryStrings": [ + "compartmentId", + "limit", + "page" + ], + "method": "GET", + "path": "rules", + "endpoint": "events.{{region}}.oci.oraclecloud.com" + } + } + }, "core": { "instance": { "list": { @@ -265,6 +340,16 @@ "endpoint": "filestorage.{{region}}.oraclecloud.com" } }, + "fileSystem": { + "list": { + "allowedQueryStrings": [ + "opc-request-id", "displayName", "availabilityDomain", "exportSetId", "id", "compartmentId", "limit", "page", "sortBy", "sortOrder", "lifecycleState" + ], + "method": "GET", + "path": "fileSystems", + "endpoint": "filestorage.{{region}}.oraclecloud.com" + } + }, "mountTarget": { "list": { "allowedQueryStrings": [ @@ -303,6 +388,18 @@ "endpoint": "identity.{{region}}.oraclecloud.com" } }, + "defaultTags": { + "list" : { + "allowedQueryStrings": [ + "compartmentId", + "page", + "limit" + ], + "method": "GET", + "path": "tagDefaults", + "endpoint": "identity.{{region}}.oraclecloud.com" + } + }, "group": { "list": { "allowedQueryStrings": [ @@ -351,8 +448,80 @@ "path": "userGroupMemberships", "endpoint": "identity.{{region}}.oraclecloud.com" } + }, + "apiKey": { + "list": { + "encodedGet": "userId", + "method": "GET", + "path": "users", + "secondaryPath": "apiKeys", + "endpoint": "identity.{{region}}.oraclecloud.com" + } + }, + "authToken": { + "list": { + "encodedGet": "userId", + "method": "GET", + "path": "users", + "secondaryPath": "authTokens", + "endpoint": "identity.{{region}}.oraclecloud.com" + } + }, + "customerSecretKey": { + "list": { + "encodedGet": "userId", + "method": "GET", + "path": "users", + "secondaryPath": "customerSecretKeys", + "endpoint": "identity.{{region}}.oraclecloud.com" + } + } + }, + "kms": { + "vault": { + "list": { + "allowedQueryStrings": [ + "compartmentId", "page", "limit" + ], + "method": "GET", + "path": "vaults", + "endpoint": "kms.{{region}}.oraclecloud.com" + } + }, + + "keys": { + "list": { + "allowedQueryStrings": [ + "compartmentId", "page", "limit" + ], + "method": "GET", + "path": "keys", + "endpoint": "{{managementEndpoint}}" + }, + "get": { + "allowedQueryStrings": [ + "compartmentId", "page", "limit" + ], + "encodedGet": "id", + "method": "GET", + "path": "keys", + "endpoint": "-management.kms.{{region}}.oraclecloud.com" + } + }, + "keyVersions": { + "list": { + "allowedQueryStrings": [ + "compartmentId", "page", "limit" + ], + "encodedGet": "id", + "method": "GET", + "path": "keys", + "secondaryPath": "keyVersions", + "endpoint": "-management.kms.{{region}}.oraclecloud.com" + } } }, + "loadBalance": { "loadBalancer": { "list": { @@ -366,6 +535,27 @@ } } }, + "logging": { + "logGroup": { + "list": { + "allowedQueryStrings": [ + "compartmentId", "displayname", "limit", "page", "sortBy", "sortOrder", "lifecycleState" + ], + "method": "GET", + "path": "logGroups", + "endpoint": "logging.{{region}}.oci.oraclecloud.com" + } + }, + "log": { + "list": { + "encodedGet": "id", + "method": "GET", + "path": "logGroups", + "secondaryPath": "logs", + "endpoint": "logging.{{region}}.oci.oraclecloud.com" + } + } + }, "objectStore": { "bucket": { "list": { diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 17fd4dc4c..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6567 +0,0 @@ -{ - "name": "cloudsploit", - "version": "0.0.1-dev5", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@babel/code-frame": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", - "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", - "dev": true, - "requires": { - "@babel/highlight": "^7.8.3" - } - }, - "@babel/generator": { - "version": "7.9.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.4.tgz", - "integrity": "sha512-rjP8ahaDy/ouhrvCoU1E5mqaitWrxwuNGU+dy1EpaoK48jZay4MdkskKGIMHLZNewg8sAsqpGSREJwP0zH3YQA==", - "dev": true, - "requires": { - "@babel/types": "^7.9.0", - "jsesc": "^2.5.1", - "lodash": "^4.17.13", - "source-map": "^0.5.0" - } - }, - "@babel/helper-function-name": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", - "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", - "dev": true, - "requires": { - "@babel/helper-get-function-arity": "^7.8.3", - "@babel/template": "^7.8.3", - "@babel/types": "^7.8.3" - } - }, - "@babel/helper-get-function-arity": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", - "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", - "dev": true, - "requires": { - "@babel/types": "^7.8.3" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", - "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", - "dev": true, - "requires": { - "@babel/types": "^7.8.3" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", - "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", - "dev": true - }, - "@babel/highlight": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", - "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.9.0", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "@babel/parser": { - "version": "7.9.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.4.tgz", - "integrity": "sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==", - "dev": true - }, - "@babel/template": { - "version": "7.8.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", - "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@babel/parser": "^7.8.6", - "@babel/types": "^7.8.6" - } - }, - "@babel/traverse": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", - "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@babel/generator": "^7.9.0", - "@babel/helper-function-name": "^7.8.3", - "@babel/helper-split-export-declaration": "^7.8.3", - "@babel/parser": "^7.9.0", - "@babel/types": "^7.9.0", - "debug": "^4.1.0", - "globals": "^11.1.0", - "lodash": "^4.17.13" - } - }, - "@babel/types": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", - "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.9.0", - "lodash": "^4.17.13", - "to-fast-properties": "^2.0.0" - } - }, - "@octokit/app": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@octokit/app/-/app-3.0.2.tgz", - "integrity": "sha512-+roaPRJiJz8a4msGtV6YwJitOEXbBRLlneAjnMFzGmqLT8yJAqJCd/k9LfTVPyr7pbl/IhEDl6cMDJrTcxlCeg==", - "requires": { - "@octokit/request": "^5.0.0", - "jsonwebtoken": "^8.3.0", - "lru-cache": "^5.1.1" - }, - "dependencies": { - "@octokit/request": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.3.4.tgz", - "integrity": "sha512-qyj8G8BxQyXjt9Xu6NvfvOr1E0l35lsXtwm3SopsYg/JWXjlsnwqLc8rsD2OLguEL/JjLfBvrXr4az7z8Lch2A==", - "requires": { - "@octokit/endpoint": "^6.0.0", - "@octokit/request-error": "^2.0.0", - "@octokit/types": "^2.0.0", - "deprecation": "^2.0.0", - "is-plain-object": "^3.0.0", - "node-fetch": "^2.3.0", - "once": "^1.4.0", - "universal-user-agent": "^5.0.0" - } - } - } - }, - "@octokit/auth-token": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.0.tgz", - "integrity": "sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg==", - "requires": { - "@octokit/types": "^2.0.0" - } - }, - "@octokit/endpoint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.0.tgz", - "integrity": "sha512-3nx+MEYoZeD0uJ+7F/gvELLvQJzLXhep2Az0bBSXagbApDvDW0LWwpnAIY/hb0Jwe17A0fJdz0O12dPh05cj7A==", - "requires": { - "@octokit/types": "^2.0.0", - "is-plain-object": "^3.0.0", - "universal-user-agent": "^5.0.0" - } - }, - "@octokit/plugin-paginate-rest": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz", - "integrity": "sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==", - "requires": { - "@octokit/types": "^2.0.1" - } - }, - "@octokit/plugin-request-log": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.0.tgz", - "integrity": "sha512-ywoxP68aOT3zHCLgWZgwUJatiENeHE7xJzYjfz8WI0goynp96wETBF+d95b8g/uL4QmS6owPVlaxiz3wyMAzcw==" - }, - "@octokit/plugin-rest-endpoint-methods": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz", - "integrity": "sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==", - "requires": { - "@octokit/types": "^2.0.1", - "deprecation": "^2.3.1" - } - }, - "@octokit/request": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-3.0.3.tgz", - "integrity": "sha512-M7pUfsiaiiUMEP4/SMysTeWxyGrkoQg6FBPEtCBIFgeDnzHaPboTpUZGTh6u1GQXdrlzMfPVn/vQs98js1QtwQ==", - "requires": { - "@octokit/endpoint": "^5.1.0", - "deprecation": "^1.0.1", - "is-plain-object": "^3.0.0", - "node-fetch": "^2.3.0", - "once": "^1.4.0", - "universal-user-agent": "^2.0.1" - }, - "dependencies": { - "@octokit/endpoint": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-5.5.3.tgz", - "integrity": "sha512-EzKwkwcxeegYYah5ukEeAI/gYRLv2Y9U5PpIsseGSFDk+G3RbipQGBs8GuYS1TLCtQaqoO66+aQGtITPalxsNQ==", - "requires": { - "@octokit/types": "^2.0.0", - "is-plain-object": "^3.0.0", - "universal-user-agent": "^5.0.0" - }, - "dependencies": { - "universal-user-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz", - "integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==", - "requires": { - "os-name": "^3.1.0" - } - } - } - }, - "deprecation": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-1.0.1.tgz", - "integrity": "sha512-ccVHpE72+tcIKaGMql33x5MAjKQIZrk+3x2GbJ7TeraUCZWHoT+KSZpoC+JQFsUBlSTXUrBaGiF0j6zVTepPLg==" - }, - "universal-user-agent": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-2.1.0.tgz", - "integrity": "sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==", - "requires": { - "os-name": "^3.0.0" - } - } - } - }, - "@octokit/request-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.0.tgz", - "integrity": "sha512-rtYicB4Absc60rUv74Rjpzek84UbVHGHJRu4fNVlZ1mCcyUPPuzFfG9Rn6sjHrd95DEsmjSt1Axlc699ZlbDkw==", - "requires": { - "@octokit/types": "^2.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "@octokit/rest": { - "version": "16.43.1", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.1.tgz", - "integrity": "sha512-gfFKwRT/wFxq5qlNjnW2dh+qh74XgTQ2B179UX5K1HYCluioWj8Ndbgqw2PVqa1NnVJkGHp2ovMpVn/DImlmkw==", - "requires": { - "@octokit/auth-token": "^2.4.0", - "@octokit/plugin-paginate-rest": "^1.1.1", - "@octokit/plugin-request-log": "^1.0.0", - "@octokit/plugin-rest-endpoint-methods": "2.4.0", - "@octokit/request": "^5.2.0", - "@octokit/request-error": "^1.0.2", - "atob-lite": "^2.0.0", - "before-after-hook": "^2.0.0", - "btoa-lite": "^1.0.0", - "deprecation": "^2.0.0", - "lodash.get": "^4.4.2", - "lodash.set": "^4.3.2", - "lodash.uniq": "^4.5.0", - "octokit-pagination-methods": "^1.1.0", - "once": "^1.4.0", - "universal-user-agent": "^4.0.0" - }, - "dependencies": { - "@octokit/request": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.3.4.tgz", - "integrity": "sha512-qyj8G8BxQyXjt9Xu6NvfvOr1E0l35lsXtwm3SopsYg/JWXjlsnwqLc8rsD2OLguEL/JjLfBvrXr4az7z8Lch2A==", - "requires": { - "@octokit/endpoint": "^6.0.0", - "@octokit/request-error": "^2.0.0", - "@octokit/types": "^2.0.0", - "deprecation": "^2.0.0", - "is-plain-object": "^3.0.0", - "node-fetch": "^2.3.0", - "once": "^1.4.0", - "universal-user-agent": "^5.0.0" - }, - "dependencies": { - "@octokit/request-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.0.tgz", - "integrity": "sha512-rtYicB4Absc60rUv74Rjpzek84UbVHGHJRu4fNVlZ1mCcyUPPuzFfG9Rn6sjHrd95DEsmjSt1Axlc699ZlbDkw==", - "requires": { - "@octokit/types": "^2.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "universal-user-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz", - "integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==", - "requires": { - "os-name": "^3.1.0" - } - } - } - }, - "@octokit/request-error": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz", - "integrity": "sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==", - "requires": { - "@octokit/types": "^2.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "universal-user-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz", - "integrity": "sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==", - "requires": { - "os-name": "^3.1.0" - } - } - } - }, - "@octokit/types": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.5.1.tgz", - "integrity": "sha512-q4Wr7RexkPRrkQpXzUYF5Fj/14Mr65RyOHj6B9d/sQACpqGcStkHZj4qMEtlMY5SnD/69jlL9ItGPbDM0dR/dA==", - "requires": { - "@types/node": ">= 8" - } - }, - "@types/color-name": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" - }, - "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" - }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, - "abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "requires": { - "event-target-shim": "^5.0.0" - } - }, - "acorn": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz", - "integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==", - "dev": true - }, - "acorn-jsx": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz", - "integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==", - "dev": true - }, - "adal-node": { - "version": "0.1.28", - "resolved": "https://registry.npmjs.org/adal-node/-/adal-node-0.1.28.tgz", - "integrity": "sha1-RoxLs+u9lrEnBmn0ucuk4AZepIU=", - "requires": { - "@types/node": "^8.0.47", - "async": ">=0.6.0", - "date-utils": "*", - "jws": "3.x.x", - "request": ">= 2.52.0", - "underscore": ">= 1.3.1", - "uuid": "^3.1.0", - "xmldom": ">= 0.1.x", - "xpath.js": "~1.1.0" - }, - "dependencies": { - "@types/node": { - "version": "8.10.59", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.59.tgz", - "integrity": "sha512-8RkBivJrDCyPpBXhVZcjh7cQxVBSmRk9QM7hOketZzp6Tg79c0N8kkpAIito9bnJ3HCVCHVYz+KHTEbfQNfeVQ==" - } - } - }, - "agent-base": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.0.tgz", - "integrity": "sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==", - "requires": { - "debug": "4" - } - }, - "ajv": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz", - "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-align": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", - "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=", - "dev": true, - "requires": { - "string-width": "^2.0.0" - } - }, - "ansi-colors": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", - "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", - "dev": true - }, - "ansi-escapes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", - "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", - "dev": true, - "requires": { - "type-fest": "^0.11.0" - }, - "dependencies": { - "type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true - } - } - }, - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "anymatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", - "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", - "dev": true, - "requires": { - "micromatch": "^3.1.4", - "normalize-path": "^2.1.1" - }, - "dependencies": { - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } - } - } - }, - "append-transform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", - "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", - "dev": true, - "requires": { - "default-require-extensions": "^2.0.0" - } - }, - "archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", - "dev": true - }, - "argparse": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.0.tgz", - "integrity": "sha512-mEKF1/WpTsblaqx7NIkcsTxwDzvJuGH5sdUqDNcJS+vXCWe+yM/o4cs/Q2/GFESAYg+O7UouEmz+iBqmKofI/Q==" - }, - "arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", - "dev": true - }, - "arr-flatten": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", - "dev": true - }, - "arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", - "dev": true - }, - "array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true - }, - "array.prototype.flat": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz", - "integrity": "sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1" - } - }, - "arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" - }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", - "dev": true - }, - "astral-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", - "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "dev": true - }, - "async": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", - "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", - "requires": { - "lodash": "^4.17.14" - } - }, - "async-each": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", - "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", - "dev": true - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true - }, - "atob-lite": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", - "integrity": "sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=" - }, - "aws-sdk": { - "version": "2.653.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.653.0.tgz", - "integrity": "sha512-vtpHfoAKoudNa5kknUgQeXzdnmkI63hqKYHuk5u7mx0HelP8iybTxmKfKENlOvkfKtBdCEbcmJRa3DxZUbQPHQ==", - "requires": { - "buffer": "4.9.1", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.15.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "uuid": "3.3.2", - "xml2js": "0.4.19" - } - }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" - }, - "aws4": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", - "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==" - }, - "azure-storage": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/azure-storage/-/azure-storage-2.10.3.tgz", - "integrity": "sha512-IGLs5Xj6kO8Ii90KerQrrwuJKexLgSwYC4oLWmc11mzKe7Jt2E5IVg+ZQ8K53YWZACtVTMBNO3iGuA+4ipjJxQ==", - "requires": { - "browserify-mime": "~1.2.9", - "extend": "^3.0.2", - "json-edm-parser": "0.1.2", - "md5.js": "1.3.4", - "readable-stream": "~2.0.0", - "request": "^2.86.0", - "underscore": "~1.8.3", - "uuid": "^3.0.0", - "validator": "~9.4.1", - "xml2js": "0.2.8", - "xmlbuilder": "^9.0.7" - }, - "dependencies": { - "sax": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz", - "integrity": "sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE=" - }, - "underscore": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", - "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" - }, - "xml2js": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz", - "integrity": "sha1-m4FpCTFjH/CdGVdUn69U9PmAs8I=", - "requires": { - "sax": "0.5.x" - } - } - } - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" - }, - "base": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", - "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", - "dev": true, - "requires": { - "cache-base": "^1.0.1", - "class-utils": "^0.3.5", - "component-emitter": "^1.2.1", - "define-property": "^1.0.0", - "isobject": "^3.0.1", - "mixin-deep": "^1.2.0", - "pascalcase": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "base64-js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" - }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "requires": { - "tweetnacl": "^0.14.3" - } - }, - "before-after-hook": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz", - "integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==" - }, - "bignumber.js": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", - "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" - }, - "binary-extensions": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", - "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", - "dev": true - }, - "bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, - "requires": { - "file-uri-to-path": "1.0.0" - } - }, - "boxen": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz", - "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==", - "dev": true, - "requires": { - "ansi-align": "^2.0.0", - "camelcase": "^4.0.0", - "chalk": "^2.0.1", - "cli-boxes": "^1.0.0", - "string-width": "^2.0.0", - "term-size": "^1.2.0", - "widest-line": "^2.0.0" - }, - "dependencies": { - "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", - "dev": true - } - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "breakword": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/breakword/-/breakword-1.0.5.tgz", - "integrity": "sha512-ex5W9DoOQ/LUEU3PMdLs9ua/CYZl1678NUkKOdUSi8Aw5F1idieaiRURCBFJCwVcrD1J8Iy3vfWSloaMwO2qFg==", - "requires": { - "wcwidth": "^1.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "browserify-mime": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/browserify-mime/-/browserify-mime-1.2.9.tgz", - "integrity": "sha1-rrGvKN5sDXpqLOQK22j/GEIq8x8=" - }, - "btoa-lite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", - "integrity": "sha1-M3dm2hWAEhD92VbCLpxokaudAzc=" - }, - "buffer": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", - "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, - "cache-base": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", - "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, - "requires": { - "collection-visit": "^1.0.0", - "component-emitter": "^1.2.1", - "get-value": "^2.0.6", - "has-value": "^1.0.0", - "isobject": "^3.0.1", - "set-value": "^2.0.0", - "to-object-path": "^0.3.0", - "union-value": "^1.0.0", - "unset-value": "^1.0.0" - }, - "dependencies": { - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "caching-transform": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-3.0.2.tgz", - "integrity": "sha512-Mtgcv3lh3U0zRii/6qVgQODdPA4G3zhG+jtbCWj39RXuUFTMzH0vcdMtaJS1jPowd+It2Pqr6y3NJMQqOqCE2w==", - "dev": true, - "requires": { - "hasha": "^3.0.0", - "make-dir": "^2.0.0", - "package-hash": "^3.0.0", - "write-file-atomic": "^2.4.2" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - }, - "capture-stack-trace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz", - "integrity": "sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==", - "dev": true - }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "chai": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", - "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "pathval": "^1.1.0", - "type-detect": "^4.0.5" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "dependencies": { - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, - "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true - }, - "chokidar": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", - "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", - "dev": true, - "requires": { - "anymatch": "^2.0.0", - "async-each": "^1.0.1", - "braces": "^2.3.2", - "fsevents": "^1.2.7", - "glob-parent": "^3.1.0", - "inherits": "^2.0.3", - "is-binary-path": "^1.0.0", - "is-glob": "^4.0.0", - "normalize-path": "^3.0.0", - "path-is-absolute": "^1.0.0", - "readdirp": "^2.2.1", - "upath": "^1.1.1" - } - }, - "ci-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "dev": true - }, - "class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, - "requires": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "cli-boxes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", - "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=", - "dev": true - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true - }, - "cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "requires": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" - }, - "collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", - "dev": true, - "requires": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", - "dev": true - }, - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "configstore": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", - "integrity": "sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==", - "dev": true, - "requires": { - "dot-prop": "^4.1.0", - "graceful-fs": "^4.1.2", - "make-dir": "^1.0.0", - "unique-string": "^1.0.0", - "write-file-atomic": "^2.0.0", - "xdg-basedir": "^3.0.0" - } - }, - "convert-source-map": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", - "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - } - } - }, - "copy-descriptor": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", - "dev": true - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - }, - "cp-file": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-6.2.0.tgz", - "integrity": "sha512-fmvV4caBnofhPe8kOcitBwSn2f39QLjnAnGq3gO9dfd75mUytzKNZB1hde6QHunW2Rt+OwuBOMc3i1tNElbszA==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "make-dir": "^2.0.0", - "nested-error-stacks": "^2.0.0", - "pify": "^4.0.1", - "safe-buffer": "^5.0.1" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } - } - }, - "create-error-class": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz", - "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=", - "dev": true, - "requires": { - "capture-stack-trace": "^1.0.0" - } - }, - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "crypto-random-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", - "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", - "dev": true - }, - "csv": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/csv/-/csv-5.3.2.tgz", - "integrity": "sha512-odDyucr9OgJTdGM2wrMbJXbOkJx3nnUX3Pt8SFOwlAMOpsUQlz1dywvLMXJWX/4Ib0rjfOsaawuuwfI5ucqBGQ==", - "requires": { - "csv-generate": "^3.2.4", - "csv-parse": "^4.8.8", - "csv-stringify": "^5.3.6", - "stream-transform": "^2.0.1" - } - }, - "csv-generate": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-3.2.4.tgz", - "integrity": "sha512-qNM9eqlxd53TWJeGtY1IQPj90b563Zx49eZs8e0uMyEvPgvNVmX1uZDtdzAcflB3PniuH9creAzcFOdyJ9YGvA==" - }, - "csv-parse": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.12.0.tgz", - "integrity": "sha512-wPQl3H79vWLPI8cgKFcQXl0NBgYYEqVnT1i6/So7OjMpsI540oD7p93r3w6fDSyPvwkTepG05F69/7AViX2lXg==" - }, - "csv-stringify": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.5.1.tgz", - "integrity": "sha512-HM0/86Ks8OwFbaYLd495tqTs1NhscZL52dC4ieKYumy8+nawQYC0xZ63w1NqLf0M148T2YLYqowoImc1giPn0g==" - }, - "csv-write-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/csv-write-stream/-/csv-write-stream-2.0.0.tgz", - "integrity": "sha1-/C2iGkjW6l+MF/3jnPuRHk8CkrA=", - "requires": { - "argparse": "^1.0.7", - "generate-object-property": "^1.0.0", - "ndjson": "^1.3.0" - }, - "dependencies": { - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "requires": { - "sprintf-js": "~1.0.2" - } - } - } - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "date-utils": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/date-utils/-/date-utils-1.2.21.tgz", - "integrity": "sha1-YfsWzcEnSzyayq/+n8ad+HIKK2Q=" - }, - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "requires": { - "ms": "^2.1.1" - } - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" - }, - "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true - }, - "deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true - }, - "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", - "dev": true - }, - "default-require-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", - "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", - "dev": true, - "requires": { - "strip-bom": "^3.0.0" - } - }, - "defaults": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", - "requires": { - "clone": "^1.0.2" - } - }, - "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "requires": { - "object-keys": "^1.0.12" - } - }, - "define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "requires": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "dependencies": { - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "deprecation": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", - "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" - }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "dot-prop": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.1.tgz", - "integrity": "sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==", - "dev": true, - "requires": { - "is-obj": "^1.0.0" - } - }, - "duplexer": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", - "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=" - }, - "duplexer3": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", - "dev": true - }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "requires": { - "once": "^1.4.0" - } - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "es-abstract": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", - "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", - "requires": { - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.1.5", - "is-regex": "^1.0.5", - "object-inspect": "^1.7.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.0", - "string.prototype.trimleft": "^2.1.1", - "string.prototype.trimright": "^2.1.1" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "eslint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", - "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "ajv": "^6.10.0", - "chalk": "^2.1.0", - "cross-spawn": "^6.0.5", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^1.4.3", - "eslint-visitor-keys": "^1.1.0", - "espree": "^6.1.2", - "esquery": "^1.0.1", - "esutils": "^2.0.2", - "file-entry-cache": "^5.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", - "globals": "^12.1.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "inquirer": "^7.0.0", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.3.0", - "lodash": "^4.17.14", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "optionator": "^0.8.3", - "progress": "^2.0.0", - "regexpp": "^2.0.1", - "semver": "^6.1.2", - "strip-ansi": "^5.2.0", - "strip-json-comments": "^3.0.1", - "table": "^5.2.3", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", - "dev": true, - "requires": { - "type-fest": "^0.8.1" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - } - } - }, - "eslint-scope": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz", - "integrity": "sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==", - "dev": true, - "requires": { - "esrecurse": "^4.1.0", - "estraverse": "^4.1.1" - } - }, - "eslint-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", - "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - }, - "dependencies": { - "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - } - } - }, - "esrecurse": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", - "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", - "dev": true, - "requires": { - "estraverse": "^4.1.0" - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" - }, - "events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" - }, - "execa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", - "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "requires": { - "cross-spawn": "^6.0.0", - "get-stream": "^4.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, - "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", - "dev": true, - "requires": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, - "extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "requires": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", - "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "fast-safe-stringify": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", - "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" - }, - "fast-text-encoding": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.1.tgz", - "integrity": "sha512-x4FEgaz3zNRtJfLFqJmHWxkMDDvXVtaznj2V9jiP8ACUJrUgist4bP9FmDL2Vew2Y9mEQI/tG4GqabaitYp9CQ==" - }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - } - }, - "file-entry-cache": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", - "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", - "dev": true, - "requires": { - "flat-cache": "^2.0.1" - } - }, - "file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true - }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "find-cache-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", - "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", - "dev": true, - "requires": { - "commondir": "^1.0.1", - "make-dir": "^2.0.0", - "pkg-dir": "^3.0.0" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } - } - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "flat": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz", - "integrity": "sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==", - "dev": true, - "requires": { - "is-buffer": "~2.0.3" - }, - "dependencies": { - "is-buffer": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", - "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", - "dev": true - } - } - }, - "flat-cache": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", - "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", - "dev": true, - "requires": { - "flatted": "^2.0.0", - "rimraf": "2.6.3", - "write": "1.0.3" - }, - "dependencies": { - "rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - } - } - }, - "flatted": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true - }, - "for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", - "dev": true - }, - "foreground-child": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", - "integrity": "sha1-T9ca0t/elnibmApcCilZN8svXOk=", - "dev": true, - "requires": { - "cross-spawn": "^4", - "signal-exit": "^3.0.0" - }, - "dependencies": { - "cross-spawn": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", - "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "which": "^1.2.9" - } - }, - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - } - } - }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" - }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "fragment-cache": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", - "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", - "dev": true, - "requires": { - "map-cache": "^0.2.2" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.12.tgz", - "integrity": "sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q==", - "dev": true, - "optional": true, - "requires": { - "bindings": "^1.5.0", - "nan": "^2.12.1", - "node-pre-gyp": "*" - }, - "dependencies": { - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "optional": true - }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true, - "optional": true - }, - "aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "dev": true, - "optional": true - }, - "are-we-there-yet": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", - "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", - "dev": true, - "optional": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.6" - } - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true, - "optional": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "optional": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "optional": true - }, - "code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true, - "optional": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "optional": true - }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true, - "optional": true - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true, - "optional": true - }, - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "optional": true, - "requires": { - "ms": "^2.1.1" - } - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "optional": true - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", - "dev": true, - "optional": true - }, - "detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", - "dev": true, - "optional": true - }, - "fs-minipass": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", - "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^2.6.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true, - "optional": true - }, - "gauge": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", - "dev": true, - "optional": true, - "requires": { - "aproba": "^1.0.3", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.0", - "object-assign": "^4.1.0", - "signal-exit": "^3.0.0", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wide-align": "^1.1.0" - } - }, - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "optional": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", - "dev": true, - "optional": true - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ignore-walk": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", - "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", - "dev": true, - "optional": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "optional": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "optional": true - }, - "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", - "dev": true, - "optional": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dev": true, - "optional": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true, - "optional": true - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "optional": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true, - "optional": true - }, - "minipass": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", - "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - }, - "minizlib": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", - "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^2.9.0" - } - }, - "mkdirp": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.3.tgz", - "integrity": "sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg==", - "dev": true, - "optional": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "optional": true - }, - "needle": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.3.tgz", - "integrity": "sha512-EkY0GeSq87rWp1hoq/sH/wnTWgFVhYlnIkbJ0YJFfRgEFlz2RraCjBpFQ+vrEgEdp0ThfyHADmkChEhcb7PKyw==", - "dev": true, - "optional": true, - "requires": { - "debug": "^3.2.6", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - } - }, - "node-pre-gyp": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz", - "integrity": "sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==", - "dev": true, - "optional": true, - "requires": { - "detect-libc": "^1.0.2", - "mkdirp": "^0.5.1", - "needle": "^2.2.1", - "nopt": "^4.0.1", - "npm-packlist": "^1.1.6", - "npmlog": "^4.0.2", - "rc": "^1.2.7", - "rimraf": "^2.6.1", - "semver": "^5.3.0", - "tar": "^4.4.2" - } - }, - "nopt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", - "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", - "dev": true, - "optional": true, - "requires": { - "abbrev": "1", - "osenv": "^0.1.4" - } - }, - "npm-bundled": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz", - "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==", - "dev": true, - "optional": true, - "requires": { - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-normalize-package-bin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", - "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", - "dev": true, - "optional": true - }, - "npm-packlist": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", - "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", - "dev": true, - "optional": true, - "requires": { - "ignore-walk": "^3.0.1", - "npm-bundled": "^1.0.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npmlog": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", - "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", - "dev": true, - "optional": true, - "requires": { - "are-we-there-yet": "~1.1.2", - "console-control-strings": "~1.1.0", - "gauge": "~2.7.3", - "set-blocking": "~2.0.0" - } - }, - "number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true, - "optional": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true, - "optional": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "optional": true, - "requires": { - "wrappy": "1" - } - }, - "os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", - "dev": true, - "optional": true - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true, - "optional": true - }, - "osenv": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", - "dev": true, - "optional": true, - "requires": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "optional": true - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "optional": true - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "optional": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - } - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "optional": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "optional": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "optional": true - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "optional": true - }, - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", - "dev": true, - "optional": true - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true, - "optional": true - }, - "signal-exit": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", - "dev": true, - "optional": true - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dev": true, - "optional": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "optional": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true, - "optional": true - }, - "tar": { - "version": "4.4.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", - "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", - "dev": true, - "optional": true, - "requires": { - "chownr": "^1.1.1", - "fs-minipass": "^1.2.5", - "minipass": "^2.8.6", - "minizlib": "^1.2.1", - "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.2", - "yallist": "^3.0.3" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true, - "optional": true - }, - "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "optional": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true, - "optional": true - }, - "yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "optional": true - } - } - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "gaxios": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.3.4.tgz", - "integrity": "sha512-US8UMj8C5pRnao3Zykc4AAVr+cffoNKRTg9Rsf2GiuZCW69vgJj38VK2PzlPuQU73FZ/nTk9/Av6/JGcE1N9vA==", - "requires": { - "abort-controller": "^3.0.0", - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.3.0" - }, - "dependencies": { - "is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" - } - } - }, - "gcp-metadata": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-2.0.4.tgz", - "integrity": "sha512-p1lXhJvcKvJHWfQXhkd4Za1kyXRsGZA0JH7Cjs07W9hrg84d/j5tqQhbGewlSLx9gNyuQUid69uLux48YbggLg==", - "requires": { - "gaxios": "^2.0.1", - "json-bigint": "^0.3.0" - } - }, - "generate-object-property": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", - "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", - "requires": { - "is-property": "^1.0.0" - } - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true - }, - "get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "requires": { - "pump": "^3.0.0" - } - }, - "get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", - "dev": true - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", - "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", - "dev": true, - "requires": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - }, - "dependencies": { - "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "requires": { - "is-extglob": "^2.1.0" - } - } - } - }, - "global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", - "dev": true, - "requires": { - "ini": "^1.3.4" - } - }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, - "google-auth-library": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-4.2.6.tgz", - "integrity": "sha512-oJ6tCA9rbsYeIVY+mcLPFHa2hatz3XO6idYIrlI/KhhlMxZrO3tKyU8O2Pxu5KnSBBP7Wj4HtbM1LLKngNFaFw==", - "requires": { - "arrify": "^2.0.0", - "base64-js": "^1.3.0", - "fast-text-encoding": "^1.0.0", - "gaxios": "^2.0.0", - "gcp-metadata": "^2.0.0", - "gtoken": "^3.0.0", - "jws": "^3.1.5", - "lru-cache": "^5.0.0" - } - }, - "google-p12-pem": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.4.tgz", - "integrity": "sha512-S4blHBQWZRnEW44OcR7TL9WR+QCqByRvhNDZ/uuQfpxywfupikf/miba8js1jZi6ZOGv5slgSuoshCWh6EMDzg==", - "requires": { - "node-forge": "^0.9.0" - } - }, - "googleapis": { - "version": "40.0.1", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-40.0.1.tgz", - "integrity": "sha512-B6qZVCautOOspEhru9GZ814I+ztkGWyA4ZEUfaXwXHBruX/HAWqedbsuUEx1w3nCECywK/FLTNUdcbH9zpaMaw==", - "requires": { - "google-auth-library": "^4.0.0", - "googleapis-common": "^2.0.2" - } - }, - "googleapis-common": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-2.0.4.tgz", - "integrity": "sha512-8RRkxr24v1jIKCC1onFWA8RGnwFV55m3Qpil9DLX1yLc9e5qvOJsRoDOhhD2e7jFRONYEhT/BzT8vJZANqSr9w==", - "requires": { - "extend": "^3.0.2", - "gaxios": "^2.0.1", - "google-auth-library": "^4.2.5", - "qs": "^6.7.0", - "url-template": "^2.0.8", - "uuid": "^3.3.2" - }, - "dependencies": { - "qs": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" - } - } - }, - "got": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", - "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", - "dev": true, - "requires": { - "create-error-class": "^3.0.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "is-redirect": "^1.0.0", - "is-retry-allowed": "^1.0.0", - "is-stream": "^1.0.0", - "lowercase-keys": "^1.0.0", - "safe-buffer": "^5.0.1", - "timed-out": "^4.0.0", - "unzip-response": "^2.0.1", - "url-parse-lax": "^1.0.0" - }, - "dependencies": { - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", - "dev": true - } - } - }, - "graceful-fs": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true - }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" - }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "gtoken": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-3.0.2.tgz", - "integrity": "sha512-BOBi6Zz31JfxhSHRZBIDdbwIbOPyux10WxJHdx8wz/FMP1zyN1xFrsAWsgcLe5ww5v/OZu/MePUEZAjgJXSauA==", - "requires": { - "gaxios": "^2.0.0", - "google-p12-pem": "^2.0.0", - "jws": "^3.1.5", - "mime": "^2.2.0" - } - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" - }, - "har-validator": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", - "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", - "requires": { - "ajv": "^6.5.5", - "har-schema": "^2.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "has-symbols": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" - }, - "has-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", - "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", - "dev": true, - "requires": { - "get-value": "^2.0.6", - "has-values": "^1.0.0", - "isobject": "^3.0.0" - }, - "dependencies": { - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "has-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", - "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "kind-of": "^4.0.0" - }, - "dependencies": { - "kind-of": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", - "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "hash-base": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", - "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "hasha": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-3.0.0.tgz", - "integrity": "sha1-UqMvq4Vp1BymmmH/GiFPjrfIvTk=", - "dev": true, - "requires": { - "is-stream": "^1.0.1" - } - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "hosted-git-info": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", - "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", - "dev": true - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" - }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", - "dev": true - }, - "import-fresh": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", - "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "import-lazy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", - "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", - "dev": true - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", - "dev": true - }, - "inquirer": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.19", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.6.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - }, - "is-binary-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", - "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", - "dev": true, - "requires": { - "binary-extensions": "^1.0.0" - } - }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, - "is-callable": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", - "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" - }, - "is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", - "dev": true, - "requires": { - "ci-info": "^1.5.0" - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-date-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", - "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" - }, - "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-installed-globally": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz", - "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=", - "dev": true, - "requires": { - "global-dirs": "^0.1.0", - "is-path-inside": "^1.0.0" - } - }, - "is-npm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz", - "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ=", - "dev": true - }, - "is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", - "dev": true - }, - "is-path-inside": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", - "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", - "dev": true, - "requires": { - "path-is-inside": "^1.0.1" - } - }, - "is-plain-object": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", - "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", - "requires": { - "isobject": "^4.0.0" - } - }, - "is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=" - }, - "is-redirect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", - "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=", - "dev": true - }, - "is-regex": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", - "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", - "requires": { - "has": "^1.0.3" - } - }, - "is-retry-allowed": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true - }, - "is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" - }, - "is-symbol": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", - "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", - "requires": { - "has-symbols": "^1.0.1" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, - "isobject": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", - "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==" - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "istanbul-lib-coverage": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", - "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", - "dev": true - }, - "istanbul-lib-hook": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-2.0.7.tgz", - "integrity": "sha512-vrRztU9VRRFDyC+aklfLoeXyNdTfga2EI3udDGn4cZ6fpSXpHLV9X6CHvfoMCPtggg8zvDDmC4b9xfu0z6/llA==", - "dev": true, - "requires": { - "append-transform": "^1.0.0" - } - }, - "istanbul-lib-instrument": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", - "integrity": "sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==", - "dev": true, - "requires": { - "@babel/generator": "^7.4.0", - "@babel/parser": "^7.4.3", - "@babel/template": "^7.4.0", - "@babel/traverse": "^7.4.3", - "@babel/types": "^7.4.0", - "istanbul-lib-coverage": "^2.0.5", - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "istanbul-lib-report": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-2.0.8.tgz", - "integrity": "sha512-fHBeG573EIihhAblwgxrSenp0Dby6tJMFR/HvlerBsrCTD5bkUuoNtn3gVh29ZCS824cGGBPn7Sg7cNk+2xUsQ==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^2.0.5", - "make-dir": "^2.1.0", - "supports-color": "^6.1.0" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - }, - "supports-color": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", - "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "istanbul-lib-source-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", - "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^2.0.5", - "make-dir": "^2.1.0", - "rimraf": "^2.6.3", - "source-map": "^0.6.1" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "istanbul-reports": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.7.tgz", - "integrity": "sha512-uu1F/L1o5Y6LzPVSVZXNOoD/KXpJue9aeLRd0sM9uMXfZvzomB0WxVamWb5ue8kA2vVWEmW7EG+A5n3f1kqHKg==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0" - } - }, - "jmespath": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", - "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "dependencies": { - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - } - } - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true - }, - "json-bigint": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", - "integrity": "sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=", - "requires": { - "bignumber.js": "^7.0.0" - } - }, - "json-edm-parser": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/json-edm-parser/-/json-edm-parser-0.1.2.tgz", - "integrity": "sha1-HmCw/vG8CvZ7wNFG393lSGzWFbQ=", - "requires": { - "jsonparse": "~1.2.0" - } - }, - "json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "jsonparse": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz", - "integrity": "sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70=" - }, - "jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", - "requires": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^5.6.0" - } - }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - } - }, - "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "requires": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" - }, - "latest-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", - "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=", - "dev": true, - "requires": { - "package-json": "^4.0.0" - } - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" - }, - "lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", - "dev": true - }, - "lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" - }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, - "lodash.set": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", - "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=" - }, - "lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" - }, - "log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", - "dev": true, - "requires": { - "chalk": "^2.0.1" - } - }, - "lowercase-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true - }, - "lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "requires": { - "yallist": "^3.0.2" - } - }, - "macos-release": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz", - "integrity": "sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==" - }, - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "requires": { - "pify": "^3.0.0" - } - }, - "map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", - "dev": true - }, - "map-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", - "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", - "dev": true, - "requires": { - "object-visit": "^1.0.0" - } - }, - "md5.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", - "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "merge-source-map": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", - "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", - "dev": true, - "requires": { - "source-map": "^0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - } - }, - "mime": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", - "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==" - }, - "mime-db": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", - "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==" - }, - "mime-types": { - "version": "2.1.26", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", - "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", - "requires": { - "mime-db": "1.43.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - }, - "mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, - "requires": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "mixme": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/mixme/-/mixme-0.3.5.tgz", - "integrity": "sha512-SyV9uPETRig5ZmYev0ANfiGeB+g6N2EnqqEfBbCGmmJ6MgZ3E4qv5aPbnHVdZ60KAHHXV+T3sXopdrnIXQdmjQ==" - }, - "mkdirp": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.4.tgz", - "integrity": "sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "mocha": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.3.tgz", - "integrity": "sha512-0R/3FvjIGH3eEuG17ccFPk117XL2rWxatr81a57D+r/x2uTYZRbdZ4oVidEUMh2W2TJDa7MdAb12Lm2/qrKajg==", - "dev": true, - "requires": { - "ansi-colors": "3.2.3", - "browser-stdout": "1.3.1", - "debug": "3.2.6", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "find-up": "3.0.0", - "glob": "7.1.3", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "3.13.1", - "log-symbols": "2.2.0", - "minimatch": "3.0.4", - "mkdirp": "0.5.4", - "ms": "2.1.1", - "node-environment-flags": "1.0.5", - "object.assign": "4.1.0", - "strip-json-comments": "2.0.1", - "supports-color": "6.0.0", - "which": "1.3.1", - "wide-align": "1.1.3", - "yargs": "13.3.2", - "yargs-parser": "13.1.2", - "yargs-unparser": "1.6.0" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true - } - } - }, - "moment": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", - "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "ms-rest": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/ms-rest/-/ms-rest-2.5.4.tgz", - "integrity": "sha512-VeqCbawxRM6nhw0RKNfj7TWL7SL8PB6MypqwgylXCi+u412uvYoyY/kSmO8n06wyd8nIcnTbYToCmSKFMI1mCg==", - "requires": { - "duplexer": "^0.1.1", - "is-buffer": "^1.1.6", - "is-stream": "^1.1.0", - "moment": "^2.21.0", - "request": "^2.88.0", - "through": "^2.3.8", - "tunnel": "0.0.5", - "uuid": "^3.2.1" - } - }, - "ms-rest-azure": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/ms-rest-azure/-/ms-rest-azure-2.6.0.tgz", - "integrity": "sha512-J6386a9krZ4VtU7CRt+Ypgo9RGf8+d3gjMBkH7zbkM4zzkhbbMOYiPRaZ+bHZcfihkKLlktTgA6rjshTjF329A==", - "requires": { - "adal-node": "^0.1.28", - "async": "2.6.0", - "moment": "^2.22.2", - "ms-rest": "^2.3.2", - "request": "^2.88.0", - "uuid": "^3.2.1" - }, - "dependencies": { - "async": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", - "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", - "requires": { - "lodash": "^4.14.0" - } - } - } - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true - }, - "nan": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", - "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", - "dev": true, - "optional": true - }, - "nanomatch": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", - "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "fragment-cache": "^0.2.1", - "is-windows": "^1.0.2", - "kind-of": "^6.0.2", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - } - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "ndjson": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-1.5.0.tgz", - "integrity": "sha1-rmA7NrE0vOw0e0UkIrC/mNWDLsg=", - "requires": { - "json-stringify-safe": "^5.0.1", - "minimist": "^1.2.0", - "split2": "^2.1.0", - "through2": "^2.0.3" - } - }, - "nested-error-stacks": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz", - "integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==", - "dev": true - }, - "nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" - }, - "node-environment-flags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.5.tgz", - "integrity": "sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ==", - "dev": true, - "requires": { - "object.getownpropertydescriptors": "^2.0.3", - "semver": "^5.7.0" - } - }, - "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" - }, - "node-forge": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", - "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" - }, - "nodemon": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.19.4.tgz", - "integrity": "sha512-VGPaqQBNk193lrJFotBU8nvWZPqEZY2eIzymy2jjY0fJ9qIsxA0sxQ8ATPl0gZC645gijYEc1jtZvpS8QWzJGQ==", - "dev": true, - "requires": { - "chokidar": "^2.1.8", - "debug": "^3.2.6", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.0.4", - "pstree.remy": "^1.1.7", - "semver": "^5.7.1", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.2", - "update-notifier": "^2.5.0" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", - "requires": { - "path-key": "^2.0.0" - } - }, - "nyc": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-14.1.1.tgz", - "integrity": "sha512-OI0vm6ZGUnoGZv/tLdZ2esSVzDwUC88SNs+6JoSOMVxA+gKMB8Tk7jBwgemLx4O40lhhvZCVw1C+OYLOBOPXWw==", - "dev": true, - "requires": { - "archy": "^1.0.0", - "caching-transform": "^3.0.2", - "convert-source-map": "^1.6.0", - "cp-file": "^6.2.0", - "find-cache-dir": "^2.1.0", - "find-up": "^3.0.0", - "foreground-child": "^1.5.6", - "glob": "^7.1.3", - "istanbul-lib-coverage": "^2.0.5", - "istanbul-lib-hook": "^2.0.7", - "istanbul-lib-instrument": "^3.3.0", - "istanbul-lib-report": "^2.0.8", - "istanbul-lib-source-maps": "^3.0.6", - "istanbul-reports": "^2.2.4", - "js-yaml": "^3.13.1", - "make-dir": "^2.1.0", - "merge-source-map": "^1.1.0", - "resolve-from": "^4.0.0", - "rimraf": "^2.6.3", - "signal-exit": "^3.0.2", - "spawn-wrap": "^1.4.2", - "test-exclude": "^5.2.3", - "uuid": "^3.3.2", - "yargs": "^13.2.2", - "yargs-parser": "^13.0.0" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } - } - }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, - "object-copy": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", - "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", - "dev": true, - "requires": { - "copy-descriptor": "^0.1.0", - "define-property": "^0.2.5", - "kind-of": "^3.0.3" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "object-inspect": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" - }, - "object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", - "dev": true, - "requires": { - "isobject": "^3.0.0" - }, - "dependencies": { - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "object.assign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", - "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "requires": { - "define-properties": "^1.1.2", - "function-bind": "^1.1.1", - "has-symbols": "^1.0.0", - "object-keys": "^1.0.11" - } - }, - "object.getownpropertydescriptors": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", - "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1" - } - }, - "object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", - "dev": true, - "requires": { - "isobject": "^3.0.1" - }, - "dependencies": { - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "octokit-pagination-methods": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz", - "integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", - "dev": true - }, - "os-name": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", - "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", - "requires": { - "macos-release": "^2.2.0", - "windows-release": "^3.1.0" - } - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, - "p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - }, - "package-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-3.0.0.tgz", - "integrity": "sha512-lOtmukMDVvtkL84rJHI7dpTYq+0rli8N2wlnqUcBuDWCfVhRUfOmnR9SsoHFMLpACvEV60dX7rd0rFaYDZI+FA==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.15", - "hasha": "^3.0.0", - "lodash.flattendeep": "^4.4.0", - "release-zalgo": "^1.0.0" - } - }, - "package-json": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz", - "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=", - "dev": true, - "requires": { - "got": "^6.7.1", - "registry-auth-token": "^3.0.1", - "registry-url": "^3.0.3", - "semver": "^5.1.0" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", - "dev": true, - "requires": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - } - }, - "pascalcase": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", - "dev": true - }, - "path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", - "dev": true - }, - "path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" - }, - "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true - }, - "path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "requires": { - "pify": "^3.0.0" - } - }, - "pathval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", - "dev": true - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true - }, - "pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - }, - "posix-character-classes": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", - "dev": true - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "dev": true - }, - "prepend-http": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", - "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", - "dev": true - }, - "process-nextick-args": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" - }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true - }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "pstree.remy": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.7.tgz", - "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==", - "dev": true - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" - }, - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" - }, - "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - } - }, - "read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", - "dev": true, - "requires": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - } - }, - "read-pkg-up": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-4.0.0.tgz", - "integrity": "sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==", - "dev": true, - "requires": { - "find-up": "^3.0.0", - "read-pkg": "^3.0.0" - } - }, - "readable-stream": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", - "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "~1.0.0", - "process-nextick-args": "~1.0.6", - "string_decoder": "~0.10.x", - "util-deprecate": "~1.0.1" - } - }, - "readdirp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", - "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11", - "micromatch": "^3.1.10", - "readable-stream": "^2.0.2" - } - }, - "regex-not": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", - "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, - "requires": { - "extend-shallow": "^3.0.2", - "safe-regex": "^1.1.0" - } - }, - "regexpp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", - "dev": true - }, - "registry-auth-token": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.4.0.tgz", - "integrity": "sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==", - "dev": true, - "requires": { - "rc": "^1.1.6", - "safe-buffer": "^5.0.1" - } - }, - "registry-url": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", - "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=", - "dev": true, - "requires": { - "rc": "^1.0.1" - } - }, - "release-zalgo": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", - "dev": true, - "requires": { - "es6-error": "^4.0.1" - } - }, - "remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", - "dev": true - }, - "repeat-element": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", - "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", - "dev": true - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true - }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" - }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" - }, - "resolve": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", - "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", - "dev": true, - "requires": { - "path-parse": "^1.0.6" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "resolve-url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", - "dev": true - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - } - }, - "ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true - }, - "rxjs": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz", - "integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "safe-buffer": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", - "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" - }, - "safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, - "requires": { - "ret": "~0.1.10" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - }, - "semver-diff": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz", - "integrity": "sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=", - "dev": true, - "requires": { - "semver": "^5.0.3" - } - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" - }, - "set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" - }, - "signal-exit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" - }, - "slice-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "astral-regex": "^1.0.0", - "is-fullwidth-code-point": "^2.0.0" - } - }, - "smartwrap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/smartwrap/-/smartwrap-2.0.1.tgz", - "integrity": "sha512-BoOIGADm5Q9OXGVmT899L1fx6dvOYciVUPB9yFlE1XkxRs3/6sqT/HYZoXOxNb4jj2zshtH+jhmGa6wGkNDPgA==", - "requires": { - "array.prototype.flat": "^1.2.3", - "breakword": "^1.0.5", - "grapheme-splitter": "^1.0.4", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1", - "yargs": "^15.1.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "requires": { - "ansi-regex": "^5.0.0" - } - }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - } - }, - "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - } - } - }, - "snapdragon": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", - "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, - "requires": { - "base": "^0.11.1", - "debug": "^2.2.0", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "map-cache": "^0.2.2", - "source-map": "^0.5.6", - "source-map-resolve": "^0.5.0", - "use": "^3.1.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "snapdragon-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", - "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, - "requires": { - "define-property": "^1.0.0", - "isobject": "^3.0.0", - "snapdragon-util": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "snapdragon-util": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", - "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, - "requires": { - "kind-of": "^3.2.0" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - }, - "source-map-resolve": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", - "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", - "dev": true, - "requires": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0", - "resolve-url": "^0.2.1", - "source-map-url": "^0.4.0", - "urix": "^0.1.0" - } - }, - "source-map-url": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", - "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", - "dev": true - }, - "spawn-wrap": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-1.4.3.tgz", - "integrity": "sha512-IgB8md0QW/+tWqcavuFgKYR/qIRvJkRLPJDFaoXtLLUaVcCDK0+HeFTkmQHj3eprcYhc+gOl0aEA1w7qZlYezw==", - "dev": true, - "requires": { - "foreground-child": "^1.5.6", - "mkdirp": "^0.5.0", - "os-homedir": "^1.0.1", - "rimraf": "^2.6.2", - "signal-exit": "^3.0.2", - "which": "^1.3.0" - } - }, - "spdx-correct": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", - "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", - "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", - "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", - "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", - "dev": true - }, - "split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", - "dev": true, - "requires": { - "extend-shallow": "^3.0.0" - } - }, - "split2": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", - "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", - "requires": { - "through2": "^2.0.2" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" - }, - "sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, - "static-extend": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", - "dev": true, - "requires": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - } - } - }, - "stream-transform": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-2.0.2.tgz", - "integrity": "sha512-J+D5jWPF/1oX+r9ZaZvEXFbu7znjxSkbNAHJ9L44bt/tCVuOEWZlDqU9qJk7N2xBU1S+K2DPpSKeR/MucmCA1Q==", - "requires": { - "mixme": "^0.3.1" - } - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "string.prototype.trimend": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz", - "integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "string.prototype.trimleft": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", - "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5", - "string.prototype.trimstart": "^1.0.0" - } - }, - "string.prototype.trimright": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", - "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5", - "string.prototype.trimend": "^1.0.0" - } - }, - "string.prototype.trimstart": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz", - "integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - }, - "strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" - }, - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true - }, - "supports-color": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", - "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "table": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", - "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", - "dev": true, - "requires": { - "ajv": "^6.10.2", - "lodash": "^4.17.14", - "slice-ansi": "^2.1.0", - "string-width": "^3.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "term-size": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz", - "integrity": "sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=", - "dev": true, - "requires": { - "execa": "^0.7.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", - "dev": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", - "dev": true - }, - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - } - } - }, - "test-exclude": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.3.tgz", - "integrity": "sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==", - "dev": true, - "requires": { - "glob": "^7.1.3", - "minimatch": "^3.0.4", - "read-pkg-up": "^4.0.0", - "require-main-filename": "^2.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" - }, - "through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "requires": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - }, - "dependencies": { - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "timed-out": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", - "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", - "dev": true - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", - "dev": true - }, - "to-object-path": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "to-regex": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", - "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", - "dev": true, - "requires": { - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "regex-not": "^1.0.2", - "safe-regex": "^1.1.0" - } - }, - "to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" - } - }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "requires": { - "nopt": "~1.0.10" - } - }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "dependencies": { - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - } - } - }, - "tslib": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", - "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", - "dev": true - }, - "tty-table": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tty-table/-/tty-table-4.1.3.tgz", - "integrity": "sha512-Aireaxah5bTJkkJ7b2RI8tMZxogscWP4UCgi5dKuOqL8BWPjnnOebC7F+oFYWTLKUaiJm8h+CvsigBaaCtxqGw==", - "requires": { - "chalk": "^3.0.0", - "csv": "^5.3.2", - "kleur": "^3.0.3", - "smartwrap": "^2.0.1", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1", - "yargs": "^15.3.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "requires": { - "ansi-regex": "^5.0.0" - } - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - } - }, - "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - } - } - }, - "tunnel": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.5.tgz", - "integrity": "sha512-gj5sdqherx4VZKMcBA4vewER7zdK25Td+z1npBqpbDys4eJrLx+SlYjJvq1bDXs2irkuJM5pf8ktaEQVipkrbA==" - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - }, - "undefsafe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", - "integrity": "sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A==", - "dev": true, - "requires": { - "debug": "^2.2.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "underscore": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz", - "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==" - }, - "union-value": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", - "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", - "dev": true, - "requires": { - "arr-union": "^3.1.0", - "get-value": "^2.0.6", - "is-extendable": "^0.1.1", - "set-value": "^2.0.1" - } - }, - "unique-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", - "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", - "dev": true, - "requires": { - "crypto-random-string": "^1.0.0" - } - }, - "universal-user-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz", - "integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==", - "requires": { - "os-name": "^3.1.0" - } - }, - "unset-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", - "dev": true, - "requires": { - "has-value": "^0.3.1", - "isobject": "^3.0.0" - }, - "dependencies": { - "has-value": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", - "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", - "dev": true, - "requires": { - "get-value": "^2.0.3", - "has-values": "^0.1.4", - "isobject": "^2.0.0" - }, - "dependencies": { - "isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", - "dev": true, - "requires": { - "isarray": "1.0.0" - } - } - } - }, - "has-values": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", - "dev": true - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - } - } - }, - "unzip-response": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz", - "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=", - "dev": true - }, - "upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "dev": true - }, - "update-notifier": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz", - "integrity": "sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==", - "dev": true, - "requires": { - "boxen": "^1.2.1", - "chalk": "^2.0.1", - "configstore": "^3.0.0", - "import-lazy": "^2.1.0", - "is-ci": "^1.0.10", - "is-installed-globally": "^0.1.0", - "is-npm": "^1.0.0", - "latest-version": "^3.0.0", - "semver-diff": "^2.0.0", - "xdg-basedir": "^3.0.0" - } - }, - "uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "requires": { - "punycode": "^2.1.0" - }, - "dependencies": { - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - } - } - }, - "urix": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", - "dev": true - }, - "url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", - "requires": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "url-parse-lax": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", - "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=", - "dev": true, - "requires": { - "prepend-http": "^1.0.1" - } - }, - "url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" - }, - "use": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" - }, - "v8-compile-cache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz", - "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==", - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validator": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.1.tgz", - "integrity": "sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==" - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "requires": { - "defaults": "^1.0.3" - } - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "requires": { - "isexe": "^2.0.0" - } - }, - "which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" - }, - "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "widest-line": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.1.tgz", - "integrity": "sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==", - "dev": true, - "requires": { - "string-width": "^2.1.1" - } - }, - "windows-release": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.3.0.tgz", - "integrity": "sha512-2HetyTg1Y+R+rUgrKeUEhAG/ZuOmTrI1NBb3ZyAGQMYmOJjBBPe4MTodghRkmLJZHwkuPi02anbeGP+Zf401LQ==", - "requires": { - "execa": "^1.0.0" - } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "write": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", - "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", - "dev": true, - "requires": { - "mkdirp": "^0.5.1" - } - }, - "write-file-atomic": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", - "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11", - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.2" - } - }, - "xdg-basedir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", - "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", - "dev": true - }, - "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" - } - }, - "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" - }, - "xmldom": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.3.0.tgz", - "integrity": "sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g==" - }, - "xpath.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz", - "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "y18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" - }, - "yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, - "yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", - "dev": true, - "requires": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", - "dev": true, - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - }, - "yargs-unparser": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", - "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", - "dev": true, - "requires": { - "flat": "^4.1.0", - "lodash": "^4.17.15", - "yargs": "^13.3.0" - } - } - } -} diff --git a/package.json b/package.json index f1fa3d4a9..2c07e5c16 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cloudsploit", + "name": "cloudexploit", "version": "2.0.0", "description": "AWS, Azure, GCP, Oracle, GitHub security scanning scripts", "main": "index.js", @@ -13,11 +13,11 @@ "lint": "npx eslint ." }, "bin": { - "cloudsploit-scan": "./index.js" + "cloudexploit": "./index.js" }, "repository": { "type": "git", - "url": "https://github.com/khulnasoft/cloudsploit.git" + "url": "https://github.com/khulnasoft/cloudexploit.git" }, "keywords": [ "aws", @@ -29,35 +29,41 @@ "cloud", "security" ], - "author": "Khulnasoft Security", + "author": "KhulnaSoft Security", "license": "GPL-3.0-or-later", "bugs": { - "url": "https://github.com/khulnasoft/cloudsploit/issues" + "url": "https://github.com/khulnasoft/cloudexploit/issues" }, "homepage": "https://cloud.khulnasoft.com", "publishConfig": { "access": "public" }, "dependencies": { - "@octokit/app": "^3.0.0", - "@octokit/request": "^3.0.3", - "@octokit/rest": "^16.3.2", + "@alicloud/pop-core": "^1.7.10", + "@azure/data-tables": "^13.2.2", + "@azure/storage-file-share": "^12.14.0", + "@azure/storage-queue": "^12.13.0", + "@azure/storage-blob": "^12.14.0", + "@octokit/auth-app": "^6.0.3", + "@octokit/request": "^8.1.6", + "@octokit/rest": "^20.0.2", + "ali-oss": "^6.15.2", "argparse": "^2.0.0", "async": "^2.6.1", - "aws-sdk": "^2.648.0", + "aws-sdk": "^2.1506.0", "azure-storage": "^2.10.3", "csv-write-stream": "^2.0.0", "fast-safe-stringify": "^2.0.6", - "googleapis": "^40.0.1", - "minimatch": "^3.0.4", - "ms-rest-azure": "^2.6.0", + "google-auth-library": "^8.1.1", + "minimatch": "^10.2.2", + "ms-rest-azure": "^3.0.2", "tty-table": "^4.1.3" }, "devDependencies": { - "chai": "^4.2.0", + "chai": "4.2.0", + "eslint": "^6.8.0", "mocha": "^6.1.4", "nodemon": "^1.19.4", - "nyc": "^14.1.1", - "eslint": "^6.8.0" + "nyc": "^14.1.1" } } diff --git a/plugins/alibaba/ack/ackPrivateClusterEnabled.js b/plugins/alibaba/ack/ackPrivateClusterEnabled.js new file mode 100644 index 000000000..b5b20486a --- /dev/null +++ b/plugins/alibaba/ack/ackPrivateClusterEnabled.js @@ -0,0 +1,59 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'ACK Private Cluster Enabled', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Kubernetes clusters are created with private cluster enabled.', + more_info: 'Private cluster restricts access to the Kubernetes API server from the public internet, making it more secure. In a private cluster, the API Server Public Network Endpoint is not exposed to the internet. This reduces the risk of unauthorised access and helps protect sensitive data and workloads. It is recommended to have Private Cluster enabled for better security.', + link: 'https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/control-public-access-to-the-api-server-of-a-cluster', + recommended_action: 'Recreate Kubernetes clusters with public access disabled.', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + + if (cluster.master_url) { + try { + var masterUrl = JSON.parse(cluster.master_url); + + if (masterUrl.api_server_endpoint && masterUrl.api_server_endpoint !== '') { + helpers.addResult(results, 2, 'Cluster does not have private cluster feature enabled', defaultRegion, resource); + } else { + helpers.addResult(results, 0, 'Cluster has private cluster feature enabled', defaultRegion, resource); + } + } catch (e) { + helpers.addResult(results, 3, `Master_url of cluster ${cluster.cluster_id} can not be parsed`, defaultRegion, resource); + } + } else { + helpers.addResult(results, 3, `Could not find master_url info for cluster ${cluster.cluster_id}`, defaultRegion, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/ackPrivateClusterEnabled.spec.js b/plugins/alibaba/ack/ackPrivateClusterEnabled.spec.js new file mode 100644 index 000000000..77199eb3d --- /dev/null +++ b/plugins/alibaba/ack/ackPrivateClusterEnabled.spec.js @@ -0,0 +1,286 @@ +var expect = require('chai').expect; +var ackPrivateClusterEnabled = require('./ackPrivateClusterEnabled.js'); + +const describeClusters = [ + { + "name": "khulnasoft-cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"terway-eniip\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IPVlan\\\":\\\"true\\\",\\\"NetworkPolicy\\\":\\\"true\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-plugin\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-provisioner\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"nginx-ingress-controller\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"arms-prometheus\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null}],\"AuditProjectName\":\"\",\"Capabilities\":{\"IntelSGX\":false,\"SandboxRuntime\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"log_ttl\":\"\",\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"\",\"Platform\":\"\",\"PodVswitchId\":\"{\\\"us-west-1a\\\":[\\\"vsw-rj98qqgecvwwzn5fzc62p\\\",\\\"vsw-rj94uhhrj5qz5008lwi1x\\\"]}\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"docker\",\"RuntimeVersion\":\"19.03.5\",\"SubClass\":\"\",\"SupportPlatforms\":null,\"VersionSpec\":null,\"VpcCidr\":\"172.16.0.0/12\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "{\"api_server_endpoint\":\"https://sample-api-server-endpoint:6443\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evcum8y76kf8a1a6s3m1", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cb3341709db3c4ea587f218d347cfdfeb", + "size": 1, + "region_id": "us-west-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-01T17:14:53+08:00", + "updated": "2021-06-03T18:52:54+08:00", + "init_version": "1.20.4-aliyun.1", + "current_version": "1.20.4-aliyun.1", + "meta_data": "{\"Addons\":[{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-plugin\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"alicloud-monitor-controller\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.5.7-e1d5de8a-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"cloud-controller-manager\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.9.3.381-gd6d0962-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-provisioner\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"kube-flannel-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.13.0.1-466064b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"aliyun-acr-credential-helper\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v21.04.22.0-273c079-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"flannel\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"coredns\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.7.0\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"managed-kube-proxy\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-aliyun.1\"},{\"config\":\"{\\\"IngressDashboardEnabled\\\":\\\"false\\\",\\\"sls_project_name\\\":\\\"akhtar-project\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"logtail-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.16.62.0-da583e0-aliyun\"},{\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"nginx-ingress-controller\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.44.0.1-5e842447b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"metrics-server\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v0.3.8.5-307cf45-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"ack-scheduler\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-ack-2.0\"}],\"AuditProjectName\":\"akhtar-project4\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"Flannel\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"26\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"aliuid\":\"0000111122223333\",\"components\":[\"apiserver\",\"kcm\",\"scheduler\"],\"log_project\":\"akhtar-project3\",\"log_ttl\":\"30\"},\"DockerVersion\":\"19.03.15\",\"EtcdVersion\":\"v3.4.3\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"1.20.4-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.4.4\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"VersionSpec\":null,\"VpcCidr\":\"10.0.0.0/8\",\"alicloud-monitor-controllerVersion\":\"v1.5.7-e1d5de8a-aliyun\",\"aliyun-acr-credential-helperVersion\":\"v21.04.22.0-273c079-aliyun\",\"corednsVersion\":\"1.7.0\",\"csi-pluginVersion\":\"v1.18.8.47-906bd535-aliyun\",\"csi-provisionerVersion\":\"v1.18.8.47-906bd535-aliyun\",\"kube-flannel-dsVersion\":\"v0.13.0.1-466064b-aliyun\",\"logtail-dsVersion\":\"v0.16.62.0-da583e0-aliyun\",\"metrics-serverVersion\":\"v0.3.8.5-307cf45-aliyun\",\"nginx-ingress-controllerVersion\":\"v0.44.0.2-abf1c6fe4-aliyun\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9xwh22u1bfdo2wjovfs", + "vswitch_id": "vsw-rj9bp9tgbcjqe7rayhtzh", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj9fujjydj19r9chwln1", + "tags": [ + { + "key": "ack.aliyun.com", + "value": "cb3341709db3c4ea587f218d347cfdfeb" + } + ], + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "172.25.32.0/20", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evc9zhl4qb3uhdehq51o", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "19.03.15", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": null, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": true, + "cluster_spec": "ack.pro.small", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cc377f7509590489da921de83a1cadfrsdd918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"terway-eniip\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IPVlan\\\":\\\"true\\\",\\\"NetworkPolicy\\\":\\\"true\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-plugin\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-provisioner\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"nginx-ingress-controller\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"arms-prometheus\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null}],\"AuditProjectName\":\"\",\"Capabilities\":{\"IntelSGX\":false,\"SandboxRuntime\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"log_ttl\":\"\",\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"\",\"Platform\":\"\",\"PodVswitchId\":\"{\\\"us-west-1a\\\":[\\\"vsw-rj98qqgecvwwzn5fzc62p\\\",\\\"vsw-rj94uhhrj5qz5008lwi1x\\\"]}\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"docker\",\"RuntimeVersion\":\"19.03.5\",\"SubClass\":\"\",\"SupportPlatforms\":null,\"VersionSpec\":null,\"VpcCidr\":\"172.16.0.0/12\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + +]; + +const createCache = (describeClusters, describeClustersErr) => { + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + } + } + }; +}; + +describe('ackPrivateClusterEnabled', function () { + describe('run', function () { + it('should FAIL if Cluster does not have Private Cluster enabled', function (done) { + const cache = createCache([describeClusters[0]]); + ackPrivateClusterEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster does not have private cluster feature enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster have Private Cluster enabled', function (done) { + const cache = createCache([describeClusters[1]]); + ackPrivateClusterEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster has private cluster feature enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + ackPrivateClusterEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + ackPrivateClusterEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should UNKNOWN if unable no Master_url is found for ACK clusters', function (done) { + const cache = createCache([describeClusters[2]]); + ackPrivateClusterEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Could not find master_url info for cluster'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ack/cloudMonitorEnabled.js b/plugins/alibaba/ack/cloudMonitorEnabled.js new file mode 100644 index 000000000..fe9a02189 --- /dev/null +++ b/plugins/alibaba/ack/cloudMonitorEnabled.js @@ -0,0 +1,57 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Cloud Monitor Enabled', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure Cloud Monitor is enabled on Kubernetes Engine clusters.', + more_info: 'Enabling CloudMonitor installation provides system metrics (e.g., CPU, memory usage) and custom metrics for Kubernetes Engine Clusters, collected periodically by a monitor controller and sent to the CloudMonitor server.', + link: 'https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/monitor-basic-resources', + recommended_action: 'Recreate Kubernetes clusters and set CloudMonitor Agent to enabled under creation options.', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity', 'ACK:describeClusterDetail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + var describeClusterDetail = helpers.addSource(cache, source, ['ack', 'describeClusterDetail', defaultRegion, cluster.cluster_id]); + + if (!describeClusterDetail) return; + + if (describeClusterDetail.err || !describeClusterDetail.data) { + helpers.addResult(results, 3, `Unable to query ACK cluster: ${helpers.addError(describeClusterDetail)}`, defaultRegion); + return; + } + if (describeClusterDetail.data.parameters && describeClusterDetail.data.parameters.CloudMonitorFlags && describeClusterDetail.data.parameters.CloudMonitorFlags === 'True') { + helpers.addResult(results, 0, 'Cluster has Cloud Monitor enabled', defaultRegion, resource); + } else { + helpers.addResult(results, 2, 'Cluster does not have Cloud Monitor enabled', defaultRegion, resource); + } + + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/cloudMonitorEnabled.spec.js b/plugins/alibaba/ack/cloudMonitorEnabled.spec.js new file mode 100644 index 000000000..bbd4b39b6 --- /dev/null +++ b/plugins/alibaba/ack/cloudMonitorEnabled.spec.js @@ -0,0 +1,232 @@ +var expect = require('chai').expect; +var cloudMonitorEnabled = require('./cloudMonitorEnabled.js'); + +const describeClusters = [ + { + "name": "cluster1", + "cluster_id": "cc377f7509590489da921de83a1cad918" + } +]; + +const describeClusterDetail = [ + { + "name": "cluster1", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 0, + "region_id": "ap-south-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2023-08-01T15:17:39+08:00", + "updated": "2023-08-01T15:22:12+08:00", + "vpc_id": "vpc-a2dwk93fhhhytkomfbkxm", + "vswitch_id": "vsw-a2dv93afd4l9roozf0x1i", + "external_loadbalancer_id": "lb-6gjewe9oi7jqhp786ydhr", + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "weekly_period": "" + }, + "parameters": { + "DisableAddons": "True", + "DisableAutoCreateK8sWorkerRole": "False", + "DisableAutoCreateK8sWorkerRolePolicy": "True", + "DockerVersion": "17.06.2-ce-3", + "ESSDeletionProtection": "True", + "BetaVersion": "", + "CloudMonitorFlags": "True", + "CloudMonitorVersion": "1.3.7", + "Eip": "False", + "EipAddress": "", + "EtcdVersion": "v3.5.4", + "ExecuteVersion": "451735391", + "HealthCheckType": "NONE", + "IPStack": "ipv4", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20230522.vhd", + "KubernetesVersion": "1.26.3-aliyun.1", + "MasterSLBPrivateIP": "192.168.50.192", + "NatGateway": "False", + "NatGatewayId": "ngw-a2di7c1swotszom3b8ev0", + "NatGatewayType": "Enhanced", + "NatGatewayVswitchId": "", + "Network": "terway-eniip", + "NodeNameMode": "nodeip", + "NumOfNodes": "0", + "OSType": "Linux", + "Password": "******", + "PodVswitchIds": "[\"vsw-a2dv93afd4l9roozf0x1i\"]", + "ProtectedInstances": "", + "ProxyMode": "ipvs", + "RemoveInstanceIds": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "SNatEntry": "False", + "ScaleOutToken": "4wnam5.lua8luvgmf8u63kk", + "SecurityGroupId": "sg-a2delotxlubqa0csxcjp", + "ServiceCIDR": "172.16.0.0/16", + "WorkerDeletionProtection": "True", + "WorkerDeploymentSetId": "", + "WorkerHpcClusterId": "", + "WorkerImageId": "aliyun_2_1903_x64_20G_alibase_20230522.vhd", + "WorkerInstanceChargeType": "PostPaid", + "WorkerInstanceTypes": "ecs.c5.xlarge", + "WorkerKeyPair": "interop", + "WorkerLoginPassword": "******", + "WorkerPeriod": "3", + "WorkerPeriodUnit": "Month", + "WorkerSnapshotPolicyId": "******", + "WorkerSystemDiskCategory": "cloud_ssd", + "WorkerSystemDiskPerformanceLevel": null, + "WorkerSystemDiskSize": "120", + "WorkerVSwitchIds": "vsw-a2dv93afd4l9roozf0x1i", + "ZoneId": "" + }, + "worker_ram_role_name": "KubernetesWorkerRole-735af2c1-d38e-4348-a530-cff78749fd37", + }, + { + "name": "cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad919", + "size": 0, + "region_id": "ap-south-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2023-08-01T15:17:39+08:00", + "updated": "2023-08-01T15:22:12+08:00", + "vpc_id": "vpc-a2dwk93fhhhytkomfbkxm", + "vswitch_id": "vsw-a2dv93afd4l9roozf0x1i", + "external_loadbalancer_id": "lb-6gjewe9oi7jqhp786ydhr", + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "weekly_period": "" + }, + "parameters": { + "DisableAddons": "True", + "DisableAutoCreateK8sWorkerRole": "False", + "DisableAutoCreateK8sWorkerRolePolicy": "True", + "DockerVersion": "17.06.2-ce-3", + "ESSDeletionProtection": "True", + "BetaVersion": "", + "CloudMonitorFlags": "False", + "CloudMonitorVersion": "1.3.7", + "Eip": "False", + "EipAddress": "", + "EtcdVersion": "v3.5.4", + "ExecuteVersion": "451735391", + "HealthCheckType": "NONE", + "IPStack": "ipv4", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20230522.vhd", + "KubernetesVersion": "1.26.3-aliyun.1", + "MasterSLBPrivateIP": "192.168.50.192", + "NatGateway": "False", + "NatGatewayId": "ngw-a2di7c1swotszom3b8ev0", + "NatGatewayType": "Enhanced", + "NatGatewayVswitchId": "", + "Network": "terway-eniip", + "NodeNameMode": "nodeip", + "NumOfNodes": "0", + "OSType": "Linux", + "Password": "******", + "PodVswitchIds": "[\"vsw-a2dv93afd4l9roozf0x1i\"]", + "ProtectedInstances": "", + "ProxyMode": "ipvs", + "RemoveInstanceIds": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "SNatEntry": "False", + "ScaleOutToken": "4wnam5.lua8luvgmf8u63kk", + "SecurityGroupId": "sg-a2delotxlubqa0csxcjp", + "ServiceCIDR": "172.16.0.0/16", + "WorkerDeletionProtection": "True", + "WorkerDeploymentSetId": "", + "WorkerHpcClusterId": "", + "WorkerImageId": "aliyun_2_1903_x64_20G_alibase_20230522.vhd", + "WorkerInstanceChargeType": "PostPaid", + "WorkerInstanceTypes": "ecs.c5.xlarge", + "WorkerKeyPair": "interop", + "WorkerLoginPassword": "******", + "WorkerPeriod": "3", + "WorkerPeriodUnit": "Month", + "WorkerSnapshotPolicyId": "******", + "WorkerSystemDiskCategory": "cloud_ssd", + "WorkerSystemDiskPerformanceLevel": null, + "WorkerSystemDiskSize": "120", + "WorkerVSwitchIds": "vsw-a2dv93afd4l9roozf0x1i", + "ZoneId": "" + }, + "worker_ram_role_name": "KubernetesWorkerRole-735af2c1-d38e-4348-a530-cff78749fd37", + } +] + +const createCache = (describeClusters, describeClustersErr, describeClusterDetail, describeClusterDetailErr) => { + let clusterId = (describeClusters && describeClusters.length) ? describeClusters[0].cluster_id : null; + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + }, + describeClusterDetail: { + 'cn-hangzhou': { + [clusterId]: { + data: describeClusterDetail, + err: describeClusterDetailErr + } + } + } + } + } +}; + + + +describe('cloudMonitorEnabled', function () { + describe('run', function () { + it('should FAIL if Cluster does not have Cloud Monitor Enabled', function (done) { + const cache = createCache(describeClusters, null, describeClusterDetail[1], null); + cloudMonitorEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster does not have Cloud Monitor enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster has Cloud Monitor enabled', function (done) { + const cache = createCache(describeClusters, null ,describeClusterDetail[0], null); + cloudMonitorEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster has Cloud Monitor enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + cloudMonitorEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + cloudMonitorEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ack/logServiceEnabled.js b/plugins/alibaba/ack/logServiceEnabled.js new file mode 100644 index 000000000..a544b2f6b --- /dev/null +++ b/plugins/alibaba/ack/logServiceEnabled.js @@ -0,0 +1,61 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'ACK Log Service Enabled', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Kubernetes Engine Clusters are configured to enable Log service.', + more_info: 'Log Service allows you to collect, consume, and analyse logs from your containerised applications. By enabling Log Service on Kubernetes Engine Clusters, you can easily access and monitor log data from your containers, aiding in troubleshooting, analysis, and system monitoring.', + link: 'https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/collect-log-data-from-containers-by-using-log-service', + recommended_action: 'Recreate Kubernetes clusters and set enable log service feature.', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + + if (cluster.meta_data) { + try { + let clusterMeta = JSON.parse(cluster.meta_data); + + if (clusterMeta.AuditProjectName) { + helpers.addResult(results, 0, 'Cluster has log service enabled', defaultRegion, resource); + } else { + helpers.addResult(results, 2, 'Cluster does not have log service enabled', defaultRegion, resource); + } + } catch (e) { + helpers.addResult(results, 3, `Meta-data info of cluster ${cluster.cluster_id} can not be parsed`, defaultRegion, resource); + } + } else { + helpers.addResult(results, 3, + `Could not find meta-data info for cluster ${cluster.cluster_id}`, + defaultRegion, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/logServiceEnabled.spec.js b/plugins/alibaba/ack/logServiceEnabled.spec.js new file mode 100644 index 000000000..85293d2e1 --- /dev/null +++ b/plugins/alibaba/ack/logServiceEnabled.spec.js @@ -0,0 +1,201 @@ +var expect = require('chai').expect; +var logServiceEnabled = require('./logServiceEnabled.js'); + +const describeClusters = [ + { + "name": "khulnasoft-cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"terway-eniip\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IPVlan\\\":\\\"true\\\",\\\"NetworkPolicy\\\":\\\"true\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-plugin\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-provisioner\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"nginx-ingress-controller\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"arms-prometheus\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null}],\"AuditProjectName\":\"\",\"Capabilities\":{\"IntelSGX\":false,\"SandboxRuntime\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"log_ttl\":\"\",\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"\",\"Platform\":\"\",\"PodVswitchId\":\"{\\\"us-west-1a\\\":[\\\"vsw-rj98qqgecvwwzn5fzc62p\\\",\\\"vsw-rj94uhhrj5qz5008lwi1x\\\"]}\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"docker\",\"RuntimeVersion\":\"19.03.5\",\"SubClass\":\"\",\"SupportPlatforms\":null,\"VersionSpec\":null,\"VpcCidr\":\"172.16.0.0/12\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "", + "external_loadbalancer_id": "lb-2evcum8y76kf8a1a6s3m1", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cb3341709db3c4ea587f218d347cfdfeb", + "size": 1, + "region_id": "us-west-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-01T17:14:53+08:00", + "updated": "2021-06-03T18:52:54+08:00", + "init_version": "1.20.4-aliyun.1", + "current_version": "1.20.4-aliyun.1", + "meta_data": "{\"Addons\":[{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-plugin\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"alicloud-monitor-controller\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.5.7-e1d5de8a-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"cloud-controller-manager\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.9.3.381-gd6d0962-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-provisioner\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"kube-flannel-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.13.0.1-466064b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"aliyun-acr-credential-helper\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v21.04.22.0-273c079-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"flannel\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"coredns\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.7.0\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"managed-kube-proxy\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-aliyun.1\"},{\"config\":\"{\\\"IngressDashboardEnabled\\\":\\\"false\\\",\\\"sls_project_name\\\":\\\"akhtar-project\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"logtail-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.16.62.0-da583e0-aliyun\"},{\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"nginx-ingress-controller\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.44.0.1-5e842447b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"metrics-server\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v0.3.8.5-307cf45-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"ack-scheduler\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-ack-2.0\"}],\"AuditProjectName\":\"akhtar-project4\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"Flannel\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"26\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"aliuid\":\"0000111122223333\",\"components\":[\"apiserver\",\"kcm\",\"scheduler\"],\"log_project\":\"akhtar-project3\",\"log_ttl\":\"30\"},\"DockerVersion\":\"19.03.15\",\"EtcdVersion\":\"v3.4.3\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"1.20.4-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.4.4\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"VersionSpec\":null,\"VpcCidr\":\"10.0.0.0/8\",\"alicloud-monitor-controllerVersion\":\"v1.5.7-e1d5de8a-aliyun\",\"aliyun-acr-credential-helperVersion\":\"v21.04.22.0-273c079-aliyun\",\"corednsVersion\":\"1.7.0\",\"csi-pluginVersion\":\"v1.18.8.47-906bd535-aliyun\",\"csi-provisionerVersion\":\"v1.18.8.47-906bd535-aliyun\",\"kube-flannel-dsVersion\":\"v0.13.0.1-466064b-aliyun\",\"logtail-dsVersion\":\"v0.16.62.0-da583e0-aliyun\",\"metrics-serverVersion\":\"v0.3.8.5-307cf45-aliyun\",\"nginx-ingress-controllerVersion\":\"v0.44.0.2-abf1c6fe4-aliyun\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9xwh22u1bfdo2wjovfs", + "vswitch_id": "vsw-rj9bp9tgbcjqe7rayhtzh", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj9fujjydj19r9chwln1", + "tags": [ + { + "key": "ack.aliyun.com", + "value": "cb3341709db3c4ea587f218d347cfdfeb" + } + ], + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "172.25.32.0/20", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evc9zhl4qb3uhdehq51o", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "19.03.15", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": null, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": true, + "cluster_spec": "ack.pro.small", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + } +]; + +const createCache = (describeClusters, describeClustersErr) => { + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + } + } + }; +}; + +describe('logServiceEnabled', function () { + describe('run', function () { + it('should FAIL if Cluster does not have Log Service enabled', function (done) { + const cache = createCache([describeClusters[0]]); + logServiceEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster does not have log service enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster has Log Service enabled', function (done) { + const cache = createCache([describeClusters[1]]); + logServiceEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster has log service enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + logServiceEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + logServiceEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ack/multipleIPmode.js b/plugins/alibaba/ack/multipleIPmode.js new file mode 100644 index 000000000..ac6c0053b --- /dev/null +++ b/plugins/alibaba/ack/multipleIPmode.js @@ -0,0 +1,65 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'ACK ENI Multiple IP Mode', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure ENI multiple IP mode support for Kubernetes Cluster.', + more_info: 'Alibaba Cloud ENI (Elastic Network Interface) supports assigning ranges of internal IP addresses as aliases to a single virtual machine\'s ENI network interfaces. This is useful if you have lots of services running on a VM and you want to assign each service a different IP address without quota limitation.', + link: 'https://www.alibabacloud.com/help/doc-detail/97467.htm?spm=a2c63.p38356.b99.209.1e7b2c60a1yuxS', + recommended_action: 'Recreate Kubernetes clusters and select Terway for Network Plugin option during cluster creation.', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + + if (cluster.meta_data) { + try { + let clusterMeta = JSON.parse(cluster.meta_data); + + if (clusterMeta.Capabilities && clusterMeta.Capabilities.Network === 'terway-eniip') { + helpers.addResult(results, 0, + 'Cluster has ENI Multiple IP Mode enabled', + defaultRegion, resource); + } else { + helpers.addResult(results, 2, + 'Cluster does not have ENI Multiple IP Mode enabled', + defaultRegion, resource); + } + } catch (e) { + helpers.addResult(results, 3, `Meta-data info of cluster ${cluster.cluster_id} can not be parsed`, defaultRegion, resource); + } + } else { + helpers.addResult(results, 3, + `Could not find meta-data info for cluster ${cluster.cluster_id}`, + defaultRegion, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/multipleIPmode.spec.js b/plugins/alibaba/ack/multipleIPmode.spec.js new file mode 100644 index 000000000..2f2918bd1 --- /dev/null +++ b/plugins/alibaba/ack/multipleIPmode.spec.js @@ -0,0 +1,201 @@ +var expect = require('chai').expect; +var ENImultipleIPmode = require('./multipleIPmode.js'); + +const describeClusters = [ + { + "name": "khulnasoft-cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"cloud-controller-manager\",\"version\":\"v2.7.0-mgk\"}],\"AuditProjectName\":\"k8s-log-c7b90baff792e40558a01e6c0a536e1d3\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"terway-eniip\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"25\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"v3.5.4\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"IPStack\":\"ipv4\",\"ImageType\":\"AliyunLinux\",\"KubernetesVersion\":\"1.26.3-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"{\\\"ap-south-1a\\\":[\\\"vsw-a2dv93afd4l9roozf0x1i\\\"]}\",\"Provider\":\"\",\"RRSAConfig\":{\"enabled\":false},\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.6.20\",\"ServiceCIDR\":\"172.16.0.0/16\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"Timezone\":\"\",\"VSwitchIds\":null,\"VersionSpec\":null,\"VpcCidr\":\"192.168.0.0/16\",\"ack-node-local-dnsVersion\":\"1.5.6\",\"ack-node-problem-detectorVersion\":\"1.2.16\",\"alicloud-monitor-controllerVersion\":\"v1.8.3\",\"arms-prometheusVersion\":\"1.1.17\",\"cloud-controller-managerVersion\":\"v2.7.0\",\"corednsVersion\":\"v1.9.3.10-7dfca203-aliyun\",\"csi-pluginVersion\":\"v1.26.2-9d15537-aliyun\",\"csi-provisionerVersion\":\"v1.26.2-9d15537-aliyun\",\"gateway-apiVersion\":\"0.6.0\",\"logtail-dsVersion\":\"v1.5.1.0-aliyun\",\"metrics-serverVersion\":\"v0.3.9.4-ff225cd-aliyun\",\"nginx-ingress-controllerVersion\":\"v1.8.0-aliyun.1\",\"security-inspectorVersion\":\"v0.10.1.2-g13c9de7-aliyun\",\"storage-operatorVersion\":\"v1.26.1-50a1499-aliyun\",\"terway-eniipVersion\":\"v1.5.5\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "", + "external_loadbalancer_id": "lb-2evcum8y76kf8a1a6s3m1", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cb3341709db3c4ea587f218d347cfdfeb", + "size": 1, + "region_id": "us-west-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-01T17:14:53+08:00", + "updated": "2021-06-03T18:52:54+08:00", + "init_version": "1.20.4-aliyun.1", + "current_version": "1.20.4-aliyun.1", + "meta_data": "{\"Addons\":[{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-plugin\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"alicloud-monitor-controller\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.5.7-e1d5de8a-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"cloud-controller-manager\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.9.3.381-gd6d0962-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"csi-provisioner\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v1.18.8.47-906bd535-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"kube-flannel-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.13.0.1-466064b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"aliyun-acr-credential-helper\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v21.04.22.0-273c079-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"flannel\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"coredns\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.7.0\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"managed-kube-proxy\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-aliyun.1\"},{\"config\":\"{\\\"IngressDashboardEnabled\\\":\\\"false\\\",\\\"sls_project_name\\\":\\\"akhtar-project\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"logtail-ds\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.16.62.0-da583e0-aliyun\"},{\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"description\":\"\",\"disabled\":false,\"name\":\"nginx-ingress-controller\",\"properties\":null,\"required\":\"\",\"value\":\"\",\"version\":\"v0.44.0.1-5e842447b-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"metrics-server\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v0.3.8.5-307cf45-aliyun\"},{\"config\":\"\",\"description\":\"\",\"disabled\":false,\"name\":\"ack-scheduler\",\"properties\":null,\"required\":\"true\",\"value\":\"\",\"version\":\"v1.20.4-ack-2.0\"}],\"AuditProjectName\":\"akhtar-project4\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"Flannel\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"26\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"aliuid\":\"0000111122223333\",\"components\":[\"apiserver\",\"kcm\",\"scheduler\"],\"log_project\":\"akhtar-project3\",\"log_ttl\":\"30\"},\"DockerVersion\":\"19.03.15\",\"EtcdVersion\":\"v3.4.3\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"1.20.4-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.4.4\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"VersionSpec\":null,\"VpcCidr\":\"10.0.0.0/8\",\"alicloud-monitor-controllerVersion\":\"v1.5.7-e1d5de8a-aliyun\",\"aliyun-acr-credential-helperVersion\":\"v21.04.22.0-273c079-aliyun\",\"corednsVersion\":\"1.7.0\",\"csi-pluginVersion\":\"v1.18.8.47-906bd535-aliyun\",\"csi-provisionerVersion\":\"v1.18.8.47-906bd535-aliyun\",\"kube-flannel-dsVersion\":\"v0.13.0.1-466064b-aliyun\",\"logtail-dsVersion\":\"v0.16.62.0-da583e0-aliyun\",\"metrics-serverVersion\":\"v0.3.8.5-307cf45-aliyun\",\"nginx-ingress-controllerVersion\":\"v0.44.0.2-abf1c6fe4-aliyun\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9xwh22u1bfdo2wjovfs", + "vswitch_id": "vsw-rj9bp9tgbcjqe7rayhtzh", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj9fujjydj19r9chwln1", + "tags": [ + { + "key": "ack.aliyun.com", + "value": "cb3341709db3c4ea587f218d347cfdfeb" + } + ], + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "172.25.32.0/20", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evc9zhl4qb3uhdehq51o", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "19.03.15", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": null, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": true, + "cluster_spec": "ack.pro.small", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + } +]; + +const createCache = (describeClusters, describeClustersErr) => { + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + } + } + }; +}; + +describe('ENImultipleIPmode', function () { + describe('run', function () { + it('should FAIL if Cluster does not have NetworkPolicy Terway enabled', function (done) { + const cache = createCache([describeClusters[1]]); + ENImultipleIPmode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster does not have ENI Multiple IP Mode enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster has NetworkPolicy Terway enabled', function (done) { + const cache = createCache([describeClusters[0]]); + ENImultipleIPmode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster has ENI Multiple IP Mode enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + ENImultipleIPmode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + ENImultipleIPmode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ack/networkPolicyEnabled.js b/plugins/alibaba/ack/networkPolicyEnabled.js new file mode 100644 index 000000000..c07ea61d9 --- /dev/null +++ b/plugins/alibaba/ack/networkPolicyEnabled.js @@ -0,0 +1,62 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Network Policy Enabled', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Kubernetes Engine Clusters are configured to enable NetworkPolicy.', + more_info: 'By default, kubernetes pods accept traffic from any source. But with NetworkPolicy, pods can be configured ' + + 'to reject any connections which are not allowed by any NetworkPolicy.', + link: 'https://www.alibabacloud.com/help/doc-detail/97467.htm?spm=a2c63.p38356.b99.209.1e7b2c60a1yuxS', + recommended_action: 'Recreate Kubernetes clusters and select Terway for Network Plug-in option', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + + if (cluster.meta_data) { + let clusterMeta = JSON.parse(cluster.meta_data); + + if (clusterMeta.Capabilities && clusterMeta.Capabilities.Network === 'terway-eniip') { + helpers.addResult(results, 0, + 'Cluster has NetworkPolicy enabled', + defaultRegion, resource); + } else { + helpers.addResult(results, 2, + 'Cluster does not have NetworkPolicy enabled', + defaultRegion, resource); + } + } else { + helpers.addResult(results, 3, + `Could not find meta-data info for cluster ${cluster.cluster_id}`, + defaultRegion, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/networkPolicyEnabled.spec.js b/plugins/alibaba/ack/networkPolicyEnabled.spec.js new file mode 100644 index 000000000..1d3546346 --- /dev/null +++ b/plugins/alibaba/ack/networkPolicyEnabled.spec.js @@ -0,0 +1,201 @@ +var expect = require('chai').expect; +var networkPolicyEnabled = require('./networkPolicyEnabled.js'); + +const describeClusters = [ + { + "name": "khulnasoft-cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"cloud-controller-manager\",\"version\":\"v2.7.0-mgk\"}],\"AuditProjectName\":\"k8s-log-c7b90baff792e40558a01e6c0a536e1d3\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"Flannel\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"25\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"v3.5.4\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"IPStack\":\"ipv4\",\"ImageType\":\"AliyunLinux\",\"KubernetesVersion\":\"1.26.3-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"{\\\"ap-south-1a\\\":[\\\"vsw-a2dv93afd4l9roozf0x1i\\\"]}\",\"Provider\":\"\",\"RRSAConfig\":{\"enabled\":false},\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.6.20\",\"ServiceCIDR\":\"172.16.0.0/16\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"Timezone\":\"\",\"VSwitchIds\":null,\"VersionSpec\":null,\"VpcCidr\":\"192.168.0.0/16\",\"ack-node-local-dnsVersion\":\"1.5.6\",\"ack-node-problem-detectorVersion\":\"1.2.16\",\"alicloud-monitor-controllerVersion\":\"v1.8.3\",\"arms-prometheusVersion\":\"1.1.17\",\"cloud-controller-managerVersion\":\"v2.7.0\",\"corednsVersion\":\"v1.9.3.10-7dfca203-aliyun\",\"csi-pluginVersion\":\"v1.26.2-9d15537-aliyun\",\"csi-provisionerVersion\":\"v1.26.2-9d15537-aliyun\",\"gateway-apiVersion\":\"0.6.0\",\"logtail-dsVersion\":\"v1.5.1.0-aliyun\",\"metrics-serverVersion\":\"v0.3.9.4-ff225cd-aliyun\",\"nginx-ingress-controllerVersion\":\"v1.8.0-aliyun.1\",\"security-inspectorVersion\":\"v0.10.1.2-g13c9de7-aliyun\",\"storage-operatorVersion\":\"v1.26.1-50a1499-aliyun\",\"terway-eniipVersion\":\"v1.5.5\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "", + "external_loadbalancer_id": "lb-2evcum8y76kf8a1a6s3m1", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cb3341709db3c4ea587f218d347cfdfeb", + "size": 1, + "region_id": "us-west-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-01T17:14:53+08:00", + "updated": "2021-06-03T18:52:54+08:00", + "init_version": "1.20.4-aliyun.1", + "current_version": "1.20.4-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"cloud-controller-manager\",\"version\":\"v2.7.0-mgk\"}],\"AuditProjectName\":\"k8s-log-c7b90baff792e40558a01e6c0a536e1d3\",\"Capabilities\":{\"AnyAZ\":true,\"CSI\":true,\"CpuPolicy\":true,\"DeploymentSet\":true,\"DisableEncryption\":true,\"EncryptionKMSKeyId\":\"\",\"EnterpriseSecurityGroup\":true,\"HpcCluster\":true,\"IntelSGX\":false,\"Knative\":true,\"Network\":\"terway-eniip\",\"NgwPayByLcu\":true,\"NodeCIDRMask\":\"25\",\"NodeNameMode\":true,\"ProxyMode\":\"ipvs\",\"PublicSLB\":false,\"RamRoleType\":\"restricted\",\"SLSProjectName\":true,\"SandboxRuntime\":true,\"SnapshotPolicy\":true,\"Taint\":true,\"TerwayEniip\":true,\"UserData\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"v3.5.4\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"IPStack\":\"ipv4\",\"ImageType\":\"AliyunLinux\",\"KubernetesVersion\":\"1.26.3-aliyun.1\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"Linux\",\"Platform\":\"AliyunLinux\",\"PodVswitchId\":\"{\\\"ap-south-1a\\\":[\\\"vsw-a2dv93afd4l9roozf0x1i\\\"]}\",\"Provider\":\"\",\"RRSAConfig\":{\"enabled\":false},\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"containerd\",\"RuntimeVersion\":\"1.6.20\",\"ServiceCIDR\":\"172.16.0.0/16\",\"SubClass\":\"default\",\"SupportPlatforms\":[\"CentOS\",\"AliyunLinux\",\"Windows\",\"WindowsCore\"],\"Timezone\":\"\",\"VSwitchIds\":null,\"VersionSpec\":null,\"VpcCidr\":\"192.168.0.0/16\",\"ack-node-local-dnsVersion\":\"1.5.6\",\"ack-node-problem-detectorVersion\":\"1.2.16\",\"alicloud-monitor-controllerVersion\":\"v1.8.3\",\"arms-prometheusVersion\":\"1.1.17\",\"cloud-controller-managerVersion\":\"v2.7.0\",\"corednsVersion\":\"v1.9.3.10-7dfca203-aliyun\",\"csi-pluginVersion\":\"v1.26.2-9d15537-aliyun\",\"csi-provisionerVersion\":\"v1.26.2-9d15537-aliyun\",\"gateway-apiVersion\":\"0.6.0\",\"logtail-dsVersion\":\"v1.5.1.0-aliyun\",\"metrics-serverVersion\":\"v0.3.9.4-ff225cd-aliyun\",\"nginx-ingress-controllerVersion\":\"v1.8.0-aliyun.1\",\"security-inspectorVersion\":\"v0.10.1.2-g13c9de7-aliyun\",\"storage-operatorVersion\":\"v1.26.1-50a1499-aliyun\",\"terway-eniipVersion\":\"v1.5.5\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "instance_type": "", + "vpc_id": "vpc-rj9xwh22u1bfdo2wjovfs", + "vswitch_id": "vsw-rj9bp9tgbcjqe7rayhtzh", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj9fujjydj19r9chwln1", + "tags": [ + { + "key": "ack.aliyun.com", + "value": "cb3341709db3c4ea587f218d347cfdfeb" + } + ], + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "172.25.32.0/20", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evc9zhl4qb3uhdehq51o", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "19.03.15", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": null, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": true, + "cluster_spec": "ack.pro.small", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + } +]; + +const createCache = (describeClusters, describeClustersErr) => { + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + } + } + }; +}; + +describe('networkPolicyEnabled', function () { + describe('run', function () { + it('should FAIL if Cluster does not have NetworkPolicy enabled', function (done) { + const cache = createCache([describeClusters[0]]); + networkPolicyEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster does not have NetworkPolicy enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster has NetworkPolicy enabled', function (done) { + const cache = createCache([describeClusters[1]]); + networkPolicyEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster has NetworkPolicy enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + networkPolicyEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + networkPolicyEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ack/webDashboardDisabled.js b/plugins/alibaba/ack/webDashboardDisabled.js new file mode 100644 index 000000000..d671e20fc --- /dev/null +++ b/plugins/alibaba/ack/webDashboardDisabled.js @@ -0,0 +1,62 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Kubernetes Web Dashboard Disabled', + category: 'ACK', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Kubernetes cluster web UI/Dashboard is not enabled.', + more_info: 'The Kubernetes Web UI (Dashboard) is backed by a highly privileged Kubernetes Service Account. It is recommended to use ACK User Console instead of Dashboard to avoid any privileged escalation via compromise the dashboard.', + link: 'https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/', + recommended_action: 'In ACK console, select the target cluster,choose the kube-system namespace in the Namespace pop-menu, input "dashboard" in the deploy filter bar, verify no result exists after the filter, and delete the dashboard deployment by selecting Delete in the More pop-menu.', + apis: ['ACK:describeClustersV1', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var describeClusters = helpers.addSource(cache, source, ['ack', 'describeClustersV1', defaultRegion]); + + if (!describeClusters) return callback(null, results, source); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, `Unable to query ACK clusters: ${helpers.addError(describeClusters)}`, defaultRegion); + return callback(null, results, source); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No ACK clusters found', defaultRegion); + return callback(null, results, source); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.cluster_id) return; + + var resource = helpers.createArn('cs', accountId, 'cluster', cluster.cluster_id, defaultRegion); + + if (cluster.master_url) { + try { + var masterUrlData = JSON.parse(cluster.master_url); + var dashboardEndpoint = masterUrlData.dashboard_endpoint; + + if (dashboardEndpoint && dashboardEndpoint.trim() !== '') { + helpers.addResult(results, 2, 'Kubernetes cluster has dashboard enabled', defaultRegion, resource); + } else { + helpers.addResult(results, 0, 'Kubernetes cluster has dashboard disabled', defaultRegion, resource); + } + } catch (e) { + helpers.addResult(results, 3, `Master_url of cluster ${cluster.cluster_id} can not be parsed`, defaultRegion, resource); + } + } else { + helpers.addResult(results, 3, + `Could not find meta-data info of master_url for cluster ${cluster.cluster_id}`, + defaultRegion, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ack/webDashboardDisabled.spec.js b/plugins/alibaba/ack/webDashboardDisabled.spec.js new file mode 100644 index 000000000..65b360bbf --- /dev/null +++ b/plugins/alibaba/ack/webDashboardDisabled.spec.js @@ -0,0 +1,198 @@ +var expect = require('chai').expect; +var webDashboardDisabled = require('./webDashboardDisabled.js'); + +const describeClusters = [ + { + "name": "khulnasoft-cluster2", + "cluster_id": "cc377f7509590489da921de83a1cad918", + "size": 2, + "region_id": "us-west-1", + "state": "initial", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-03T19:26:32+08:00", + "updated": "0001-01-01T00:00:00Z", + "init_version": "v1.18.8-aliyun.1", + "current_version": "v1.18.8-aliyun.1", + "meta_data": "{\"Addons\":[{\"name\":\"terway-eniip\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IPVlan\\\":\\\"true\\\",\\\"NetworkPolicy\\\":\\\"true\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-plugin\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"csi-provisioner\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"nginx-ingress-controller\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"{\\\"IngressSlbNetworkType\\\":\\\"internet\\\",\\\"IngressSlbSpec\\\":\\\"slb.s2.small\\\"}\",\"value\":\"\",\"description\":\"\",\"properties\":null},{\"name\":\"arms-prometheus\",\"version\":\"\",\"disabled\":false,\"required\":\"\",\"config\":\"\",\"value\":\"\",\"description\":\"\",\"properties\":null}],\"AuditProjectName\":\"\",\"Capabilities\":{\"IntelSGX\":false,\"SandboxRuntime\":true},\"CloudMonitorVersion\":\"\",\"ClusterDomain\":\"\",\"ControlPlaneLogConfig\":{\"log_ttl\":\"\",\"components\":null},\"DockerVersion\":\"\",\"EtcdVersion\":\"\",\"ExtraCertSAN\":null,\"HasSandboxRuntime\":false,\"KubernetesVersion\":\"\",\"MultiAZ\":false,\"NameMode\":\"\",\"NextVersion\":\"\",\"OSType\":\"\",\"Platform\":\"\",\"PodVswitchId\":\"{\\\"us-west-1a\\\":[\\\"vsw-rj98qqgecvwwzn5fzc62p\\\",\\\"vsw-rj94uhhrj5qz5008lwi1x\\\"]}\",\"Provider\":\"\",\"ResourceGroupId\":\"rg-aekzsj44b4lt5fa\",\"Runtime\":\"docker\",\"RuntimeVersion\":\"19.03.5\",\"SubClass\":\"\",\"SupportPlatforms\":null,\"VersionSpec\":null,\"VpcCidr\":\"172.16.0.0/12\"}", + "resource_group_id": "rg-aekzsj44b4lt5fa", + "current_version": "v1.18.8-aliyun.1", + "vpc_id": "vpc-rj9vu86hdve3qr173ew17", + "vswitch_id": "vsw-rj9755hwhio2ua0rdnm00", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj95cax8rsfe92ifomz0", + "tags": null, + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evcum8y76kf8a1a6s3m1", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": { + "Kubernetes": { + "component_name": "Kubernetes", + "version": "v1.18.8-aliyun.1", + "next_version": "", + "changed": "", + "can_upgrade": false, + "force": false, + "policy": "", + "ExtraVars": null, + "ready_to_upgrade": "", + "message": "", + "exist": false, + "category": "", + "required": false, + "template": "", + "value": "", + "description": "", + "properties": null + } + }, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": false, + "cluster_spec": "ack.standard", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + }, + { + "name": "khulnasoft-cluster", + "cluster_id": "cb3341709db3c4ea587f218d347cfdfeb", + "size": 1, + "region_id": "us-west-1", + "state": "running", + "cluster_type": "ManagedKubernetes", + "created": "2021-06-01T17:14:53+08:00", + "updated": "2021-06-03T18:52:54+08:00", + "init_version": "1.20.4-aliyun.1", + "instance_type": "", + "vpc_id": "vpc-rj9xwh22u1bfdo2wjovfs", + "vswitch_id": "vsw-rj9bp9tgbcjqe7rayhtzh", + "vswitch_cidr": "", + "data_disk_size": 0, + "data_disk_category": "cloud", + "security_group_id": "sg-rj9fujjydj19r9chwln1", + "tags": [ + { + "key": "ack.aliyun.com", + "value": "cb3341709db3c4ea587f218d347cfdfeb" + } + ], + "zone_id": "us-west-1a", + "-": "PayByTraffic", + "network_mode": "vpc", + "subnet_cidr": "172.25.32.0/20", + "master_url": "{\"api_server_endpoint\":\"\",\"dashboard_endpoint\":\"https://10.0.0.167:6443\",\"intranet_api_server_endpoint\":\"https://10.0.0.167:6443\"}", + "external_loadbalancer_id": "lb-2evc9zhl4qb3uhdehq51o", + "port": 0, + "node_status": "", + "cluster_healthy": "", + "docker_version": "19.03.15", + "swarm_mode": false, + "gw_bridge": "", + "upgrade_components": null, + "next_version": "", + "private_zone": false, + "service_discovery_types": null, + "private_link": false, + "profile": "Default", + "deletion_protection": true, + "cluster_spec": "ack.pro.small", + "maintenance_window": { + "enable": false, + "maintenance_time": "", + "duration": "", + "weekly_period": "" + }, + "capabilities": null, + "enabled_migration": false, + "need_update_agent": false, + "outputs": null, + "parameters": null, + "worker_ram_role_name": "", + "maintenance_info": null + } +]; + +const createCache = (describeClusters, describeClustersErr) => { + return { + ack: { + describeClustersV1: { + 'cn-hangzhou': { + data: describeClusters, + err: describeClustersErr + }, + } + } + }; +}; + +describe('webDashboardDisabled', function () { + describe('run', function () { + it('should FAIL if Cluster has web dashboard enabled', function (done) { + const cache = createCache([describeClusters[1]]); + webDashboardDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Kubernetes cluster has dashboard enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Cluster does not have web dashboard enabled', function (done) { + const cache = createCache([describeClusters[0]]); + webDashboardDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Kubernetes cluster has dashboard disabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No ACK clusters found', function (done) { + const cache = createCache([]); + webDashboardDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ACK clusters', function (done) { + const cache = createCache(null, { err: 'error' }); + webDashboardDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ACK clusters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}) ; \ No newline at end of file diff --git a/plugins/alibaba/actiontrail/actiontrailBucketPrivate.js b/plugins/alibaba/actiontrail/actiontrailBucketPrivate.js new file mode 100644 index 000000000..e3a9723ae --- /dev/null +++ b/plugins/alibaba/actiontrail/actiontrailBucketPrivate.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'ActionTrail Bucket Private', + category: 'ActionTrail', + domain: 'Compliance', + severity: 'High', + description: 'Ensure that OSS buckets which are acting as ActionTrail trails destinations, should not be publicly accessible.', + more_info: 'When you allow public-access on an OSS bucket, all Internet users can access the objects in the bucket ' + + 'and write data to the bucket. This may cause unexpected access to the data in your bucket, and cause an increase in your fees. ' + + 'If a user uploads prohibited data or information, it may affect your legitimate interests and rights.', + link: 'https://help.aliyun.com/document_detail/31954.html', + recommended_action: 'Modify bucket ACL to restrict access to be private.', + apis: ['ActionTrail:DescribeTrails', 'OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', defaultRegion]); + + if (!listBuckets || listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, defaultRegion); + return callback(null, results, source); + } + + async.each(regions.actiontrail, function(region, rcb) { + var describeTrails = helpers.addSource(cache, source, ['actiontrail', 'DescribeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, 'Unable to query ActionTrail trails: ' + helpers.addError(describeTrails), region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 0, 'No ActionTrail trail found', region); + return rcb(); + } + + var osstrailFound = false; + for (let trail of describeTrails.data) { + if (!trail.OssBucketName) continue; + + osstrailFound = true; + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, trail.OssBucketName]); + var bucketLocation = (getBucketInfo && getBucketInfo.data && getBucketInfo.data.Location) ? + getBucketInfo.data.Location : region; + bucketLocation = bucketLocation.replace('oss-', ''); + + var resource = helpers.createArn('oss', accountId, 'bucket', trail.OssBucketName, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + continue; + } + + if (getBucketInfo.data.AccessControlList && + getBucketInfo.data.AccessControlList.Grant && + getBucketInfo.data.AccessControlList.Grant == 'private') { + helpers.addResult(results, 0, + `ActionTrail trail Bucket ACL allows ${getBucketInfo.data.AccessControlList.Grant} access`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `ActionTrail trail Bucket ACL allows ${getBucketInfo.data.AccessControlList.Grant} access`, + bucketLocation, resource); + } + } + + if (!osstrailFound) { + helpers.addResult(results, 0, 'No ActionTrail trail with OSS bucket destination found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/actiontrail/actiontrailBucketPrivate.spec.js b/plugins/alibaba/actiontrail/actiontrailBucketPrivate.spec.js new file mode 100644 index 000000000..83d47f56e --- /dev/null +++ b/plugins/alibaba/actiontrail/actiontrailBucketPrivate.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +var actiontrailBucketPrivate = require('./actiontrailBucketPrivate') + +const describeTrails = [ + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'All', + Name: 'khulnasoft-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/khulnasoft-proj', + EventRW: 'All', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: 'trail-bucket', + OssWriteRoleArn: '', + IsShadowTrail: 0 + }, + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'All', + Name: 'khulnasoft-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/khulnasoft-proj', + EventRW: 'All', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: 'trail-bucket', + OssWriteRoleArn: '', + IsShadowTrail: 0 + }, + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'us-west-1', + Name: 'khulnasoft-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/khulnasoft-proj', + EventRW: 'Write', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: '', + OssWriteRoleArn: '', + IsShadowTrail: 0 + } +]; + +const listBuckets = [ + { + "name": 'trail-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "trail-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "private" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "trail-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + } +]; + + +const createCache = (listBuckets, listBucketsErr, describeTrails, describeTrailsErr, getBucketInfo, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + actiontrail: { + DescribeTrails: { + 'cn-hangzhou': { + data: describeTrails, + err: describeTrailsErr + } + } + }, + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + } + } +} + +describe('actiontrailBucketPrivate', function () { + describe('run', function () { + it('should FAIL if ActionTrail trail Bucket ACL allows public access', function (done) { + const cache = createCache(listBuckets, null, [describeTrails[1]], null, getBucketInfo[1]); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ActionTrail trail Bucket ACL allows public-read-write access'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if ActionTrail trail Bucket ACL allows private access', function (done) { + const cache = createCache(listBuckets, null, [describeTrails[0]], null, getBucketInfo[0]); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ActionTrail trail Bucket ACL allows private access'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no ActionTrail trail found', function (done) { + const cache = createCache(listBuckets, null, []); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ActionTrail trail found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no ActionTrail trail with OSS bucket destination found', function (done) { + const cache = createCache(listBuckets, null, [describeTrails[2]], null); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ActionTrail trail with OSS bucket destination found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ActionTrail trails', function (done) { + const cache = createCache(listBuckets, null, [], { err: 'Unable to query ActionTrail trails' }); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ActionTrail trails'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, null, describeTrails, null, null, { err: 'Unable to query OSS bucket info' }); + actiontrailBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.js b/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.js new file mode 100644 index 000000000..20bacfdd2 --- /dev/null +++ b/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'ActionTrail Global Export Logs', + category: 'ActionTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensure that ActionTrail is configured to export copies of all log entries for all regions.', + more_info: 'ActionTrail records API calls for Alibaba account which can be exported to OSS bucket. There should be at least one trail which logs all API calls for all regions.', + link: 'https://www.alibabacloud.com/help/doc-detail/28810.htm', + recommended_action: 'Create an ActionTrail trail with applied regions set to All Regions and event type set to All', + apis: ['ActionTrail:DescribeTrails'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var globalTrailFound = false; + var globalTrailOSS = false; + async.each(regions.actiontrail, function(region, rcb) { + var describeTrails = helpers.addSource(cache, source, ['actiontrail', 'DescribeTrails', region]); + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, 'Unable to query ActionTrail trails: ' + helpers.addError(describeTrails), region); + return rcb(); + } + + async.each(describeTrails.data, (trail, tcb) => { + if (!trail.Name) return tcb(); + + if (trail.TrailRegion && trail.TrailRegion.toLowerCase() == 'all' && + trail.EventRW && trail.EventRW.toLowerCase() == 'all') { + globalTrailFound = true; + + if (trail.OssBucketName && trail.OssBucketName.length) { + globalTrailOSS = true; + } + } + + tcb(); + }, function() { + rcb(); + }); + }, function(){ + if (globalTrailFound && globalTrailOSS) { + helpers.addResult(results, 0, + 'ActionTrail has a global trail to log all events', 'global'); + } else if (globalTrailFound){ + helpers.addResult(results, 2, + 'ActionTrail has global trail to log all events but does not export logs to OSS bucket', 'global'); + } else { + helpers.addResult(results, 2, + 'ActionTrail does not have global trail to log all events', 'global'); + } + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.spec.js b/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.spec.js new file mode 100644 index 000000000..99fd66d0e --- /dev/null +++ b/plugins/alibaba/actiontrail/actiontrailGlobalExportLogs.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var actiontrailGlobalExportLogs = require('./actiontrailGlobalExportLogs') + +const describeTrails = [ + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'All', + Name: 'akhtar-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/akhtar-proj', + EventRW: 'All', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: 'trail-bucket', + OssWriteRoleArn: '', + IsShadowTrail: 0 + }, + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'All', + Name: 'akhtar-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/akhtar-proj', + EventRW: 'All', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: '', + OssWriteRoleArn: '', + IsShadowTrail: 0 + }, + { + Status: 'Enable', + HomeRegion: 'us-west-1', + StartLoggingTime: '2021-05-25T15:20:06Z', + CreateTime: '2021-05-25T15:20:05Z', + SlsWriteRoleArn: 'acs:ram::0000111122223333:role/aliyunserviceroleforactiontrail', + OssBucketLocation: '', + TrailRegion: 'us-west-1', + Name: 'akhtar-at', + IsOrganizationTrail: false, + SlsProjectArn: 'acs:log:us-west-1:0000111122223333:project/akhtar-proj', + EventRW: 'Write', + OssKeyPrefix: '', + UpdateTime: '2021-05-25T15:20:06Z', + Region: 'us-west-1', + OssBucketName: '', + OssWriteRoleArn: '', + IsShadowTrail: 0 + } +]; + + +const createCache = (describeTrails, describeTrailsErr) => { + return { + actiontrail: { + DescribeTrails: { + 'cn-hangzhou': { + data: describeTrails, + err: describeTrailsErr + } + } + } + } +} + +describe('actiontrailGlobalExportLogs', function () { + describe('run', function () { + it('should FAIL if ActionTrail does not have global trail to log all events', function (done) { + const cache = createCache([describeTrails[2]]); + actiontrailGlobalExportLogs.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ActionTrail does not have global trail to log all events'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if ActionTrail has global trail to log all events but does not export logs to OSS bucket', function (done) { + const cache = createCache([describeTrails[1]]); + actiontrailGlobalExportLogs.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ActionTrail has global trail to log all events but does not export logs to OSS bucket'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if ActionTrail has global trails to log all events', function (done) { + const cache = createCache([describeTrails[0]]); + actiontrailGlobalExportLogs.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ActionTrail has a global trail to log all events'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ActionTrail trails', function (done) { + const cache = createCache([], { err: 'Unable to query ActionTrail trails' }); + actiontrailGlobalExportLogs.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ActionTrail trails'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/apigateway/apiGroupTlsVersion.js b/plugins/alibaba/apigateway/apiGroupTlsVersion.js new file mode 100644 index 000000000..f241fa3b5 --- /dev/null +++ b/plugins/alibaba/apigateway/apiGroupTlsVersion.js @@ -0,0 +1,53 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'API Group TLS Version', + category: 'APIGateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that API Gateway groups are using latest TLS version.', + more_info: 'API Gateway groups should enforce TLS version 1.2.1 to ensure encryption of data in transit with updated features.', + link: 'https://www.alibabacloud.com/help/doc-detail/115169.html', + recommended_action: 'Configure latest TLS version for API Gateway groups', + apis: ['ApiGateway:DescribeApiGroups', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + for (const region of regions.apigateway) { + const describeApiGroups = helpers.addSource(cache, source, + ['apigateway', 'DescribeApiGroups', region]); + + if (!describeApiGroups) continue; + + if (describeApiGroups.err || !describeApiGroups.data){ + helpers.addResult(results, 3, + 'Unable to describe APIs: ' + helpers.addError(describeApiGroups), region); + continue; + } + + if (!describeApiGroups.data.length) { + helpers.addResult(results, 0, 'No API groups found', region); + continue; + } + + for (const apiGroup of describeApiGroups.data) { + if (!apiGroup.GroupId) continue; + + var resource = helpers.createArn('apigateway', accountId, 'apigroup', apiGroup.GroupId, region); + let configEnabled = false; + if (apiGroup.HttpsPolicy && apiGroup.HttpsPolicy == 'HTTPS2_TLS1_2') configEnabled = true; + + const status = configEnabled ? 0 : 2; + helpers.addResult(results, status, + `API instance ${configEnabled ? 'has' : 'does not have'} latest TLS version`, region, resource); + } + } + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/apigateway/apiGroupTlsVersion.spec.js b/plugins/alibaba/apigateway/apiGroupTlsVersion.spec.js new file mode 100644 index 000000000..3ce9c6579 --- /dev/null +++ b/plugins/alibaba/apigateway/apiGroupTlsVersion.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var apiGroupTlsVersion = require('./apiGroupTlsVersion') + +const describeApiGroups = [ + { + "GroupName": "test", + "HttpsPolicy": "HTTPS2_TLS1_0", + "GroupId": "da12e54692c2435ab4ef3b4d0274ea93", + "RegionId": "cn-hangzhou", + "InstanceType": "VPC_DEDICATED" + }, + { + "GroupName": "test", + "HttpsPolicy": "HTTPS2_TLS1_2", + "GroupId": "da12e54692c2435ab4ef3b4d0274ea93", + "RegionId": "cn-hangzhou", + "InstanceType": "VPC_DEDICATED" + }, + { + "GroupName": "test", + "GroupId": "da12e54692c2435ab4ef3b4d0274ea93", + "RegionId": "cn-hangzhou", + "InstanceType": "VPC_DEDICATED" + }, +] +const createCache = (describeApiGroup) => { + return { + apigateway: { + DescribeApiGroups: { + 'cn-hangzhou': { + data: describeApiGroup + } + } + } + } +} + +const errorCache = () => { + return { + apigateway: { + DescribeApiGroups: { + 'cn-hangzhou': { + err: 'Unable to describe API group' + } + } + } + } +} + +const nullCache = () => { + return { + apigateway: { + DescribeApiGroups: { + 'cn-hangzhou': null + } + } + } +} + +describe('apiGroupTlsVersion', () => { + describe('run', () => { + it('should PASS if API has latest TLS version', done => { + const cache = createCache([describeApiGroups[1]]); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API instance has latest TLS version'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if API does not have latest TLS version', done => { + const cache = createCache([describeApiGroups[0]]); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API instance does not have latest TLS version'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if API response does not have HttpsPolicy', done => { + const cache = createCache([describeApiGroups[2]]); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API instance does not have latest TLS version'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no api groups found', done => { + const cache = createCache([]); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe API groups', done => { + const cache = errorCache(); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should not return anything if response not received', done => { + const cache = nullCache(); + apiGroupTlsVersion.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }) + }) \ No newline at end of file diff --git a/plugins/alibaba/apigateway/apiProtocol.js b/plugins/alibaba/apigateway/apiProtocol.js new file mode 100644 index 000000000..31da5fe2e --- /dev/null +++ b/plugins/alibaba/apigateway/apiProtocol.js @@ -0,0 +1,66 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'API Protocol', + category: 'APIGateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that API Gateway APIs have protocol set to HTTPS.', + more_info: 'HTTPS protocol should be implemented for APIs to ensure encryption of data in transit.', + link: 'https://www.alibabacloud.com/help/doc-detail/29478.htm', + recommended_action: 'Enable HTTPS protocol for APIs', + apis: ['ApiGateway:DescribeApis', 'ApiGateway:DescribeApi', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + + const region = helpers.defaultRegion(settings); + const regions = helpers.regions(settings); + const accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + for (const region of regions.apigateway) { + const describeApis = helpers.addSource(cache, source, + ['apigateway', 'DescribeApis', region]); + + if (!describeApis) continue; + + if (describeApis.err || !describeApis.data) { + helpers.addResult(results, 3, + 'Unable to describe APIs: ' + helpers.addError(describeApis), region); + continue; + } + + if (!describeApis.data.length) { + helpers.addResult(results, 0, 'No APIs found', region); + continue; + } + + for (var api of describeApis.data) { + if (!api.ApiId) continue; + + const resource = helpers.createArn('apigateway', accountId, 'api', api.ApiId); + const describeApi = helpers.addSource(cache, source, + ['apigateway', 'DescribeApi', region, api.ApiId]); + + if (!describeApi || describeApi.err || !describeApi.data) { + helpers.addResult(results, 3, + 'Unable to describe API: ' + helpers.addError(describeApi), region, resource); + continue; + } + + let secure = false; + if (describeApi.data && + describeApi.data.RequestConfig && + describeApi.data.RequestConfig.RequestProtocol && + describeApi.data.RequestConfig.RequestProtocol.toUpperCase() === 'HTTPS') secure = true; + + const status = secure ? 0 : 2; + helpers.addResult(results, status, + `API ${secure ? 'has' : 'does not have'} HTTPS protocol configured`, region, resource); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/apigateway/apiProtocol.spec.js b/plugins/alibaba/apigateway/apiProtocol.spec.js new file mode 100644 index 000000000..ff95b4404 --- /dev/null +++ b/plugins/alibaba/apigateway/apiProtocol.spec.js @@ -0,0 +1,247 @@ +var expect = require('chai').expect; +var apiProtocol = require('./apiProtocol') + +const describeApis = { + "GroupName": "test_group", + "CreatedTime": "2021-08-24T05:47:32Z", + "ModifiedTime": "2021-08-24T05:47:32Z", + "ApiName": "test_api", + "Visibility": "PRIVATE", + "RegionId": "cn-hangzhou", + "ApiId": "69567aca1ff14efe8b864fb1a6f58f32", + "GroupId": "db81d7d3fd794d3db5a4642afb408fa7" +} + +const describeApi = [ + { + "GroupName": "test_group", + "CreatedTime": "2021-08-24T05:47:32Z", + "ForceNonceCheck": false, + "DeployedInfos": { + "DeployedInfo": [ + { + "StageName": "RELEASE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "PRE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "TEST", + "EffectiveVersion": "20210824134753641", + "DeployedStatus": "DEPLOYED" + } + ] + }, + "ResultDescriptions": { + "ResultDescription": [] + }, + "AuthType": "APP", + "RequestConfig": { + "RequestPath": "/getInfo", + "RequestMode": "MAPPING", + "RequestProtocol": "HTTPS", + "RequestHttpMethod": "GET", + "PostBodyDescription": "", + "BodyFormat": "" + }, + "GroupId": "db81d7d3fd794d3db5a4642afb408fa7", + "Visibility": "PRIVATE", + "RegionId": "cn-hangzhou", + "ServiceParameters": { + "ServiceParameter": [] + }, + "ApiId": "69567aca1ff14efe8b864fb1a6f58f32" + }, + { + "GroupName": "test_group", + "CreatedTime": "2021-08-24T05:47:32Z", + "ForceNonceCheck": false, + "DeployedInfos": { + "DeployedInfo": [ + { + "StageName": "RELEASE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "PRE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "TEST", + "EffectiveVersion": "20210824134753641", + "DeployedStatus": "DEPLOYED" + } + ] + }, + "ResultDescriptions": { + "ResultDescription": [] + }, + "AuthType": "APP", + "RequestConfig": { + "RequestPath": "/getInfo", + "RequestMode": "MAPPING", + "RequestProtocol": "HTTP", + "RequestHttpMethod": "GET", + "PostBodyDescription": "", + "BodyFormat": "" + }, + "GroupId": "db81d7d3fd794d3db5a4642afb408fa7", + "Visibility": "PRIVATE", + "RegionId": "cn-hangzhou", + "ServiceParameters": { + "ServiceParameter": [] + }, + "ApiId": "69567aca1ff14efe8b864fb1a6f58f32" + }, + { + "GroupName": "test_group", + "CreatedTime": "2021-08-24T05:47:32Z", + "ForceNonceCheck": false, + "DeployedInfos": { + "DeployedInfo": [ + { + "StageName": "RELEASE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "PRE", + "DeployedStatus": "NONDEPLOYED" + }, + { + "StageName": "TEST", + "EffectiveVersion": "20210824134753641", + "DeployedStatus": "DEPLOYED" + } + ] + }, + "ResultDescriptions": { + "ResultDescription": [] + }, + "AuthType": "APP", + "GroupId": "db81d7d3fd794d3db5a4642afb408fa7", + "Visibility": "PRIVATE", + "RegionId": "cn-hangzhou", + "ServiceParameters": { + "ServiceParameter": [] + }, + "ApiId": "69567aca1ff14efe8b864fb1a6f58f32" + } + +] + +const createCache = (describeApis, describeApi) => { + const apiKey = (describeApis && describeApis.length && describeApis[0].ApiId) ? describeApis[0].ApiId : null; + return { + apigateway: { + DescribeApis: { + 'cn-hangzhou': { + data: describeApis, + } + }, + DescribeApi: { + 'cn-hangzhou': { + [apiKey]: { + data: describeApi, + } + } + } + } + } +} +const errorCache = () => { + return { + apigateway: { + DescribeApis: { + 'cn-hangzhou': { + err: 'Unable to describe APIs', + } + }, + DescribeApi: { + 'cn-hangzhou': { + err: 'Unable to describe API', + } + } + } + } +} + +const nullCache = () => { + return { + apigateway: { + DescribeApis: { + 'cn-hangzhou': null + }, + DescribeApi: { + 'cn-hangzhou': null + } + } + } +} + +describe('apiProtocol', () => { + describe('run', () => { + it('should PASS if API has HTTPS protocol configured', done => { + const cache = createCache([describeApis], describeApi[0]); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API has HTTPS protocol configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if API does not HTTPS protocol configured', done => { + const cache = createCache([describeApis], describeApi[1]); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API does not have HTTPS protocol configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if API response does not have RequestConfig property', done => { + const cache = createCache([describeApis], describeApi[2]); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API does not have HTTPS protocol configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no APIs are found', done => { + const cache = createCache({}, {}); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No APIs found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe APIs', done => { + const cache = errorCache(); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should not return anything if response not received', done => { + const cache = nullCache(); + apiProtocol.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/ecs/dataDisksEncrypted.js b/plugins/alibaba/ecs/dataDisksEncrypted.js new file mode 100644 index 000000000..d99cbe20e --- /dev/null +++ b/plugins/alibaba/ecs/dataDisksEncrypted.js @@ -0,0 +1,139 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +var encryptionLevels = ['none', 'sse', 'cloudkms', 'alibabacmk', 'externalcmk', 'cloudhsm']; + +function getEncryptionLevel(kmsKey) { + if (kmsKey.Origin) { + if (kmsKey.Origin === 'Aliyun_KMS') { + if (kmsKey.ProtectionLevel) { + if (kmsKey.ProtectionLevel.toUpperCase() == 'SOFTWARE') return 3; + if (kmsKey.ProtectionLevel.toUpperCase() == 'HSM') return 5; + } + } + if (kmsKey.Origin === 'EXTERNAL') return 4; + } + + return 0; +} + +module.exports = { + title: 'Data Disks Encrypted', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that encryption is enabled for ECS data disk volumes.', + more_info: 'Encryption can help you secure your data stored in Alibaba Cloud ECS and comply with security standards.', + link: 'https://www.alibabacloud.com/help/doc-detail/59643.htm', + recommended_action: 'Enable encryption for ECS data disk volumes.', + apis: ['ECS:DescribeDisks', 'KMS:ListKeys', 'KMS:DescribeKey', 'STS:GetCallerIdentity'], + compliance: { + hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + + 'ECS disk is a HIPAA-compliant solution that provides automated encryption ' + + 'of ECS instance data at rest.', + pci: 'PCI requires proper encryption of cardholder data at rest. Encryption ' + + 'should be enabled for all disk volumes storing this type of data.' + }, + settings: { + data_disks_encryption_level: { + name: 'ECS Data Disks Encryption Level', + description: 'In order (lowest to highest) cloudkms=Alibaba managed default service KMS; alibabacmk=Customer managed KMS; externalcmk=Customer imported key; cloudhsm=Customer managed CloudHSM sourced Key', + regex: '^(cloudkms|alibabacmk|externalcmk|cloudhsm)$', + default: 'cloudkms', + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var targetEncryptionLevel = encryptionLevels.indexOf(settings.data_disks_encryption_level || this.settings.data_disks_encryption_level.default); + + async.each(regions.ecs, function(region, rcb) { + var describeDisks = helpers.addSource(cache, source, ['ecs', 'DescribeDisks', region]); + + if (!describeDisks) return rcb(); + + if (describeDisks.err || !describeDisks.data) { + helpers.addResult(results, 3, 'Unable to query ECS disks: ' + helpers.addError(describeDisks), region); + return rcb(); + } + + if (!describeDisks.data.length) { + helpers.addResult(results, 0, 'No ECS disks found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, ['kms', 'ListKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, 'Unable to query KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + var found = false; + async.each(describeDisks.data, (disk, dcb) => { + if (!disk.DiskId || disk.Type.toLowerCase() !== 'data') return dcb(); + + found = true; + + let resource = helpers.createArn('ecs', accountId, 'disk', disk.DiskId, region); + + if (!disk.Encrypted) { + helpers.addResult(results, 2, + `Data disk is not encrypted to ${encryptionLevels[targetEncryptionLevel]}`, + region, resource); + return dcb(); + } + + if (!disk.KMSKeyId || !disk.KMSKeyId.length) { + if (targetEncryptionLevel > 2) { + helpers.addResult(results, 2, + `Data disk is not encrypted to ${encryptionLevels[targetEncryptionLevel]}`, + region, resource); + } else { + helpers.addResult(results, 0, + `Data disk is encrypted to at least ${encryptionLevels[targetEncryptionLevel]}`, + region, resource); + } + + return dcb(); + } + + var describeKey = helpers.addSource(cache, source, ['kms', 'DescribeKey', region, disk.KMSKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data) { + let keyResource = helpers.createArn('kms', accountId, 'key', disk.KMSKeyId, region); + helpers.addResult(results, 3, + `Unable to describe KMS key: ${helpers.addError(describeKey)}`, + region, keyResource); + return dcb(); + } + + var currentEncryptionLevel = getEncryptionLevel(describeKey.data); + + if (currentEncryptionLevel < targetEncryptionLevel) { + helpers.addResult(results, 2, + `Data disk is not encrypted to ${encryptionLevels[targetEncryptionLevel]}`, + region, resource); + } else { + helpers.addResult(results, 0, `Data disk is encrypted to at least ${encryptionLevels[targetEncryptionLevel]}`, + region, resource); + } + + dcb(); + }, function() { + if (!found) { + helpers.addResult(results, 0, 'No ECS data disks found', region); + } + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/dataDisksEncrypted.spec.js b/plugins/alibaba/ecs/dataDisksEncrypted.spec.js new file mode 100644 index 000000000..39394e7c9 --- /dev/null +++ b/plugins/alibaba/ecs/dataDisksEncrypted.spec.js @@ -0,0 +1,316 @@ +var expect = require('chai').expect; +var dataDisksEncrypted = require('./dataDisksEncrypted.js'); + +const describeDisks = [ + { + "DetachedTime": "", + "Category": "cloud_essd", + "Description": "", + "KMSKeyId": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DedicatedBlockStorageClusterId": "", + "Size": 40, + "Encrypted": true, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "", + "StorageSetId": "", + "Tags": { + "Tag": [] + }, + "Status": "Available", + "AttachedTime": "", + "ZoneId": "us-west-1b", + "SourceSnapshotId": "", + "ProductCode": "", + "InstanceId": "", + "PerformanceLevel": "PL1", + "Device": "", + "DeleteWithInstance": false, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "", + "OperationLocks": { + "OperationLock": [] + }, + "BdfId": "", + "Portable": true, + "Type": "data", + "SerialNumber": "rj956ec1t8qrh97kei6k", + "MountInstances": { + "MountInstance": [] + }, + "CreationTime": "2021-05-06T10:23:18Z", + "RegionId": "us-west-1", + "DiskId": "d-rj956ec1t8qrh97kei6k" + }, + { + "DetachedTime": "", + "Category": "cloud_essd", + "KMSKeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "Description": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DedicatedBlockStorageClusterId": "", + "Size": 20, + "Encrypted": true, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "", + "StorageSetId": "", + "Tags": { + "Tag": [] + }, + "Status": "Available", + "AttachedTime": "", + "ZoneId": "us-west-1b", + "SourceSnapshotId": "", + "ProductCode": "", + "InstanceId": "", + "PerformanceLevel": "PL1", + "Device": "", + "DeleteWithInstance": false, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "akhtar-made", + "OperationLocks": { + "OperationLock": [] + }, + "BdfId": "", + "Portable": true, + "Type": "data", + "SerialNumber": "rj94pupgygkqr4y3rr59", + "MountInstances": { + "MountInstance": [] + }, + "CreationTime": "2021-05-05T18:31:03Z", + "RegionId": "us-west-1", + "DiskId": "d-rj94pupgygkqr4y3rr59" + }, + { + "DetachedTime": "", + "Category": "cloud_efficiency", + "Description": "", + "KMSKeyId": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DedicatedBlockStorageClusterId": "", + "Size": 20, + "Encrypted": true, + "DeleteAutoSnapshot": true, + "DiskChargeType": "PostPaid", + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "", + "StorageSetId": "", + "Tags": { + "Tag": [] + }, + "Status": "In_use", + "AttachedTime": "2021-05-05T11:56:22Z", + "ZoneId": "us-west-1b", + "InstanceId": "i-rj9cexclrthxbysg4w5x", + "SourceSnapshotId": "", + "ProductCode": "", + "Device": "/dev/xvdb", + "PerformanceLevel": "", + "DeleteWithInstance": false, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "akhtar-made", + "OperationLocks": { + "OperationLock": [] + }, + "BdfId": "", + "Portable": true, + "Type": "data", + "SerialNumber": "rj9jcm5n3s695mng74et", + "MountInstances": { + "MountInstance": [] + }, + "CreationTime": "2021-05-03T11:11:53Z", + "RegionId": "us-west-1", + "DiskId": "d-rj9jcm5n3s695mng74et" + }, + { + "DetachedTime": "2021-05-06T10:20:59Z", + "Category": "cloud_efficiency", + "Description": "", + "KMSKeyId": "", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DedicatedBlockStorageClusterId": "", + "Size": 20, + "Encrypted": false, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "ExpiredTime": "", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20210325.vhd", + "StorageSetId": "", + "Tags": { + "Tag": [ + { + "TagKey": "acs:ecs:sourceInstanceId", + "TagValue": "i-rj9cexclrthxbysg4w5x" + }, + { + "TagKey": "acs:ecs:diskDeleteProtection", + "TagValue": "true" + }, + { + "TagKey": "acs:ecs:diskPayType", + "TagValue": "AfterPay" + } + ] + }, + "Status": "Available", + "AttachedTime": "2021-04-30T09:57:27Z", + "ZoneId": "us-west-1b", + "SourceSnapshotId": "", + "ProductCode": "", + "InstanceId": "", + "Device": "", + "PerformanceLevel": "", + "DeleteWithInstance": true, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "", + "OperationLocks": { + "OperationLock": [ + { + "LockReason": "detached-system-disk" + } + ] + }, + "BdfId": "", + "Portable": true, + "Type": "data", + "SerialNumber": "rj947ftycv8s1xxvghaa", + "MountInstances": { + "MountInstance": [] + }, + "CreationTime": "2021-04-30T09:57:14Z", + "RegionId": "us-west-1", + "DiskId": "d-rj947ftycv8s1xxvghaa" + } +]; + +const listKeys = [ + { + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeyArn": "acs:kms:us-west-1:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260" + } +]; + +const describeKey = [ + { + "data": { + "CreationDate": "2021-05-03T11:11:47Z", + "Description": "", + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeySpec": "Aliyun_AES_256", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT/DECRYPT", + "PrimaryKeyVersion": "9e42450c-fe3a-4bc0-93b5-e8074aa4b4c9", + "DeleteDate": "", + "Creator": "Ecs", + "Arn": "acs:kms:us-west-1:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260", + "Origin": "Aliyun_KMS", + "MaterialExpireTime": "", + "ProtectionLevel": "SOFTWARE", + "LastRotationDate": "2021-05-03T11:11:47Z", + "AutomaticRotation": "Disabled" + } + } +]; + +const createCache = (disksData, listKeys, describeKeyData, disksErr, listKeysErr, describeKeyErr) => { + let keyId = (listKeys && listKeys.length) ? listKeys[0].KeyId : null; + return { + ecs: { + DescribeDisks: { + 'cn-hangzhou': { + data: disksData, + err: disksErr + }, + } + }, + kms: { + ListKeys: { + 'cn-hangzhou': { + data: listKeys, + err: listKeysErr + } + }, + DescribeKey: { + 'cn-hangzhou': { + [keyId]: { + data: describeKeyData, + err: describeKeyErr + } + } + } + } + }; +}; + +describe('dataDisksEncrypted', function () { + describe('run', function () { + it('should FAIL if disk is not encrypted', function (done) { + const cache = createCache([describeDisks[3]], listKeys); + dataDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Data disk is not encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if Data disk is not encrypted to target encryption level', function (done) { + const cache = createCache([describeDisks[0]], listKeys); + dataDisksEncrypted.run(cache, { data_disks_encryption_level: 'alibabacmk', china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Data disk is not encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if data disks are encrypted', function (done) { + const cache = createCache([describeDisks[0]], listKeys); + dataDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Data disk is encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no ECS disks found', function (done) { + const cache = createCache([]); + dataDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS disks found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query ECS disks', function (done) { + const cache = createCache([], null, null, { err: 'Unable to query ECS disks' }); + dataDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ECS disks'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ecs/openAllPortsProtocols.js b/plugins/alibaba/ecs/openAllPortsProtocols.js new file mode 100644 index 000000000..e070e9ad7 --- /dev/null +++ b/plugins/alibaba/ecs/openAllPortsProtocols.js @@ -0,0 +1,102 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open All Ports Protocols', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that ECS security groups do not have all ports or protocols open to the public', + more_info: 'Security groups should be created on a per-service basis and avoid allowing all ports or protocols.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/51170.htm', + recommended_action: 'Modify the security group to specify a specific port and protocol to allow.', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + compliance: { + hipaa: 'HIPAA requires strict access controls to networks and services ' + + 'processing sensitive data. Security groups are the built-in ' + + 'method for restricting access to Alibaba services and should be ' + + 'configured to allow least-privilege access.', + pci: 'PCI has explicit requirements around firewalled access to systems. ' + + 'Security groups should be properly secured to prevent access to ' + + 'backend services.' + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, { china: true }, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to describe security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + var found = false; + async.each(describeSecurityGroups.data, (group, scb)=> { + var strings = []; + var resource = helpers.createArn('ecs', accountId, 'securitygroup', group.SecurityGroupId, region); + + var describeSecurityGroupAttribute = helpers.addSource(cache, { china: true }, + ['ecs', 'DescribeSecurityGroupAttribute', region, group.SecurityGroupId]); + + if (!describeSecurityGroupAttribute || describeSecurityGroupAttribute.err || !describeSecurityGroupAttribute.data) { + helpers.addResult(results, 3, + `Unable to query security group attributes: ${describeSecurityGroupAttribute}`, region, resource); + return scb(); + } + + if (describeSecurityGroupAttribute.data.Permissions && + describeSecurityGroupAttribute.data.Permissions.Permission && + describeSecurityGroupAttribute.data.Permissions.Permission.length){ + for (var permission of describeSecurityGroupAttribute.data.Permissions.Permission) { + if (permission.Direction && permission.Direction !== 'ingress') continue; + if (permission.SourceCidrIp === '0.0.0.0/0') { + if (permission.PortRange == '-1/-1') { + var string = 'all ports open to 0.0.0.0/0'; + if (strings.indexOf(string) === -1) strings.push(string); + found = true; + } + + if (permission.IpProtocol && permission.IpProtocol.toUpperCase() === 'ALL') { + var stringO = 'all protocols open to 0.0.0.0/0'; + if (strings.indexOf(stringO) === -1) strings.push(stringO); + found = true; + } + } + } + } + + if (strings.length) { + helpers.addResult(results, 2, + `Security group: ${group.SecurityGroupId} has ${strings.join(' and ')}`, + region, resource); + } + + scb(); + }, function(){ + if (!found) { + helpers.addResult(results, 0, 'No public open ports found', region); + } + + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openAllPortsProtocols.spec.js b/plugins/alibaba/ecs/openAllPortsProtocols.spec.js new file mode 100644 index 000000000..3910aebcf --- /dev/null +++ b/plugins/alibaba/ecs/openAllPortsProtocols.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openAllPortsProtocols = require('./openAllPortsProtocols'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "UDP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "-1/-1", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "ALL", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openAllPortsProtocols', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openAllPortsProtocols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has all ports and protocols open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openAllPortsProtocols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has all ports open to 0.0.0.0/0 and all protocols open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openAllPortsProtocols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openAllPortsProtocols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openCIFS.js b/plugins/alibaba/ecs/openCIFS.js new file mode 100644 index 000000000..eb2fe1aa1 --- /dev/null +++ b/plugins/alibaba/ecs/openCIFS.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open CIFS', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have UDP port 445 for CIFS open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as CIFS should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict UDP port 445 for CIFS to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'udp': [445] + }; + + var service = 'CIFS'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openCIFS.spec.js b/plugins/alibaba/ecs/openCIFS.spec.js new file mode 100644 index 000000000..ec06fe298 --- /dev/null +++ b/plugins/alibaba/ecs/openCIFS.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openCIFS = require('./openCIFS'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "UDP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "445/445", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "UDP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openCIFS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openCIFS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has CIFS UDP 445 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openCIFS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has CIFS:UDP:445 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCIFS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCIFS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openCustomPorts.js b/plugins/alibaba/ecs/openCustomPorts.js new file mode 100644 index 000000000..eada356c5 --- /dev/null +++ b/plugins/alibaba/ecs/openCustomPorts.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Custom Ports', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that defined custom ports are not open to public.', + more_info: 'Security groups should restrict access to ports from known networks.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Modify the security group to ensure the defined custom ports are not exposed publicly', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + settings: { + restricted_open_ports: { + name: 'Restricted Open Ports', + description: 'Comma separated list of ports/port-ranges that should be restricted and not publicly open. Example: tcp:80,tcp:443,tcp:80-443', + regex: '[a-zA-Z0-9,:]', + default: '' + }, + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var restricted_open_ports = settings.restricted_open_ports || this.settings.restricted_open_ports.default; + + if (!restricted_open_ports.length) return callback(); + + restricted_open_ports = restricted_open_ports.split(','); + + var ports = {}; + restricted_open_ports.forEach(port => { + var [protocol, portNo] = port.split(':'); + if (ports[protocol]) { + ports[protocol].push(portNo); + } else { + ports[protocol] = [portNo]; + } + }); + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, 'custom', region, results); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openCustomPorts.spec.js b/plugins/alibaba/ecs/openCustomPorts.spec.js new file mode 100644 index 000000000..a8c3620f9 --- /dev/null +++ b/plugins/alibaba/ecs/openCustomPorts.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openCustomPorts = require('./openCustomPorts'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "53/80", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openCustomPorts', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22', china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has custom ports open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:60,tcp:65-70', china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has custom:TCP:60, some of TCP:65-70 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22', china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22', china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openDNS.js b/plugins/alibaba/ecs/openDNS.js new file mode 100644 index 000000000..e19c8eac6 --- /dev/null +++ b/plugins/alibaba/ecs/openDNS.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open DNS', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP or UDP port 53 for DNS open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as DNS should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP and UDP port 53 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'udp': [53], + 'tcp': [53] + }; + + var service = 'DNS'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openDNS.spec.js b/plugins/alibaba/ecs/openDNS.spec.js new file mode 100644 index 000000000..068aecb8b --- /dev/null +++ b/plugins/alibaba/ecs/openDNS.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openDNS = require('./openDNS'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "53/53", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openDNS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openDNS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has RDP TCP 53 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openDNS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has DNS:TCP:53 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openDNS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openDNS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openDocker.js b/plugins/alibaba/ecs/openDocker.js new file mode 100644 index 000000000..f5592f23e --- /dev/null +++ b/plugins/alibaba/ecs/openDocker.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Docker', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 2375 or 2376 for Docker open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Docker should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP ports 2375 and 2376 for Docker to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [2375, 2376] + }; + + var service = 'Docker'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openDocker.spec.js b/plugins/alibaba/ecs/openDocker.spec.js new file mode 100644 index 000000000..5d087b444 --- /dev/null +++ b/plugins/alibaba/ecs/openDocker.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openDocker = require('./openDocker'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "2375/2375", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openDocker', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openDocker.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Docker TCP 2375 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openDocker.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Docker:TCP:2375 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openDocker.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openDocker.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openElasticsearch.js b/plugins/alibaba/ecs/openElasticsearch.js new file mode 100644 index 000000000..13e75ce69 --- /dev/null +++ b/plugins/alibaba/ecs/openElasticsearch.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Elasticsearch', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 9200 for Elasticsearch open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Elasticsearch should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 9200 for Elasticsearch to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [9200] + }; + + var service = 'Elasticsearch'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openElasticsearch.spec.js b/plugins/alibaba/ecs/openElasticsearch.spec.js new file mode 100644 index 000000000..e4bc888e0 --- /dev/null +++ b/plugins/alibaba/ecs/openElasticsearch.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openElasticsearch = require('./openElasticsearch'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "9200/9200", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openElasticsearch', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openElasticsearch.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Elasticsearch TCP 9200 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openElasticsearch.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Elasticsearch:TCP:9200 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openElasticsearch.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openElasticsearch.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openFTP.js b/plugins/alibaba/ecs/openFTP.js new file mode 100644 index 000000000..d704d9ba3 --- /dev/null +++ b/plugins/alibaba/ecs/openFTP.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open FTP', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 20 or 21 for FTP open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as FTP should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 20 or 21 for FTP to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [20, 21] + }; + + var service = 'FTP'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openFTP.spec.js b/plugins/alibaba/ecs/openFTP.spec.js new file mode 100644 index 000000000..1b1362ccb --- /dev/null +++ b/plugins/alibaba/ecs/openFTP.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openFTP = require('./openFTP'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "20/20", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openFTP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openFTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has FTP TCP 20 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openFTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has FTP:TCP:20 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openFTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openFTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openHadoopNameNode.js b/plugins/alibaba/ecs/openHadoopNameNode.js new file mode 100644 index 000000000..5ef043911 --- /dev/null +++ b/plugins/alibaba/ecs/openHadoopNameNode.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Hadoop HDFS NameNode Metadata Service', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 8020 for HDFS NameNode metadata service open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 8020 for HDFS to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [8020] + }; + + var service = 'HDFSNameNodeMetadataService'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openHadoopNameNode.spec.js b/plugins/alibaba/ecs/openHadoopNameNode.spec.js new file mode 100644 index 000000000..9b174985f --- /dev/null +++ b/plugins/alibaba/ecs/openHadoopNameNode.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openHadoopNameNode = require('./openHadoopNameNode'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "UDP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "8020/8020", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openHadoopNameNode', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openHadoopNameNode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has HDFSNameNodeMetadataService TCP 8020 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openHadoopNameNode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has HDFSNameNodeMetadataService:TCP:8020 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHadoopNameNode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHadoopNameNode.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openHadoopNameNodeWebUI.js b/plugins/alibaba/ecs/openHadoopNameNodeWebUI.js new file mode 100644 index 000000000..90221774d --- /dev/null +++ b/plugins/alibaba/ecs/openHadoopNameNodeWebUI.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Hadoop HDFS NameNode WebUI', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 50070 and 50470 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [50070,50470] + }; + + var service = 'NameNodeWebUI'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openHadoopNameNodeWebUI.spec.js b/plugins/alibaba/ecs/openHadoopNameNodeWebUI.spec.js new file mode 100644 index 000000000..2d952c4bd --- /dev/null +++ b/plugins/alibaba/ecs/openHadoopNameNodeWebUI.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openHadoopNameNodeWebUI = require('./openHadoopNameNodeWebUI'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "UDP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "50070/50070", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openHadoopNameNodeWebUI', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openHadoopNameNodeWebUI.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has NameNodeWebUI TCP 50070 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openHadoopNameNodeWebUI.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has NameNodeWebUI:TCP:50070 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHadoopNameNodeWebUI.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHadoopNameNodeWebUI.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openKibana.js b/plugins/alibaba/ecs/openKibana.js new file mode 100644 index 000000000..482e6383a --- /dev/null +++ b/plugins/alibaba/ecs/openKibana.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Kibana', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 5601 for Kibana open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Kibana should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 5601 for Kibana to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [5601] + }; + + var service = 'Kibana'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openKibana.spec.js b/plugins/alibaba/ecs/openKibana.spec.js new file mode 100644 index 000000000..7a54f4040 --- /dev/null +++ b/plugins/alibaba/ecs/openKibana.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openKibana = require('./openKibana'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "5601/5601", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openKibana', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openKibana.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Kibana TCP 5601 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openKibana.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Kibana:TCP:5601 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openKibana.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openKibana.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openMySQL.js b/plugins/alibaba/ecs/openMySQL.js new file mode 100644 index 000000000..b9ea2e3a1 --- /dev/null +++ b/plugins/alibaba/ecs/openMySQL.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open MySQL', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 4333 or 3306 for MySQL open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MySQL should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 4333 or 3306 for MySQL to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [3306, 4333] + }; + + var service = 'MySQL'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openMySQL.spec.js b/plugins/alibaba/ecs/openMySQL.spec.js new file mode 100644 index 000000000..9086db39d --- /dev/null +++ b/plugins/alibaba/ecs/openMySQL.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openMySQL = require('./openMySQL'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "3306/3306", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openMySQL', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openMySQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has MySQL TCP 3306 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openMySQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has MySQL:TCP:3306 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openMySQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openMySQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openNetBIOS.js b/plugins/alibaba/ecs/openNetBIOS.js new file mode 100644 index 000000000..ba23b7a2e --- /dev/null +++ b/plugins/alibaba/ecs/openNetBIOS.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open NetBIOS', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have UDP port 137 or 138 for NetBIOS open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as NetBIOS should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict UDP port 137 or 138 for NetBIOS to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'udp': [137, 138] + }; + + var service = 'NetBIOS'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openNetBIOS.spec.js b/plugins/alibaba/ecs/openNetBIOS.spec.js new file mode 100644 index 000000000..5a331ffa6 --- /dev/null +++ b/plugins/alibaba/ecs/openNetBIOS.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openNetBIOS = require('./openNetBIOS'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "UDP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "137/137", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "UDP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openNetBIOS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openNetBIOS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has NetBIOS UDP 137 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openNetBIOS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has NetBIOS:UDP:137 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openNetBIOS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openNetBIOS.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openOracle.js b/plugins/alibaba/ecs/openOracle.js new file mode 100644 index 000000000..cccb332ad --- /dev/null +++ b/plugins/alibaba/ecs/openOracle.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Oracle', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 1521 and 2483 for Oracle open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 1521 and 2483 for Oracle to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [1521, 2483] + }; + + var service = 'Oracle'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openOracle.spec.js b/plugins/alibaba/ecs/openOracle.spec.js new file mode 100644 index 000000000..4751c8fd5 --- /dev/null +++ b/plugins/alibaba/ecs/openOracle.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openOracle = require('./openOracle'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "1521/1521", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openOracle', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openOracle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Oracle TCP 1521 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openOracle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Oracle:TCP:1521 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openOracle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openOracle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openOracleAutoDataWarehouse.js b/plugins/alibaba/ecs/openOracleAutoDataWarehouse.js new file mode 100644 index 000000000..f69a85b0d --- /dev/null +++ b/plugins/alibaba/ecs/openOracleAutoDataWarehouse.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Oracle Auto Data Warehouse', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 1522 for Oracle Auto Data Warehouse open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle Auto Data Warehouse should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 1522 for Oracle Auto Data Warehouse to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [1522] + }; + + var service = 'Oracle Auto Data Warehouse'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openOracleAutoDataWarehouse.spec.js b/plugins/alibaba/ecs/openOracleAutoDataWarehouse.spec.js new file mode 100644 index 000000000..9fce93899 --- /dev/null +++ b/plugins/alibaba/ecs/openOracleAutoDataWarehouse.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openOracleAutoDataWarehouse = require('./openOracleAutoDataWarehouse'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "1522/1522", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openOracleAutoDataWarehouse', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openOracleAutoDataWarehouse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Oracle Auto Data Warehouse TCP 1522 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openOracleAutoDataWarehouse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Oracle Auto Data Warehouse:TCP:1522 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openOracleAutoDataWarehouse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openOracleAutoDataWarehouse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openPostgreSQL.js b/plugins/alibaba/ecs/openPostgreSQL.js new file mode 100644 index 000000000..855170edb --- /dev/null +++ b/plugins/alibaba/ecs/openPostgreSQL.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open PostgreSQL', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 5432 for PostgreSQL open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as PostgreSQL should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 5432 for PostgreSQL to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [5432] + }; + + var service = 'PostgreSQL'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openPostgreSQL.spec.js b/plugins/alibaba/ecs/openPostgreSQL.spec.js new file mode 100644 index 000000000..27d6bc2ce --- /dev/null +++ b/plugins/alibaba/ecs/openPostgreSQL.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openPostgreSQL = require('./openPostgreSQL'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "5432/5432", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openPostgreSQL', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openPostgreSQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has PostgreSQL TCP 5432 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openPostgreSQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has PostgreSQL:TCP:5432 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openPostgreSQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openPostgreSQL.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openRDP.js b/plugins/alibaba/ecs/openRDP.js new file mode 100644 index 000000000..53b0fc189 --- /dev/null +++ b/plugins/alibaba/ecs/openRDP.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open RDP', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 3389 for RDP open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RDP should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 3389 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [3389] + }; + + var service = 'RDP'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openRDP.spec.js b/plugins/alibaba/ecs/openRDP.spec.js new file mode 100644 index 000000000..8a24ccf2a --- /dev/null +++ b/plugins/alibaba/ecs/openRDP.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openRDP = require('./openRDP'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "3389/3389", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openRDP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openRDP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has RDP TCP 3389 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openRDP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has RDP:TCP:3389 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openRDP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openRDP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openSMBoTCP.js b/plugins/alibaba/ecs/openSMBoTCP.js new file mode 100644 index 000000000..733ebac6e --- /dev/null +++ b/plugins/alibaba/ecs/openSMBoTCP.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open SMBoTCP', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 445 for Windows SMB over TCP open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMBoTCP should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 445 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [445] + }; + + var service = 'SMBoTCP'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openSMBoTCP.spec.js b/plugins/alibaba/ecs/openSMBoTCP.spec.js new file mode 100644 index 000000000..feeef9b20 --- /dev/null +++ b/plugins/alibaba/ecs/openSMBoTCP.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openSMBoTCP = require('./openSMBoTCP'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "445/445", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openSMBoTCP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openSMBoTCP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has SMBoTCP TCP 445 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openSMBoTCP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has SMBoTCP:TCP:445 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSMBoTCP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSMBoTCP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openSMTP.js b/plugins/alibaba/ecs/openSMTP.js new file mode 100644 index 000000000..f21cc1991 --- /dev/null +++ b/plugins/alibaba/ecs/openSMTP.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open SMTP', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 25 for SMTP open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMTP should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 25 for SMTP to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [25] + }; + + var service = 'SMTP'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openSMTP.spec.js b/plugins/alibaba/ecs/openSMTP.spec.js new file mode 100644 index 000000000..970cfa39f --- /dev/null +++ b/plugins/alibaba/ecs/openSMTP.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openSMTP = require('./openSMTP'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "25/25", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openSMTP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openSMTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has SMTP TCP 25 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openSMTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has SMTP:TCP:25 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSMTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSMTP.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openSQLServer.js b/plugins/alibaba/ecs/openSQLServer.js new file mode 100644 index 000000000..cdcaff12b --- /dev/null +++ b/plugins/alibaba/ecs/openSQLServer.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open SQL Server', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 1433 or UDP port 1434 for SQL Server open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SQL Server should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 1433 or UDP port 1434 for SQL Server to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [1433], + 'udp': [1434] + }; + + var service = 'SQL Server'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openSQLServer.spec.js b/plugins/alibaba/ecs/openSQLServer.spec.js new file mode 100644 index 000000000..196b2c0b9 --- /dev/null +++ b/plugins/alibaba/ecs/openSQLServer.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openSQLServer = require('./openSQLServer'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "1433/1433", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openSQLServer', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openSQLServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has SQL Server TCP 1433 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openSQLServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has SQL Server:TCP:1433 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSQLServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSQLServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openSSH.js b/plugins/alibaba/ecs/openSSH.js new file mode 100644 index 000000000..4ec008eb2 --- /dev/null +++ b/plugins/alibaba/ecs/openSSH.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open SSH', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that security groups does not have TCP port 22 for SSH open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SSH should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 22 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [22] + }; + + var service = 'SSH'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openSSH.spec.js b/plugins/alibaba/ecs/openSSH.spec.js new file mode 100644 index 000000000..45f20439d --- /dev/null +++ b/plugins/alibaba/ecs/openSSH.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openSSH = require('./openSSH'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "22/22", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openSSH', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openSSH.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has SSH TCP 22 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openSSH.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has SSH:TCP:22 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSSH.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSSH.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openSalt.js b/plugins/alibaba/ecs/openSalt.js new file mode 100644 index 000000000..b69d6a04d --- /dev/null +++ b/plugins/alibaba/ecs/openSalt.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Salt', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP ports 4505 or 4506 for the Salt master open to the public.', + more_info: 'Active Salt vulnerabilities, CVE-2020-11651 and CVE-2020-11652 are exploiting Salt instances exposed to the internet. These ports should be closed immediately.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP ports 4505 and 4506 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [4505, 4506] + }; + + var service = 'Salt'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openSalt.spec.js b/plugins/alibaba/ecs/openSalt.spec.js new file mode 100644 index 000000000..974d3746a --- /dev/null +++ b/plugins/alibaba/ecs/openSalt.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openSalt = require('./openSalt'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "4505/4505", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openSalt', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openSalt.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Salt TCP 4505 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openSalt.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Salt:TCP:4505 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSalt.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSalt.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openTelnet.js b/plugins/alibaba/ecs/openTelnet.js new file mode 100644 index 000000000..f713cfbea --- /dev/null +++ b/plugins/alibaba/ecs/openTelnet.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open Telnet', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 23 for Telnet open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Telnet should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 23 for Telnet to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [23] + }; + + var service = 'Telnet'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openTelnet.spec.js b/plugins/alibaba/ecs/openTelnet.spec.js new file mode 100644 index 000000000..5cfb760d5 --- /dev/null +++ b/plugins/alibaba/ecs/openTelnet.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openTelnet = require('./openTelnet'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "23/23", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openTelnet', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openTelnet.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has Telnet TCP 23 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openTelnet.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has Telnet:TCP:23 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openTelnet.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openTelnet.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openVNCClient.js b/plugins/alibaba/ecs/openVNCClient.js new file mode 100644 index 000000000..bffcadb37 --- /dev/null +++ b/plugins/alibaba/ecs/openVNCClient.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open VNC Client', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 5500 for VNC Client open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Client should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 5500 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [5500] + }; + + var service = 'VNC Client'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openVNCClient.spec.js b/plugins/alibaba/ecs/openVNCClient.spec.js new file mode 100644 index 000000000..44a2f87a1 --- /dev/null +++ b/plugins/alibaba/ecs/openVNCClient.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openVNCClient = require('./openVNCClient'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "5500/5500", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openVNCClient', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openVNCClient.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has VNC Client TCP 5500 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openVNCClient.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has VNC Client:TCP:5500 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openVNCClient.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openVNCClient.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/openVNCServer.js b/plugins/alibaba/ecs/openVNCServer.js new file mode 100644 index 000000000..14843b4f0 --- /dev/null +++ b/plugins/alibaba/ecs/openVNCServer.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Open VNC Server', + category: 'ECS', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that security groups does not have TCP port 5900 for VNC Server open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Server should be restricted to known IP addresses.', + link: 'https://www.alibabacloud.com/help/doc-detail/25471.htm', + recommended_action: 'Restrict TCP port 5900 to known IP addresses', + apis: ['ECS:DescribeSecurityGroups', 'ECS:DescribeSecurityGroupAttribute', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [5900] + }; + + var service = 'VNC Server'; + + async.each(regions.ecs, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ecs', 'DescribeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to describe security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + helpers.findOpenPorts(cache, describeSecurityGroups.data, ports, service, region, results); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/openVNCServer.spec.js b/plugins/alibaba/ecs/openVNCServer.spec.js new file mode 100644 index 000000000..019ef1bad --- /dev/null +++ b/plugins/alibaba/ecs/openVNCServer.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const openVNCServer = require('./openVNCServer'); + +const describeSecurityGroups = [ + { + "Description": "System created security group.", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "ServiceManaged": false, + "ResourceGroupId": "", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "CreationTime": "2021-04-30T09:57:23Z", + "SecurityGroupType": "normal", + "Tags": { + "Tag": [] + } + } +]; + +const describeSecurityGroupAttribute = [ + { + "Description": "System created security group.", + "RequestId": "B417712F-F2D9-4D84-9E14-53642866EC41", + "SecurityGroupName": "sg-rj998kwpxbxh3muao6nx", + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "SecurityGroupId": "sg-rj998kwpxbxh3muao6nx", + "Permissions": { + "Permission": [ + { + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "443/443", + "SourceCidrIp": "0.0.0.0/0", + "IpProtocol": "TCP" + } + ] + } + }, + { + "Description": "System created security group.", + "RequestId": "BCC3A7D9-93A5-44AA-85C1-A0C94A53DDBD", + "SecurityGroupName": "sg-0xijcm5n3s67cgnlklmi", + "VpcId": "vpc-0xitjib9awrnrv6i3sk9y", + "SecurityGroupId": "sg-0xijcm5n3s67cgnlklmi", + "Permissions": { + "Permission": [ + { + "SourceGroupId": "", + "Policy": "Accept", + "Description": "System created rule.", + "SourcePortRange": "", + "Priority": 100, + "CreateTime": "2021-04-29T22:40:41Z", + "DestPrefixListName": "", + "Ipv6SourceCidrIp": "", + "NicType": "intranet", + "DestGroupId": "", + "Direction": "ingress", + "SourceGroupName": "", + "PortRange": "5900/5900", + "DestGroupOwnerAccount": "", + "DestPrefixListId": "", + "SourceCidrIp": "0.0.0.0/0", + "SourcePrefixListName": "", + "IpProtocol": "TCP", + "DestCidrIp": "", + "DestGroupName": "", + "SourceGroupOwnerAccount": "", + "Ipv6DestCidrIp": "", + "SourcePrefixListId": "" + }, + ] + } + } +]; + +const createCache = (securityGroups, describeSecurityGroupAttribute, securityGroupsErr, describeSecurityGroupAttributeErr) => { + const securityGroupId = (securityGroups && securityGroups.length) ? securityGroups[0].SecurityGroupId : null; + return { + ecs:{ + DescribeSecurityGroups: { + 'cn-hangzhou': { + err: securityGroupsErr, + data: securityGroups + } + }, + DescribeSecurityGroupAttribute: { + 'cn-hangzhou': { + [securityGroupId]: { + err: describeSecurityGroupAttributeErr, + data: describeSecurityGroupAttribute + } + } + } + } + }; +}; + +describe('openVNCServer', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[0]); + openVNCServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No public open ports found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if security group has VNC Server TCP 5900 port open to public', function (done) { + const cache = createCache(describeSecurityGroups, describeSecurityGroupAttribute[1]); + openVNCServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has VNC Server:TCP:5900 open to 0.0.0.0/0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openVNCServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openVNCServer.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe security groups'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/ecs/systemDisksEncrypted.js b/plugins/alibaba/ecs/systemDisksEncrypted.js new file mode 100644 index 000000000..45af4a329 --- /dev/null +++ b/plugins/alibaba/ecs/systemDisksEncrypted.js @@ -0,0 +1,120 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'System Disks Encryption', + category: 'ECS', + domain: 'Compute', + severity: 'High', + description: 'Ensure that encryption is enabled for ECS system disk volumes.', + more_info: 'Encryption can help you secure your data stored in Alibaba Cloud ECS and comply with security standards.', + link: 'https://www.alibabacloud.com/help/doc-detail/59643.htm', + recommended_action: 'Enable encryption for ECS system disk volumes.', + apis: ['ECS:DescribeDisks', 'KMS:ListKeys', 'KMS:DescribeKey', 'STS:GetCallerIdentity'], + compliance: { + hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + + 'ECS disk is a HIPAA-compliant solution that provides automated encryption ' + + 'of ECS instance data at rest.', + pci: 'PCI requires proper encryption of cardholder data at rest. Encryption ' + + 'should be enabled for all disk volumes storing this type of data.' + }, + settings: { + system_disks_encryption_level: { + name: 'ECS system Disks Encryption Level', + description: 'In order (lowest to highest) cloudkms=Alibaba managed default service KMS; alibabacmk=Customer managed KMS; externalcmk=Customer imported key; cloudhsm=Customer managed CloudHSM sourced Key', + regex: '^(cloudkms|alibabacmk|externalcmk|cloudhsm)$', + default: 'cloudkms', + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + var targetEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(settings.system_disks_encryption_level || this.settings.system_disks_encryption_level.default); + + async.each(regions.ecs, function(region, rcb) { + var describeDisks = helpers.addSource(cache, source, ['ecs', 'DescribeDisks', region]); + if (!describeDisks) return rcb(); + + if (describeDisks.err || !describeDisks.data) { + helpers.addResult(results, 3, 'Unable to query ECS disks: ' + helpers.addError(describeDisks), region); + return rcb(); + } + + if (!describeDisks.data.length) { + helpers.addResult(results, 0, 'No ECS disks found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, ['kms', 'ListKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, 'Unable to query KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + var found = false; + + async.each(describeDisks.data, (disk, dcb) => { + if (!disk.DiskId || (disk.Type && disk.Type.toLowerCase() !== 'system')) return dcb(); + + found = true; + let resource = helpers.createArn('ecs', accountId, 'disk', disk.DiskId, region); + + if (!disk.Encrypted) { + helpers.addResult(results, 2, + `System disk is not encrypted to ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, + region, resource); + return dcb(); + } + + if (!disk.KMSKeyId) { + if (targetEncryptionLevel > 2) { + helpers.addResult(results, 2, + `System disk is not encrypted to ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, + region, resource); + } else { + helpers.addResult(results, 0, `System disk is encrypted to at least ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, + region, resource); + } + + return dcb(); + } + + var describeKey = helpers.addSource(cache, source, ['kms', 'DescribeKey', region, disk.KMSKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data) { + let keyResource = helpers.createArn('kms', accountId, 'key', disk.KMSKeyId, region); + helpers.addResult(results, 3, + `Unable to describe KMS key: ${helpers.addError(describeKey)}`, + region, keyResource); + return dcb(); + } + + var currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data, helpers.ENCRYPTION_LEVELS); + + if (currentEncryptionLevel < targetEncryptionLevel) { + helpers.addResult(results, 2, + `System disk is not encrypted to ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, + region, resource); + } else { + helpers.addResult(results, 0, `System disk is encrypted to at least ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, + region, resource); + } + dcb(); + }, function() { + if (!found) { + helpers.addResult(results, 0, 'No ECS system disks found', region); + } + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/ecs/systemDisksEncrypted.spec.js b/plugins/alibaba/ecs/systemDisksEncrypted.spec.js new file mode 100644 index 000000000..c25ea9196 --- /dev/null +++ b/plugins/alibaba/ecs/systemDisksEncrypted.spec.js @@ -0,0 +1,306 @@ +var expect = require("chai").expect; +var systemDisksEncrypted = require("./systemDisksEncrypted.js"); + +const describeDisks = [ + { + "DetachedTime": "", + "KMSKeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "Category": "cloud_essd", + "Description": "", + "ResourceGroupId": "rg-aekzqtoj2lxbgmq", + "DedicatedBlockStorageClusterId": "", + "Encrypted": true, + "Size": 20, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "MultiAttach": "Disabled", + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "", + "StorageSetId": "", + "Tags": { Tag: [] }, + "Status": "Available", + "AttachedTime": "", + "ZoneId": "us-west-1b", + "SourceSnapshotId": "", + "InstanceId": "", + "ProductCode": "", + "PerformanceLevel": "PL1", + "Device": "", + "DeleteWithInstance": false, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "encrypted_disk", + "OperationLocks": { OperationLock: [] }, + "BdfId": "", + "Portable": true, + "Type": "data", + "SerialNumber": "rj9jd5ee7h54aax5xdp0", + "CreationTime": "2021-06-15T13:21:49Z", + "RegionId": "us-west-1", + "DiskId": "d-rj9jd5ee7h54aax5xdp0", + }, + { + "DetachedTime": "", + "Category": "cloud_essd", + "Description": "", + "KMSKeyId": "", + "ResourceGroupId": "rg-acfnsrchcdcbvfa", + "DedicatedBlockStorageClusterId": "", + "Encrypted": true, + "Size": 120, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "MultiAttach": "Disabled", + "Attachments": { }, + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20210325.vhd", + "StorageSetId": "", + "Tags": { Tag: [Array] }, + "Status": "In_use", + "AttachedTime": "2021-05-25T14:07:23Z", + "ZoneId": "us-west-1a", + "InstanceId": "i-rj9ai23qaqnabm6i23dd", + "SourceSnapshotId": "", + "ProductCode": "", + "PerformanceLevel": "PL1", + "Device": "/dev/xvda", + "DeleteWithInstance": true, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "", + "OperationLocks": { OperationLock: [] }, + "BdfId": "", + "Portable": true, + "Type": "system", + "SerialNumber": "rj94hrde98wgfuvoc6ei", + "CreationTime": "2021-05-25T14:07:19Z", + "RegionId": "us-west-1", + "DiskId": "d-rj94hrde98wgfuvoc6ei", + }, + { + "DetachedTime": "", + "Category": "cloud_essd", + "Description": "", + "KMSKeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "ResourceGroupId": "rg-acfnsrchcdcbvfa", + "DedicatedBlockStorageClusterId": "", + "Encrypted": true, + "Size": 120, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "MultiAttach": "Disabled", + "Attachments": { }, + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20210325.vhd", + "StorageSetId": "", + "Tags": { Tag: [Array] }, + "Status": "In_use", + "AttachedTime": "2021-05-25T14:07:23Z", + "ZoneId": "us-west-1a", + "InstanceId": "i-rj9ai23qaqnabm6i23df", + "SourceSnapshotId": "", + "ProductCode": "", + "PerformanceLevel": "PL1", + "Device": "/dev/xvda", + "DeleteWithInstance": true, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "", + "OperationLocks": { OperationLock: [] }, + "BdfId": "", + "Portable": true, + "Type": "system", + "SerialNumber": "rj94hrde98wgfuvoc6ek", + "CreationTime": "2021-05-25T14:07:19Z", + "RegionId": "us-west-1", + "DiskId": "d-rj94hrde98wgfuvoc6ek", + }, + { + "DetachedTime": "", + "Category": "cloud_essd", + "Description": "", + "KMSKeyId": "", + "ResourceGroupId": "rg-acfnsrchcdcbvfa", + "DedicatedBlockStorageClusterId": "", + "Encrypted": false, + "Size": 120, + "DeleteAutoSnapshot": false, + "DiskChargeType": "PostPaid", + "MultiAttach": "Disabled", + "Attachments": { }, + "ExpiredTime": "2999-09-08T16:00Z", + "ImageId": "aliyun_2_1903_x64_20G_alibase_20210325.vhd", + "StorageSetId": "", + "Tags": { Tag: [Array] }, + "Status": "In_use", + "AttachedTime": "2021-05-25T14:07:23Z", + "ZoneId": "us-west-1a", + "InstanceId": "i-rj9ai23qaqnabm6i23de", + "SourceSnapshotId": "", + "ProductCode": "", + "PerformanceLevel": "PL1", + "Device": "/dev/xvda", + "DeleteWithInstance": true, + "EnableAutomatedSnapshotPolicy": false, + "EnableAutoSnapshot": true, + "AutoSnapshotPolicyId": "", + "DiskName": "", + "OperationLocks": { OperationLock: [] }, + "BdfId": "", + "Portable": true, + "Type": "system", + "SerialNumber": "rj94hrde98wgfuvoc6ej", + "CreationTime": "2021-05-25T14:07:19Z", + "RegionId": "us-west-1", + "DiskId": "d-rj94hrde98wgfuvoc6ej", + }, +]; +const listKeys = [ + { + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeyArn": + "acs:kms:us-west-1:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260", + }, +]; + +const describeKey = [ + { + "data": { + "CreationDate": "2021-05-03T11:11:47Z", + "Description": "", + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeySpec": "Aliyun_AES_256", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT/DECRYPT", + "PrimaryKeyVersion": "9e42450c-fe3a-4bc0-93b5-e8074aa4b4c9", + "DeleteDate": "", + "Creator": "Ecs", + "Arn": "acs:kms:us-west-1:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260", + "Origin": "Aliyun_KMS", + "MaterialExpireTime": "", + "ProtectionLevel": "SOFTWARE", + "LastRotationDate": "2021-05-03T11:11:47Z", + 'AutomaticRotation': "Disabled", + }, + }, +]; + +const createCache = ( + disksData, + listKeys, + describeKeyData, + disksErr, + listKeysErr, + describeKeyErr +) => { + let keyId = listKeys && listKeys.length ? listKeys[0].KeyId : null; + let keyData = describeKeyData && describeKeyData.length ? describeKeyData[0].data : null; + return { + ecs: { + DescribeDisks: { + "cn-hangzhou": { + data: disksData, + err: disksErr, + }, + }, + }, + kms: { + ListKeys: { + "cn-hangzhou": { + data: listKeys, + err: listKeysErr, + }, + }, + DescribeKey: { + "cn-hangzhou": { + [keyId]: { + data: keyData, + err: describeKeyErr, + }, + }, + }, + }, + }; +}; + +const system_disks_encryption_level = "alibabacmk"; +const system_disks_result_limit = "0"; + +describe("systemDisksEncrypted", function () { + describe("run", function () { + + it("should PASS if System disks are encrypted", function (done) { + const cache = createCache([describeDisks[1]], listKeys); + systemDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include("System disk is encrypted to at least cloudkms"); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should PASS if System disks are encrypted to target encryption level", function (done) { + const cache = createCache([describeDisks[2]], listKeys, describeKey); + systemDisksEncrypted.run(cache, { system_disks_encryption_level, china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include(`System disk is encrypted to at least ${system_disks_encryption_level}`); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should FAIL if disk is not encrypted", function (done) { + const cache = createCache([describeDisks[3]], listKeys); + systemDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("System disk is not encrypted to cloudkms"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should FAIL if System disk is not encrypted to target encryption level", function (done) { + const cache = createCache([describeDisks[3]], listKeys); + systemDisksEncrypted.run( + cache, { system_disks_encryption_level, china: true }, + (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include(`System disk is not encrypted to ${system_disks_encryption_level}`); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + } + ); + }); + + it("should PASS if no ECS disks found", function (done) { + const cache = createCache([]); + systemDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No ECS disks found"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should UNKNOWN if unable to query ECS disks", function (done) { + const cache = createCache([], null, null, { + err: "Unable to query ECS disks", + }); + systemDisksEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query ECS disks"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + }); +}); diff --git a/plugins/alibaba/oss/bucketCmkEncrypted.js b/plugins/alibaba/oss/bucketCmkEncrypted.js new file mode 100644 index 000000000..133c6e49e --- /dev/null +++ b/plugins/alibaba/oss/bucketCmkEncrypted.js @@ -0,0 +1,142 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket CMK Encrypted', + category: 'OSS', + domain: 'Storage', + severity: 'High', + description: 'Ensure that OSS buckets are encrypted using Alibaba CMK.', + more_info: 'OSS buckets should be encrypted using customer master keys in order to gain greater control and transparency, ' + + 'as well as increasing security by having full control of the encryption keys.', + recommended_action: 'Modify bucket\'s server-side encrypted setting to configure encryption method as KMS', + link: 'https://www.alibabacloud.com/help/doc-detail/31871.html', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'KMS:ListKeys', 'KMS:DescribeKey', 'STS:GetCallerIdentity'], + settings: { + oss_buckets_encryption_level: { + name: 'OSS Buckets Encryption Level', + description: 'In order (lowest to highest) ossmanaged=OSS-Managed; cloudkms=Alibaba managed default service KMS; alibabacmk=Customer managed KMS; externalcmk=Customer imported key; cloudhsm=Customer managed CloudHSM sourced Key', + regex: '^(ossmanaged|cloudkms|alibabacmk|externalcmk|cloudhsm)$', + default: 'alibabacmk', + } + }, + compliance: { + hipaa: 'All data in HIPAA environments must be encrypted, including ' + + 'data at rest. OSS encryption ensures that this HIPAA control ' + + 'is implemented by providing KMS-backed encryption for all OSS ' + + 'buckets data.', + pci: 'PCI requires proper encryption of cardholder data at rest. OSS ' + + 'encryption should be enabled for all buckets storing this type ' + + 'of data.' + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var targetEncryptionLevelStr = settings.oss_buckets_encryption_level || this.settings.oss_buckets_encryption_level.default; + var targetEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(targetEncryptionLevelStr); + var currentEncryptionLevelStr; + var currentEncryptionLevel; + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + var listKeys = helpers.addSource(cache, source, ['kms', 'ListKeys', region]); + + if (!listKeys) return callback(null, results, source); + + if (listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, 'Unable to query KMS keys: ' + helpers.addError(listKeys), region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return; + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (getBucketInfo.data.ServerSideEncryptionRule && + getBucketInfo.data.ServerSideEncryptionRule.SSEAlgorithm) { + let sseRule = getBucketInfo.data.ServerSideEncryptionRule; + let sseAlgo = getBucketInfo.data.ServerSideEncryptionRule.SSEAlgorithm; + + if (sseAlgo.toUpperCase() == 'NONE') { + helpers.addResult(results, 2, + 'OSS bucket is not server-side encrypted', bucketLocation, resource); + return cb(); + } + + if (sseAlgo.toUpperCase() == 'KMS') { + if (sseRule.KMSMasterKeyID && + sseRule.KMSMasterKeyID.length) { + let kmsKey = sseRule.KMSMasterKeyID; + + var describeKey = helpers.addSource(cache, source, ['kms', 'DescribeKey', bucketLocation, kmsKey]); + + if (!describeKey || describeKey.err || !describeKey.data) { + helpers.addResult(results, 3, + `Unable to query KMS key (${kmsKey}): ${helpers.addError(describeKey)}`, + bucketLocation, resource); + return cb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; // cloudkms + } + } else { + currentEncryptionLevel = 1; // oss-managed + } + + currentEncryptionLevelStr = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + if (currentEncryptionLevel >= targetEncryptionLevel) { + helpers.addResult(results, 0, + `OSS bucket is server-side encrypted to ${currentEncryptionLevelStr} which is greater than or equal to required level ${targetEncryptionLevelStr}`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `OSS bucket is server-side encrypted to ${currentEncryptionLevelStr} which is less than required level ${targetEncryptionLevelStr}`, + bucketLocation, resource); + } + } else { + helpers.addResult(results, 2, + 'OSS bucket is not server-side encrypted', bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/bucketCmkEncrypted.spec.js b/plugins/alibaba/oss/bucketCmkEncrypted.spec.js new file mode 100644 index 000000000..1f7b7c5f4 --- /dev/null +++ b/plugins/alibaba/oss/bucketCmkEncrypted.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +var bucketCmkEncrypted = require('./bucketCmkEncrypted.js'); + +const listBuckets = [ + { + "name": "khulnasoft-test-bucket", + "region": "oss-cn-hangzhou", + "creationDate": "2021-05-03T11:53:13.000Z", + "storageClass": "Standard", + "StorageClass": "Standard", + "tag": {} + }, +]; + +const getBucketInfo = [ + { + "Name": "khulnasoft-test-bucket", + "ServerSideEncryptionRule": { + "KMSMasterKeyID": "ed204e08-f814-4788-8406-3dc19c8e5260", + "SSEAlgorithm": "KMS" + } + }, + { + "Location": "oss-cn-hangzhou", + "Name": "khulnasoft-test-bucket", + "ServerSideEncryptionRule": { + "KMSMasterKeyID": "", + "SSEAlgorithm": "KMS" + } + }, + { + "Location": "oss-cn-hangzhou", + "Name": "khulnasoft-test-bucket", + "ServerSideEncryptionRule": { + "SSEAlgorithm": "AES256" + } + }, + { + "Location": "oss-cn-hangzhou", + "Name": "khulnasoft-test-bucket", + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + } + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + kms: { + ListKeys: { + 'cn-hangzhou': { + data: [ + { + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeyArn": "acs:kms:cn-hangzhou:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260" + } + ] + } + }, + DescribeKey: { + 'cn-hangzhou': { + "ed204e08-f814-4788-8406-3dc19c8e5260": { + "data": { + "CreationDate": "2021-05-03T11:11:47Z", + "Description": "", + "KeyId": "ed204e08-f814-4788-8406-3dc19c8e5260", + "KeySpec": "Aliyun_AES_256", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT/DECRYPT", + "PrimaryKeyVersion": "9e42450c-fe3a-4bc0-93b5-e8074aa4b4c9", + "DeleteDate": "", + "Creator": "Ecs", + "Arn": "acs:kms:cn-hangzhou:0000111122223333:key/ed204e08-f814-4788-8406-3dc19c8e5260", + "Origin": "Aliyun_KMS", + "MaterialExpireTime": "", + "ProtectionLevel": "SOFTWARE", + "LastRotationDate": "2021-05-03T11:11:47Z", + "AutomaticRotation": "Disabled", + "DeletionProtection": "Disabled" + } + } + } + } + } + }; +}; + +describe('bucketCmkEncrypted', function () { + describe('run', function () { + it('should FAIL if OSS bucket is not encrypted to required encryption level', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OSS bucket is server-side encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if OSS bucket is not encrypted', function (done) { + const cache = createCache(listBuckets, getBucketInfo[3]); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OSS bucket is not server-side encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if OSS bucket is encrypted to required encryption level', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OSS bucket is server-side encrypted'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + bucketCmkEncrypted.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/bucketCrossRegionReplication.js b/plugins/alibaba/oss/bucketCrossRegionReplication.js new file mode 100644 index 000000000..dde502e5d --- /dev/null +++ b/plugins/alibaba/oss/bucketCrossRegionReplication.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket Cross Region Replication Enabled', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS buckets have cross region replication enabled.', + more_info: 'Enabling Cross region replication for OSS buckets provides automatic and asynchronous replication of objects across buckets in different Object Storage Service (OSS) regions.', + recommended_action: 'Modify OSS buckets to enable cross region replication.', + link: 'https://www.alibabacloud.com/help/doc-detail/181408.htm?', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketInfo.data.CrossRegionReplication && + getBucketInfo.data.CrossRegionReplication.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, + 'Bucket has cross region replication enabled', + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'Bucket does not have cross region replication enabled', + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/bucketCrossRegionReplication.spec.js b/plugins/alibaba/oss/bucketCrossRegionReplication.spec.js new file mode 100644 index 000000000..85afd5a4a --- /dev/null +++ b/plugins/alibaba/oss/bucketCrossRegionReplication.spec.js @@ -0,0 +1,183 @@ +var expect = require('chai').expect; +var bucketCrossRegionReplication = require('./bucketCrossRegionReplication.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "private" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Enabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Enabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "log-bucket", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "log-bucket", + "LogPrefix": "" + } + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + }; +}; + +describe('bucketCrossRegionReplication', function () { + describe('run', function () { + it('should FAIL if bucket does not have cross region replication enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have cross region replication enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if bucket info does not have cross region replication property', function (done) { + const cache = createCache(listBuckets, getBucketInfo[2]); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have cross region replication enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has cross region replication enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has cross region replication enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + bucketCrossRegionReplication.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/bucketLoggingEnabled.js b/plugins/alibaba/oss/bucketLoggingEnabled.js new file mode 100644 index 000000000..f601134fb --- /dev/null +++ b/plugins/alibaba/oss/bucketLoggingEnabled.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket Logging Enabled', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS buckets has logging enabled.', + more_info: 'Enabling logging for OSS buckets provides visibility into request made to access bucket objects which can be useful in auditing and security workflows.', + recommended_action: 'Modify OSS buckets to enable logging.', + link: 'https://www.alibabacloud.com/help/doc-detail/31900.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketInfo.data.BucketPolicy && + getBucketInfo.data.BucketPolicy.LogBucket && + getBucketInfo.data.BucketPolicy.LogBucket.length) { + helpers.addResult(results, 0, + 'Bucket has logging enabled', + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'Bucket does not have logging enabled', + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/bucketLoggingEnabled.spec.js b/plugins/alibaba/oss/bucketLoggingEnabled.spec.js new file mode 100644 index 000000000..f4b4092c5 --- /dev/null +++ b/plugins/alibaba/oss/bucketLoggingEnabled.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +var bucketLoggingEnabled = require('./bucketLoggingEnabled.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "private" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "log-bucket", + "LogPrefix": "" + } + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + }; +}; + +describe('bucketLoggingEnabled', function () { + describe('run', function () { + it('should FAIL if bucket does not have logging enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + bucketLoggingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have logging enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has logging enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + bucketLoggingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has logging enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketLoggingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketLoggingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + bucketLoggingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/bucketPayByRequester.js b/plugins/alibaba/oss/bucketPayByRequester.js new file mode 100644 index 000000000..5e7fb1ac1 --- /dev/null +++ b/plugins/alibaba/oss/bucketPayByRequester.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket Pay By Requester Enabled', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS buckets have pay per requester feature enabled.', + more_info: 'Enabling pay per requester for OSS buckets ensures that requesters pay the request and traffic fees that are incurred when the requesters access objects in the bucket.', + recommended_action: 'Modify OSS buckets to enable pay per requester mode.', + link: 'https://www.alibabacloud.com/help/doc-detail/91383.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketRequestPayment', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + var getBucketRequestPayment = helpers.addSource(cache, source, + ['oss', 'getBucketRequestPayment', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketRequestPayment || getBucketRequestPayment.err || !getBucketRequestPayment.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketRequestPayment)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketRequestPayment.data.payer && + getBucketRequestPayment.data.payer.toLowerCase() === 'requester') { + helpers.addResult(results, 0, + 'Bucket has pay-by-requester feature enabled', + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'Bucket does not have pay-by-requester feature enabled', + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/bucketPayByRequester.spec.js b/plugins/alibaba/oss/bucketPayByRequester.spec.js new file mode 100644 index 000000000..d38ac6fb4 --- /dev/null +++ b/plugins/alibaba/oss/bucketPayByRequester.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var bucketRequestPayment = require('./bucketPayByRequester.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketRequestPayment = [ + { + "payer": "requester" + }, + { + "payer": "bucketowner" + }, + {} +]; + +const createCache = (listBuckets, getBucketRequestPayment, listBucketsErr, getBucketRequestPaymentErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketRequestPayment: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketRequestPayment, + err: getBucketRequestPaymentErr + } + } + } + }, + }; +}; + +describe('bucketRequestPayment', function () { + describe('run', function () { + it('should FAIL if bucket does not have pay per requester enabled', function (done) { + const cache = createCache(listBuckets, getBucketRequestPayment[1]); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have pay-by-requester feature enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should FAIL if payer property is not returned', function (done) { + const cache = createCache(listBuckets, getBucketRequestPayment[2]); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have pay-by-requester feature enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has pay per requester enabled', function (done) { + const cache = createCache(listBuckets, getBucketRequestPayment[0]); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has pay-by-requester feature enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + bucketRequestPayment.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketIpRestriction.js b/plugins/alibaba/oss/ossBucketIpRestriction.js new file mode 100644 index 000000000..6154558b8 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketIpRestriction.js @@ -0,0 +1,92 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'OSS Bucket IP Restriction Configured', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS buckets have policy configured to allow only specific IP addresses.', + more_info: 'OSS buckets should limit access to selected networks. Restricting default network access provides a new layer of security.', + recommended_action: 'Add or modify bucket policy to create IP-based conditions', + link: 'https://www.alibabacloud.com/help/doc-detail/85111.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketPolicy', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + listBuckets.data.forEach( bucket => { + if (!bucket.name) return; + + var getBucketPolicy = helpers.addSource(cache, source, + ['oss', 'getBucketPolicy', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return; + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (getBucketPolicy && getBucketPolicy.data && getBucketPolicy.data.status == 404) { + helpers.addResult(results, 2, + 'No OSS bucket policy found', bucketLocation, resource); + return; + } + + if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket policy: ${helpers.addError(getBucketPolicy)}`, bucketLocation, resource); + return; + } + + let statements = helpers.normalizePolicyDocument(getBucketPolicy.data); + + let ipRestrictionInPlace = false; + for (let statement of statements) { + if (statement.Principal && statement.Action && statement.Condition) { + if (statement.Condition.IpAddress) { + let conditionValue = statement.Condition.IpAddress[Object.keys(statement.Condition.IpAddress).find(key => key.toLowerCase() == 'acs:sourceip')]; + if (conditionValue.length) { + ipRestrictionInPlace = true; + break; + } + } else if (statement.Condition.NotIpAddress) { + let conditionValue = statement.Condition.NotIpAddress[Object.keys(statement.Condition.NotIpAddress).find(key => key.toLowerCase() == 'acs:sourceip')]; + if (conditionValue.length) { + ipRestrictionInPlace = true; + break; + } + } + } + } + + if (ipRestrictionInPlace) { + helpers.addResult(results, 0, + 'OSS bucket has IP restrictions configured', region, resource); + } else { + helpers.addResult(results, 2, + 'OSS bucket does not have IP restrictions configured', region, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/oss/ossBucketIpRestriction.spec.js b/plugins/alibaba/oss/ossBucketIpRestriction.spec.js new file mode 100644 index 000000000..d68d729b5 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketIpRestriction.spec.js @@ -0,0 +1,180 @@ +var expect = require('chai').expect; +var ossBucketIpRestriction = require('./ossBucketIpRestriction.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketPolicy = [ + { + "Version": "1", + "Statement": [ + { + "Effect": "Deny", + "Action": [ + "oss:RestoreObject", + "oss:ListObjects", + "oss:AbortMultipartUpload", + "oss:PutObjectAcl", + "oss:GetObjectAcl", + "oss:ListParts", + "oss:DeleteObject", + "oss:PutObject", + "oss:GetObject", + "oss:GetVodPlaylist", + "oss:PostVodPlaylist", + "oss:PublishRtmpStream", + "oss:ListObjectVersions", + "oss:GetObjectVersion", + "oss:GetObjectVersionAcl", + "oss:RestoreObjectVersion" + ], + "Principal": [ + "*" + ], + "Resource": [ + "acs:oss:*:0000111122223333:akhtar-made/*" + ], + "Condition": { + "IpAddress": { + "acs:SourceIp": [ "103.127.36.50" ] + } + } + } + ] + }, + { + "Version": "1", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "oss:RestoreObject", + "oss:ListObjects", + "oss:AbortMultipartUpload", + "oss:PutObjectAcl", + "oss:GetObjectAcl", + "oss:ListParts", + "oss:DeleteObject", + "oss:PutObject", + "oss:GetObject", + "oss:GetVodPlaylist", + "oss:PostVodPlaylist", + "oss:PublishRtmpStream", + "oss:ListObjectVersions", + "oss:GetObjectVersion", + "oss:GetObjectVersionAcl", + "oss:RestoreObjectVersion" + ], + "Principal": [ + "*" + ], + "Resource": [ + "acs:oss:*:0000111122223333:akhtar-made/*" + ] + } + ] + }, + { + "policy": null, + "status": 404 + } +]; + +const createCache = (listBuckets, getBucketPolicy, listBucketsErr, getBucketPolicyErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketPolicy: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketPolicy, + err: getBucketPolicyErr + } + } + } + }, + }; +}; + +describe('ossBucketIpRestriction', function () { + describe('run', function () { + it('should PASS if OSS bucket has IP restrictions configured', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[0]); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OSS bucket has IP restrictions configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if OSS bucket does not have IP restrictions configured', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[1]); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OSS bucket does not have IP restrictions configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if no OSS bucket policy found', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[2]); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No OSS bucket policy found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket policy', function (done) { + const cache = createCache([listBuckets[0]], {}, null, { err: 'Unable to query OSS bucket policy' }); + ossBucketIpRestriction.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketLifecycle.js b/plugins/alibaba/oss/ossBucketLifecycle.js new file mode 100644 index 000000000..ca5c99f69 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketLifecycle.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket Lifecycle Configuration', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that OSS buckets have lifecycle configuration enabled to automatically transition bucket objects.', + more_info: 'Enabling lifecycle policies for OSS buckets enables automatic transition of data from one storage class to another.', + recommended_action: 'Modify OSS buckets to enable lifecycle policies.', + link: 'https://www.alibabacloud.com/help/doc-detail/31904.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketLifecycle', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.forEach(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketLifecycle = helpers.addSource(cache, source, + ['oss', 'getBucketLifecycle', region, bucket.name]); + + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (getBucketLifecycle && getBucketLifecycle.err && getBucketLifecycle.err.code && getBucketLifecycle.err.code == 'NoSuchLifecycle') { + helpers.addResult(results, 2, + 'No lifecycle policy exists', + bucketLocation, resource); + return cb(); + } + + if (!getBucketLifecycle || !getBucketLifecycle.data || getBucketLifecycle.err) { + helpers.addResult(results, 3, + `Unable to get OSS bucket lifecycle policy info: ${helpers.addError(getBucketLifecycle)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketLifecycle.data.rules && getBucketLifecycle.data.rules.length){ + let bucketPolicyEnabled = getBucketLifecycle.data.rules.find(rule => rule.status && rule.status.toLowerCase() == 'enabled'); + + if (bucketPolicyEnabled){ + helpers.addResult(results, 0, + 'OSS bucket has lifecycle policy enabled', bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'OSS bucket does not have lifecycle policy enabled', bucketLocation, resource); + } + } else { + helpers.addResult(results, 2, + 'No lifecycle policy exists', + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/ossBucketLifecycle.spec.js b/plugins/alibaba/oss/ossBucketLifecycle.spec.js new file mode 100644 index 000000000..fe3c4af15 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketLifecycle.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +var bucketLifecycle = require('./ossBucketLifecycle.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketLifecycle = [ + { + rules: [ + { + Prefix: '', + status: 'Enabled' + }, + ] + }, + { + rules: [ + { + status: 'disabled', + }, + ] + }, + { + rules: [ + { + Prefix: 'test', + status: 'Enabled', + }, + { + Prefix: 'image', + status: 'Enabled' + } + ] + }, +]; + +const getBucketLifecycleErr = { + "name": "NoSuchLifecycleError", + "status": 404, + "code": "NoSuchLifecycle", + "hostId": "akhtar-made-2.oss-us-west-1.aliyuncs.com", + "params": { + "method": "GET", + "bucket": "akhtar-made-2", + "subres": "lifecycle", + "successStatuses": [ + 200 + ], + "xmlResponse": true + } +} + +const createCache = (listBuckets, getBucketLifecycle, listBucketsErr, getBucketLifecycleErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketLifecycle: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketLifecycle, + err: getBucketLifecycleErr + } + } + } + }, + }; +}; + +describe('bucketLifecycle', function () { + describe('run', function () { + it('should FAIL if bucket does not have lifecycle policies', function (done) { + const cache = createCache(listBuckets, getBucketLifecycle[2], null, getBucketLifecycleErr); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No lifecycle policy exists'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has lifecycle policies enabled', function (done) { + const cache = createCache(listBuckets, getBucketLifecycle[0]); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OSS bucket has lifecycle policy enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has lifecycle policies disabled', function (done) { + const cache = createCache(listBuckets, getBucketLifecycle[1]); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OSS bucket does not have lifecycle policy enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query OSS bucket lifecycle policy info', function (done) { + const cache = createCache(listBuckets, {}, null, 'Unable to query OSS bucket lifecycle policy info'); + bucketLifecycle.run(cache, { china: true }, (err, results) => { + expect(results[0].message).to.include('Unable to query OSS bucket lifecycle policy info'); + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketPrivate.js b/plugins/alibaba/oss/ossBucketPrivate.js new file mode 100644 index 000000000..dbe7b403e --- /dev/null +++ b/plugins/alibaba/oss/ossBucketPrivate.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'OSS Bucket Private', + category: 'OSS', + domain: 'Storage', + severity: 'Critical', + description: 'Ensure that OSS bucket is not publicly accessible.', + more_info: 'When you allow public-access on an OSS bucket, all Internet users can access the objects in the bucket ' + + 'and write data to the bucket. This may cause unexpected access to the data in your bucket, and cause an increase in your fees. ' + + 'If a user uploads prohibited data or information, it may affect your legitimate interests and rights. ', + recommended_action: 'Modify bucket ACL to restrict access to be private.', + link: 'https://www.alibabacloud.com/help/doc-detail/31843.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketInfo.data.AccessControlList && + getBucketInfo.data.AccessControlList.Grant && + getBucketInfo.data.AccessControlList.Grant == 'private') { + helpers.addResult(results, 0, + `Bucket ACL allows ${getBucketInfo.data.AccessControlList.Grant} access`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `Bucket ACL allows ${getBucketInfo.data.AccessControlList.Grant} access`, + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/ossBucketPrivate.spec.js b/plugins/alibaba/oss/ossBucketPrivate.spec.js new file mode 100644 index 000000000..4cdcf8e9a --- /dev/null +++ b/plugins/alibaba/oss/ossBucketPrivate.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +var ossBucketPrivate = require('./ossBucketPrivate.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "private" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + }; +}; + +describe('ossBucketPrivate', function () { + describe('run', function () { + it('should FAIL if bucket ACL allows public-read-write access', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + ossBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket ACL allows public-read-write access'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket ACL allows private access', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + ossBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket ACL allows private access'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + ossBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + ossBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + ossBucketPrivate.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketSecureTransport.js b/plugins/alibaba/oss/ossBucketSecureTransport.js new file mode 100644 index 000000000..23b925b05 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketSecureTransport.js @@ -0,0 +1,88 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'OSS Bucket Secure Transport Enabled', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that Alibaba OSS buckets have secure transport enabled.', + more_info: 'Configuring secure transfer enhances the security of OSS bucket by allowing requests to the storage account by only a secure connection.', + recommended_action: 'Modify OSS bucket policy to configure secure transport', + link: 'https://www.alibabacloud.com/help/doc-detail/85111.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketPolicy', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + listBuckets.data.forEach( bucket => { + if (!bucket.name) return; + + var getBucketPolicy = helpers.addSource(cache, source, + ['oss', 'getBucketPolicy', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return; + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (getBucketPolicy && getBucketPolicy.data && getBucketPolicy.data.status == 404) { + helpers.addResult(results, 2, + 'No OSS bucket policy found', bucketLocation, resource); + return; + } + + if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket policy: ${helpers.addError(getBucketPolicy)}`, bucketLocation, resource); + return; + } + + let statements = helpers.normalizePolicyDocument(getBucketPolicy.data); + + let secureTransportEnabled = false; + for (let statement of statements) { + if (statement.Principal && statement.Principal.includes('*') && + statement.Action && statement.Action.length && + statement.Condition && statement.Condition.Bool) { + let conditionValue = statement.Condition.Bool[Object.keys(statement.Condition.Bool).find(key => key.toLowerCase() == 'acs:securetransport')]; + if (statement.Effect && statement.Effect.toUpperCase() == 'DENY' && + conditionValue.find(boolValue => boolValue.toLowerCase() == 'false')) { + secureTransportEnabled = true; + break; + } else if (conditionValue.find(boolValue => boolValue.toLowerCase() == 'true')) { + secureTransportEnabled = true; + break; + } + } + } + + if (secureTransportEnabled) { + helpers.addResult(results, 0, 'OSS bucket has secure transport enabled', region, resource); + } else { + helpers.addResult(results, 2, 'OSS bucket does not have secure transport enabled', region, resource); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/oss/ossBucketSecureTransport.spec.js b/plugins/alibaba/oss/ossBucketSecureTransport.spec.js new file mode 100644 index 000000000..401906b56 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketSecureTransport.spec.js @@ -0,0 +1,180 @@ +var expect = require('chai').expect; +var ossBucketSecureTransport = require('./ossBucketSecureTransport.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketPolicy = [ + { + "Version": "1", + "Statement": [ + { + "Effect": "Deny", + "Action": [ + "oss:RestoreObject", + "oss:ListObjects", + "oss:AbortMultipartUpload", + "oss:PutObjectAcl", + "oss:GetObjectAcl", + "oss:ListParts", + "oss:DeleteObject", + "oss:PutObject", + "oss:GetObject", + "oss:GetVodPlaylist", + "oss:PostVodPlaylist", + "oss:PublishRtmpStream", + "oss:ListObjectVersions", + "oss:GetObjectVersion", + "oss:GetObjectVersionAcl", + "oss:RestoreObjectVersion" + ], + "Principal": [ + "*" + ], + "Resource": [ + "acs:oss:*:0000111122223333:akhtar-made/*" + ], + "Condition": { + "Bool": { + "acs:SecureTransport": [ "false" ] + } + } + } + ] + }, + { + "Version": "1", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "oss:RestoreObject", + "oss:ListObjects", + "oss:AbortMultipartUpload", + "oss:PutObjectAcl", + "oss:GetObjectAcl", + "oss:ListParts", + "oss:DeleteObject", + "oss:PutObject", + "oss:GetObject", + "oss:GetVodPlaylist", + "oss:PostVodPlaylist", + "oss:PublishRtmpStream", + "oss:ListObjectVersions", + "oss:GetObjectVersion", + "oss:GetObjectVersionAcl", + "oss:RestoreObjectVersion" + ], + "Principal": [ + "*" + ], + "Resource": [ + "acs:oss:*:0000111122223333:akhtar-made/*" + ] + } + ] + }, + { + "policy": null, + "status": 404 + } +]; + +const createCache = (listBuckets, getBucketPolicy, listBucketsErr, getBucketPolicyErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketPolicy: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketPolicy, + err: getBucketPolicyErr + } + } + } + }, + }; +}; + +describe('ossBucketSecureTransport', function () { + describe('run', function () { + it('should PASS if OSS bucket has secure transport enabled', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[0]); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OSS bucket has secure transport enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if OSS bucket does not have secure transport enabled', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[1]); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OSS bucket does not have secure transport enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if no OSS bucket policy found', function (done) { + const cache = createCache(listBuckets, getBucketPolicy[2]); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No OSS bucket policy found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket policy', function (done) { + const cache = createCache([listBuckets[0]], {}, null, { err: 'Unable to query OSS bucket policy' }); + ossBucketSecureTransport.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketTransferAcceleration.js b/plugins/alibaba/oss/ossBucketTransferAcceleration.js new file mode 100644 index 000000000..68b456d24 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketTransferAcceleration.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Bucket Transfer Acceleration Enabled', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS buckets has transfer acceleration enabled.', + more_info: 'Enabling Transfer Acceleration for OSS buckets provides an optimized end-to-end acceleration solution to access OSS over the Internet.', + recommended_action: 'Modify OSS buckets to enable transfer acceleration.', + link: 'https://www.alibabacloud.com/help/doc-detail/131313.htm', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketInfo.data.TransferAcceleration && + getBucketInfo.data.TransferAcceleration.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, + 'Bucket has transfer acceleration enabled', + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'Bucket does not have transfer acceleration enabled', + bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/ossBucketTransferAcceleration.spec.js b/plugins/alibaba/oss/ossBucketTransferAcceleration.spec.js new file mode 100644 index 000000000..3d08a9621 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketTransferAcceleration.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +var bucketTransferAcceleration = require('./ossBucketTransferAcceleration.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "private" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Enabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "log-bucket", + "LogPrefix": "" + } + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "AccessControlList": { + "Grant": "public-read-write" + }, + "ServerSideEncryptionRule": { + "SSEAlgorithm": "None" + }, + "BucketPolicy": { + "LogBucket": "log-bucket", + "LogPrefix": "" + } + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + }; +}; + +describe('bucketTransferAcceleration', function () { + describe('run', function () { + it('should FAIL if bucket does not have transfer acceleration enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have transfer acceleration enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should FAIL if bucket info does not have transfer acceleration property', function (done) { + const cache = createCache(listBuckets, getBucketInfo[2]); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have transfer acceleration enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket has transfer acceleration enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has transfer acceleration enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + bucketTransferAcceleration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/oss/ossBucketVersioning.js b/plugins/alibaba/oss/ossBucketVersioning.js new file mode 100644 index 000000000..02e8bbf44 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketVersioning.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'OSS Bucket Versioning', + category: 'OSS', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that OSS bucket has versioning enabled.', + more_info: 'OSS allows you to configure versioning to protect data in buckets. When versioning is enabled for a bucket, ' + + 'data that is overwritten or deleted in the bucket is saved as a previous version.', + recommended_action: 'Modify bucket settings to enabled versioning.', + link: 'https://www.alibabacloud.com/help/doc-detail/109695.html', + apis: ['OSS:listBuckets', 'OSS:getBucketInfo', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listBuckets = helpers.addSource(cache, source, ['oss', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, `Unable to query for OSS buckets: ${helpers.addError(listBuckets)}`, region); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No OSS buckets found', region); + return callback(null, results, source); + } + + async.each(listBuckets.data, (bucket, cb) => { + if (!bucket.name) return cb(); + + var getBucketInfo = helpers.addSource(cache, source, + ['oss', 'getBucketInfo', region, bucket.name]); + + var bucketLocation = bucket.region || region; + bucketLocation = bucketLocation.replace('oss-', ''); + + if (bucketLocation !== region && !regions.all.includes(bucketLocation)) return cb(); + + var resource = helpers.createArn('oss', accountId, 'bucket', bucket.name, bucketLocation); + + if (!getBucketInfo || getBucketInfo.err || !getBucketInfo.data) { + helpers.addResult(results, 3, + `Unable to query OSS bucket info: ${helpers.addError(getBucketInfo)}`, bucketLocation, resource); + return cb(); + } + + if (getBucketInfo.data.Versioning && getBucketInfo.data.Versioning.toUpperCase() === 'ENABLED') { + helpers.addResult(results, 0, 'Bucket has versioning enabled', bucketLocation, resource); + } else { + helpers.addResult(results, 2, 'Bucket does not have versioning enabled', bucketLocation, resource); + } + + cb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/oss/ossBucketVersioning.spec.js b/plugins/alibaba/oss/ossBucketVersioning.spec.js new file mode 100644 index 000000000..4abd531c0 --- /dev/null +++ b/plugins/alibaba/oss/ossBucketVersioning.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var ossBucketVersioning = require('./ossBucketVersioning.js'); + +const listBuckets = [ + { + "name": 'test-bucket', + "region": 'oss-cn-hangzhou', + "creationDate": '2021-05-08T10:35:06.000Z', + "storageClass": 'Standard', + "StorageClass": 'Standard', + } +]; + +const getBucketInfo = [ + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + "Versioning": "Enabled" + }, + { + "Comment": "", + "CreationDate": "2021-05-08T18:42:34.000Z", + "CrossRegionReplication": "Disabled", + "DataRedundancyType": "LRS", + "ExtranetEndpoint": "oss-cn-hangzhou.aliyuncs.com", + "IntranetEndpoint": "oss-cn-hangzhou-internal.aliyuncs.com", + "Location": "oss-cn-hangzhou", + "Name": "test-bucket", + "StorageClass": "Standard", + "TransferAcceleration": "Disabled", + "Owner": { + "DisplayName": "0000111122223333", + "ID": "0000111122223333" + }, + } +]; + +const createCache = (listBuckets, getBucketInfo, listBucketsErr, getBucketInfoErr) => { + let bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].name : null; + return { + oss: { + listBuckets: { + 'cn-hangzhou': { + data: listBuckets, + err: listBucketsErr + }, + }, + getBucketInfo: { + 'cn-hangzhou': { + [bucketName]: { + data: getBucketInfo, + err: getBucketInfoErr + } + } + } + }, + }; +}; + +describe('ossBucketVersioning', function () { + describe('run', function () { + it('should FAIL if bucket versioning is not enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[1]); + ossBucketVersioning.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have versioning enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if bucket versioning is enabled', function (done) { + const cache = createCache(listBuckets, getBucketInfo[0]); + ossBucketVersioning.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has versioning enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no OSS buckets found', function (done) { + const cache = createCache([]); + ossBucketVersioning.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OSS buckets found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for OSS buckets', function (done) { + const cache = createCache([], null, { err: 'Unable to query for OSS buckets' }); + ossBucketVersioning.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OSS buckets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query OSS bucket info', function (done) { + const cache = createCache(listBuckets, {}, null, { err: 'Unable to query OSS bucket info' }); + ossBucketVersioning.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OSS bucket info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/accessKeysRotation.js b/plugins/alibaba/ram/accessKeysRotation.js new file mode 100644 index 000000000..9eff90502 --- /dev/null +++ b/plugins/alibaba/ram/accessKeysRotation.js @@ -0,0 +1,90 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Access Keys Rotation', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM user access keys are rotated after regular interval of time.', + more_info: 'Access keys should be rotated to avoid having them accidentally exposed.', + link: 'https://www.alibabacloud.com/help/doc-detail/152682.htm', + recommended_action: 'Rotate the access keys every desired number of days', + apis: ['RAM:ListUsers', 'RAM:ListAccessKeys', 'STS:GetCallerIdentity'], + settings: { + ram_access_keys_rotation_interval: { + name: 'RAM User Access Keys Rotation Interval', + description: 'Return a failing result when access keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var accessKeyRotationInterval = parseInt(settings.ram_access_keys_rotation_interval || this.settings.ram_access_keys_rotation_interval.default); + var region = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + var listUsers = helpers.addSource(cache, source, + ['ram', 'ListUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query RAM users' + helpers.addError(listUsers), region); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No RAM users found', region); + return callback(null, results, source); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + var getAccessKey = helpers.addSource(cache, source, + ['ram', 'ListAccessKeys', region, user.UserName]); + + if (getAccessKey.err || !getAccessKey.data) { + helpers.addResult(results, 3, + 'Unable to query user access keys' + helpers.addError(getAccessKey), region); + continue; + } + + let resource = helpers.createArn('ram', accountId, 'user', user.UserName); + if (getAccessKey.data.AccessKeys && getAccessKey.data.AccessKeys.AccessKey && getAccessKey.data.AccessKeys.AccessKey.length) { + let activeKeyFound = false; + for (var accessKey of getAccessKey.data.AccessKeys.AccessKey) { + if (accessKey.Status && accessKey.Status == 'Active') { + activeKeyFound = true; + resource = resource + ':' + accessKey.AccessKeyId; + let createDate = accessKey.CreateDate; + var currentDate = new Date(); + var createDateFormat = new Date(createDate); + + var diffInDays = helpers.daysBetween(currentDate, createDateFormat); + if (diffInDays <= accessKeyRotationInterval) { + helpers.addResult(results, 0, + `RAM user access key was last rotated ${diffInDays} days ago which is equal to or less than ${accessKeyRotationInterval}`, region, resource); + } else { + helpers.addResult(results, 2, + `RAM user access key was last rotated ${diffInDays} days ago which is greater than ${accessKeyRotationInterval}`, region, resource); + } + } + } + if (!activeKeyFound) { + helpers.addResult(results, 0, + 'RAM user does not have any active access keys', region, resource); + } + } else { + helpers.addResult(results, 0, + 'RAM user does not have any access keys', region, resource); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/alibaba/ram/accessKeysRotation.spec.js b/plugins/alibaba/ram/accessKeysRotation.spec.js new file mode 100644 index 000000000..019e383a8 --- /dev/null +++ b/plugins/alibaba/ram/accessKeysRotation.spec.js @@ -0,0 +1,141 @@ +var expect = require('chai').expect; +var helpers = require('../../../helpers/alibaba'); +var accessKeysRotation = require('./accessKeysRotation') + +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 2); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 7); + +const listUsers = [ + { + "UserName": "khulnasoft", + "UserId": "254529020129829608", + }, + { + "UserName": "cloudexploit", + "UserId": "283806919721151694", + } +]; + +const getUserLoginProfile = [ + { + AccessKeys: { + AccessKey: [ + { + Status: "Active", + AccessKeyId: "LTABCDEHJJH", + CreateDate: failDate, + }, + ], + }, + }, + { + AccessKeys: { + AccessKey: [ + { + Status: "Active", + AccessKeyId: "LTABCDEHJJ", + CreateDate: passDate, + }, + ], + }, + }, + { + AccessKeys: { + AccessKey: [], + }, + } +]; + +const createCache = (users, accessKeys, accessKeysError, error) => { + let userName = (users && users.length) ? users[0].UserName : null; + return { + ram: { + ListUsers: { + 'cn-hangzhou': { + data: users, + err: error + } + }, + ListAccessKeys: { + 'cn-hangzhou': { + [userName]: { + data: accessKeys, + err: accessKeysError + } + } + }, + } + } +} + +describe('accessKeysRotation', function () { + describe('run', function () { + it('should FAIL if RAM user access keys are not rotated every 90 days or less', function (done) { + const cache = createCache([listUsers[0]], getUserLoginProfile[0], null, null); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is greater than'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM user access keys are not rotated every 90 days or less', function (done) { + const cache = createCache([listUsers[0]], getUserLoginProfile[1], null, null); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is equal to or less than'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM user does not have any access keys', function (done) { + const cache = createCache([listUsers[1]], getUserLoginProfile[2], null, null); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM user does not have any access keys'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No RAM users found', function (done) { + const cache = createCache([]); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RAM users found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query user access keys', function (done) { + const cache = createCache([listUsers[0]], null, [], null); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query user access keys'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM users', function (done) { + const cache = createCache(null, null, null, null); + accessKeysRotation.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM users'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/ram/inactiveUserDisabled.js b/plugins/alibaba/ram/inactiveUserDisabled.js new file mode 100644 index 000000000..257a95328 --- /dev/null +++ b/plugins/alibaba/ram/inactiveUserDisabled.js @@ -0,0 +1,74 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Inactive User Disabled', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM users inactive for 90 or more days are disabled.', + more_info: 'RAM User should not have the console access enabled on being inactive for 90 or more days.', + link: 'https://alibaba-cloud.medium.com/11-security-recommendations-for-production-instances-on-alibaba-cloud-960e3e8442d4', + recommended_action: 'Disable RAM user if its inactive for 90 or more days', + apis: ['RAM:ListUsers', 'RAM:GetUser', 'RAM:GetLoginProfile', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + var listUsers = helpers.addSource(cache, source, + ['ram', 'ListUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query RAM users' + helpers.addError(listUsers), region); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No RAM users found', region); + return callback(null, results, source); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + var getUser = helpers.addSource(cache, source, + ['ram', 'GetUser', region, user.UserName]); + + if (getUser.err || !getUser.data) { + helpers.addResult(results, 3, + 'Unable to query RAM user' + helpers.addError(getUser), region); + continue; + } + + let lastLoginDate = (getUser.data.LastLoginDate && getUser.data.LastLoginDate.length) ? + getUser.data.LastLoginDate : getUser.data.CreateDate ; + var currentDate = new Date(); + var loginDate = new Date(lastLoginDate); + var resource = helpers.createArn('ram', accountId, 'user', user.UserName); + + var diffInDays = helpers.daysBetween(currentDate, loginDate); + if (diffInDays >= 90) { + var getUserProfile = helpers.addSource(cache, source, + ['ram', 'GetLoginProfile', region, user.UserName]); + + if (getUserProfile && getUserProfile.err && getUserProfile.err.Code && getUserProfile.err.Code == 'EntityNotExist.User.LoginProfile') { + helpers.addResult(results, 0, `RAM user inactive for ${diffInDays} days is not enabled`, region, resource); + } else if (getUserProfile.err || !getUserProfile.data) { + helpers.addResult(results, 3, `Unable to query user login profile: ${helpers.addError(getUserProfile)}`, region, resource); + } else { + helpers.addResult(results, 2, `RAM user inactive for ${diffInDays} days is enabled`, region, resource); + } + } else { + helpers.addResult(results, 0, + `RAM user last activity was ${diffInDays} days ago`, region, resource); + } + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/inactiveUserDisabled.spec.js b/plugins/alibaba/ram/inactiveUserDisabled.spec.js new file mode 100644 index 000000000..05593ec73 --- /dev/null +++ b/plugins/alibaba/ram/inactiveUserDisabled.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var helpers = require('../../../helpers/alibaba'); +var inactiveUserDisabled = require('./inactiveUserDisabled') + +const currentDate = new Date(); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 4); +const listUsers = [ + { + "UserName": "khulnasoft", + "UserId": "254529020129829608", + }, + { + "UserName": "cloudexploit", + "UserId": "283806919721151694", + } +]; + +const getUserData = [ + { + "UserName": "khulnasoft", + "UserId": "214008820731498041", + "LastLoginDate": currentDate, + "CreateDate": "2021-05-11T11:11:38Z", + }, + { + "UserName": "cloudexploit", + "UserId": "214008820731498041", + "LastLoginDate": failDate, + "CreateDate": "2021-05-11T11:11:38Z", + } +]; + +const getUserLoginProfile = [ + { + "LoginProfile": { + "UserName": "khulnasoft", + } + }, + { + "RequestId": "1AE50527-F715-4F01-AE7D-64463D920B3D", + } +]; + +const createCache = (users, userData, userProfile, userProfileError, error) => { + let userName = (users && users.length) ? users[0].UserName : null; + return { + ram: { + ListUsers: { + 'cn-hangzhou': { + data: users, + err: error + } + }, + GetUser: { + 'cn-hangzhou': { + [userName]: { + data: userData, + err: error + } + } + }, + GetLoginProfile: { + 'cn-hangzhou': { + [userName]: { + data: userProfile, + err: userProfileError + } + } + }, + } + } +} + +describe('inactiveUserDisabled', function () { + describe('run', function () { + it('should FAIL if RAM user is enabled on being inactive for 90 or more days', function (done) { + const loginDate = new Date(getUserData[0].LastLoginDate); + const diffInDays = helpers.daysBetween(currentDate, loginDate); + const cache = createCache([listUsers[0]], getUserData[0], getUserLoginProfile[0], null, null); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include(`RAM user inactive for`); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should PASS if RAM user is disabled on being inactive for 90 or more days', function (done) { + const loginDate = new Date(getUserData[0].LastLoginDate); + const diffInDays = helpers.daysBetween(currentDate, loginDate); + const cache = createCache([listUsers[0]], getUserData[0], getUserLoginProfile[1], {Code : 'EntityNotExist.User.LoginProfile'}, null); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include(`RAM user inactive for`); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should PASS if RAM user last activity was before 90 days', function (done) { + const loginDate = new Date(getUserData[1].LastLoginDate); + const diffInDays = helpers.daysBetween(currentDate, loginDate); + const cache = createCache([listUsers[1]], getUserData[1], getUserLoginProfile[0], null, null); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + //expect(results[0].status).to.equal(0); + //expect(results[0].message).to.include(`RAM user last activity was ${diffInDays} days ago`); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should PASS if No RAM users found', function (done) { + const cache = createCache([]); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RAM users found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should UNKNOWN if Unable to query login profile', function (done) { + const cache = createCache([listUsers[0]], getUserData[0], getUserLoginProfile[0], {}, null); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query user login profile'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should UNKNOWN if Unable to query RAM users', function (done) { + const cache = createCache(null, null, null, null); + inactiveUserDisabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM users'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/ram/passwordBlockLogon.js b/plugins/alibaba/ram/passwordBlockLogon.js new file mode 100644 index 000000000..efddb1feb --- /dev/null +++ b/plugins/alibaba/ram/passwordBlockLogon.js @@ -0,0 +1,40 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Block Logon', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure that RAM password security settings require logon to be blocked after maximum of 5 incorrect login attempts.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require logon to be blocked after maximum of 5 or less incorrect login attempts.', + apis: ['RAM:GetPasswordPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.MaxLoginAttemps > 0 && getPasswordPolicy.data.MaxLoginAttemps <= 5) { + helpers.addResult(results, 0, + 'RAM password security policy requires logon to be blocked after 5 attempts', region); + } else { + helpers.addResult(results, 2, + 'RAM password security policy does not require logon to be blocked after 5 attempts', region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordBlockLogon.spec.js b/plugins/alibaba/ram/passwordBlockLogon.spec.js new file mode 100644 index 000000000..d7e815a62 --- /dev/null +++ b/plugins/alibaba/ram/passwordBlockLogon.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordBlockLogon = require('./passwordBlockLogon') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:5, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordBlockLogon', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require logon to be blocked after 5 attempts', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordBlockLogon.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy does not require logon to be blocked after 5 attempts'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires logon to be blocked after 5 attempts', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordBlockLogon.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires logon to be blocked after 5 attempts'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordBlockLogon.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordExpiry.js b/plugins/alibaba/ram/passwordExpiry.js new file mode 100644 index 000000000..90d76bb7a --- /dev/null +++ b/plugins/alibaba/ram/passwordExpiry.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Expiry', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure that RAM password security settings require password to be expired after set number of days.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require password to be expired after set number of days.', + apis: ['RAM:GetPasswordPolicy'], + settings: { + ram_password_expiry: { + name: 'RAM User Password Expiry', + description: 'Maximum number of days after which RAM user password should be expired', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + var ramPasswordExpiry = parseInt(settings.ram_password_expiry || this.settings.ram_password_expiry.default); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.MaxPasswordAge <= ramPasswordExpiry) { + helpers.addResult(results, 0, + `RAM password security policy requires password to be expired after ${getPasswordPolicy.data.MaxPasswordAge} days which is equal to or less than desired limit of ${ramPasswordExpiry}`, region); + } else { + helpers.addResult(results, 2, + `RAM password security policy requires password to be expired after ${getPasswordPolicy.data.MaxPasswordAge} days which is greater than desired limit of ${ramPasswordExpiry}`, region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordExpiry.spec.js b/plugins/alibaba/ram/passwordExpiry.spec.js new file mode 100644 index 000000000..11d9197c3 --- /dev/null +++ b/plugins/alibaba/ram/passwordExpiry.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordExpiry = require('./passwordExpiry') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:91, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:10, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordExpiry', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require password to be expired after 90 days', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordExpiry.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy requires password to be expired after 91 days which is greater than desired limit of 90'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires password to be expired after set days', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordExpiry.run(cache, { ram_password_expiry: '100' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires password to be expired after 10 days which is equal to or less than desired limit of 100'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordExpiry.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordMinLength.js b/plugins/alibaba/ram/passwordMinLength.js new file mode 100644 index 000000000..5a3f32d63 --- /dev/null +++ b/plugins/alibaba/ram/passwordMinLength.js @@ -0,0 +1,54 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Minimum Length', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings require minimum length of 14 or above.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require the minimum length of 14 or above.', + apis: ['RAM:GetPasswordPolicy'], + compliance: { + pci: 'PCI requires a strong password policy. Setting Identity password ' + + 'requirements enforces this policy.' + }, + settings: { + ram_password_min_length: { + name: 'RAM User Password Minimum Length', + description: 'Minimum password length required for RAM user login passwords. Should be 14 or above', + regex: '^1[4-9]|[2-9]{2,3}$', + default: '14' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var minPasswordLength = parseInt(settings.ram_password_min_length || this.settings.ram_password_min_length.default); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.MinimumPasswordLength >= minPasswordLength) { + helpers.addResult(results, 0, + `RAM password security policy requires minimum length of ${getPasswordPolicy.data.MinimumPasswordLength} which is equal to or greater than desired limit of ${minPasswordLength}`, region); + } else { + helpers.addResult(results, 2, + `RAM password security policy requires minimum length of ${getPasswordPolicy.data.MinimumPasswordLength} which is less than desired limit of ${minPasswordLength}`, region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordMinLength.spec.js b/plugins/alibaba/ram/passwordMinLength.spec.js new file mode 100644 index 000000000..c3738fe0c --- /dev/null +++ b/plugins/alibaba/ram/passwordMinLength.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordMinLength = require('./passwordMinLength') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:15, + RequireLowercaseCharacters:true, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordMinLength', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require minimum length of 14 or greater', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordMinLength.run(cache, { ram_password_min_length: '15' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy requires minimum length of 8 which is less than desired limit of 15'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy require minimum length of 14 or greater', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordMinLength.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires minimum length of 15 which is equal to or greater than desired limit of 14'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordMinLength.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordNoReuse.js b/plugins/alibaba/ram/passwordNoReuse.js new file mode 100644 index 000000000..8f3d48039 --- /dev/null +++ b/plugins/alibaba/ram/passwordNoReuse.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password No Reuse', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings are set to prevent reusing desired number of previous passwords.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to prevent reusing desired number of previous passwords.', + apis: ['RAM:GetPasswordPolicy'], + settings: { + ram_password_no_reuse_limit: { + name: 'RAM User Password No Reuse Limit', + description: 'Maximum number of previous user passwords which should not be reused', + regex: '^[5-9]|[0-9]{2,3}$', + default: '5' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var ramPasswordReuse = parseInt(settings.ram_password_no_reuse_limit || this.settings.ram_password_no_reuse_limit.default); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.PasswordReusePrevention >= ramPasswordReuse) { + helpers.addResult(results, 0, + `RAM password security policy requires to prevent reusing previous ${getPasswordPolicy.data.PasswordReusePrevention} passwords which is equal to or greater than desired limit of ${ramPasswordReuse}`, region); + } else { + helpers.addResult(results, 2, + `RAM password security policy requires to prevent reusing previous ${getPasswordPolicy.data.PasswordReusePrevention} passwords which is less than desired limit of ${ramPasswordReuse}`, region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordNoReuse.spec.js b/plugins/alibaba/ram/passwordNoReuse.spec.js new file mode 100644 index 000000000..29da7a430 --- /dev/null +++ b/plugins/alibaba/ram/passwordNoReuse.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordNoReuse = require('./passwordNoReuse') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:5, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:3, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordNoReuse', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not requires to prevent reusing 5 previous passwords', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordNoReuse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy requires to prevent reusing previous 3 passwords which is less than desired limit of 5'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires to prevent reusing 5 previous passwords', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordNoReuse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires to prevent reusing previous 5 passwords which is equal to or greater than desired limit of 5'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordNoReuse.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresLowercase.js b/plugins/alibaba/ram/passwordRequiresLowercase.js new file mode 100644 index 000000000..90f416818 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresLowercase.js @@ -0,0 +1,43 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Requires Lowercase', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings require at least one lowercase character.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require the use of lowercase characters.', + apis: ['RAM:GetPasswordPolicy'], + compliance: { + pci: 'PCI requires a strong password policy. Setting Identity password ' + + 'requirements enforces this policy.' + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.RequireLowercaseCharacters) { + helpers.addResult(results, 0, + 'RAM password security policy requires lowercase characters', region); + } else { + helpers.addResult(results, 2, + 'RAM password security policy does not require lowercase characters', region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresLowercase.spec.js b/plugins/alibaba/ram/passwordRequiresLowercase.spec.js new file mode 100644 index 000000000..01c8f5fa8 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresLowercase.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordRequiresLowercase = require('./passwordRequiresLowercase') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:true, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordRequiresLowercase', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require lowercase characters', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordRequiresLowercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy does not require lowercase characters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires lowercase characters', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordRequiresLowercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires lowercase characters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordRequiresLowercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresNumbers.js b/plugins/alibaba/ram/passwordRequiresNumbers.js new file mode 100644 index 000000000..2dbbbbdee --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresNumbers.js @@ -0,0 +1,43 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Requires Numbers', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings require at least one number.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require the use of numbers.', + apis: ['RAM:GetPasswordPolicy'], + compliance: { + pci: 'PCI requires a strong password policy. Setting Identity password ' + + 'requirements enforces this policy.' + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.RequireNumbers) { + helpers.addResult(results, 0, + 'RAM password security policy requires numbers', region); + } else { + helpers.addResult(results, 2, + 'RAM password security policy does not require numbers', region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresNumbers.spec.js b/plugins/alibaba/ram/passwordRequiresNumbers.spec.js new file mode 100644 index 000000000..9e44625b5 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresNumbers.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordRequiresNumbers = require('./passwordRequiresNumbers') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:true, + RequireNumbers:true, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordRequiresNumbers', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require numbers', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordRequiresNumbers.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy does not require numbers'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires numbers', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordRequiresNumbers.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires numbers'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordRequiresNumbers.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresSymbols.js b/plugins/alibaba/ram/passwordRequiresSymbols.js new file mode 100644 index 000000000..9887c0d73 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresSymbols.js @@ -0,0 +1,44 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Requires Symbols', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings require at least one symbol.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require the use symbols.', + apis: ['RAM:GetPasswordPolicy'], + compliance: { + pci: 'PCI requires a strong password policy. Setting Identity password ' + + 'requirements enforces this policy.' + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.RequireSymbols) { + helpers.addResult(results, 0, + 'RAM password security policy requires symbols', region); + } else { + helpers.addResult(results, 2, + 'RAM password security policy does not require symbols', region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresSymbols.spec.js b/plugins/alibaba/ram/passwordRequiresSymbols.spec.js new file mode 100644 index 000000000..710bfc7f5 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresSymbols.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordRequiresSymbols = require('./passwordRequiresSymbols') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:true, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:true + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordRequiresSymbols', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require symbols', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordRequiresSymbols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy does not require symbols'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires symbols', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordRequiresSymbols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires symbols'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordRequiresSymbols.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresUppercase.js b/plugins/alibaba/ram/passwordRequiresUppercase.js new file mode 100644 index 000000000..89d3810be --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresUppercase.js @@ -0,0 +1,43 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Password Requires Uppercase', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM password security settings require at least one uppercase character.', + more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', + link: 'https://www.alibabacloud.com/help/doc-detail/116413.htm', + recommended_action: 'Update the password security settings to require the use of uppercase characters.', + apis: ['RAM:GetPasswordPolicy'], + compliance: { + pci: 'PCI requires a strong password policy. Setting Identity password ' + + 'requirements enforces this policy.' + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var getPasswordPolicy = helpers.addSource(cache, source, + ['ram', 'GetPasswordPolicy', region]); + + if (!getPasswordPolicy) return callback(null, results, source); + + if (getPasswordPolicy.err || !getPasswordPolicy.data || !Object.keys(getPasswordPolicy.data).length) { + helpers.addResult(results, 3, + 'Unable to query RAM password policy: ' + helpers.addError(getPasswordPolicy), region); + return callback(null, results, source); + } + + if (getPasswordPolicy.data.RequireUppercaseCharacters) { + helpers.addResult(results, 0, + 'RAM password security policy requires uppercase characters', region); + } else { + helpers.addResult(results, 2, + 'RAM password security policy does not require uppercase characters', region); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/passwordRequiresUppercase.spec.js b/plugins/alibaba/ram/passwordRequiresUppercase.spec.js new file mode 100644 index 000000000..33637e1e0 --- /dev/null +++ b/plugins/alibaba/ram/passwordRequiresUppercase.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var passwordRequiresUppercase = require('./passwordRequiresUppercase') + +const getPasswordPolicy = [ + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:false, + RequireSymbols:false + }, + { + MinimumPasswordLength:8, + RequireLowercaseCharacters:false, + RequireNumbers:false, + MaxLoginAttemps:0, + MaxPasswordAge:0, + PasswordReusePrevention:0, + HardExpiry:false, + RequireUppercaseCharacters:true, + RequireSymbols:false + } +]; + +const createCache = (data, err) => { + return { + ram: { + GetPasswordPolicy: { + 'cn-hangzhou': { + data: data, + err: err + } + } + } + } +} + +describe('passwordRequiresUppercase', function () { + describe('run', function () { + it('should FAIL if RAM password security policy does not require uppercase characters', function (done) { + const cache = createCache(getPasswordPolicy[0]); + passwordRequiresUppercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM password security policy does not require uppercase characters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM password security policy requires uppercase characters', function (done) { + const cache = createCache(getPasswordPolicy[1]); + passwordRequiresUppercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM password security policy requires uppercase characters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RAM password policy', function (done) { + const cache = createCache({}); + passwordRequiresUppercase.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM password policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/ram/ramAdminPolicy.js b/plugins/alibaba/ram/ramAdminPolicy.js new file mode 100644 index 000000000..b0207e9b5 --- /dev/null +++ b/plugins/alibaba/ram/ramAdminPolicy.js @@ -0,0 +1,90 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RAM Administrator Policies', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure that RAM policies which allow administrator access ("*:*") are not attached to RAM users, groups or roles.', + more_info: 'RAM policies represent permissions that can be granted to users, groups, or roles. It is recommended and considered a standard security advice to grant least privilege. ' + + 'Determine what users need to do and then create policies with permissions only fits those tasks, instead of allowing full administrative privileges', + link: 'https://www.alibabacloud.com/help/doc-detail/116815.htm', + recommended_action: 'Ensure that administrator RAM policies are not attached with any RAM resource.', + apis: ['RAM:ListPolicies', 'RAM:GetPolicy', 'STS:GetCallerIdentity'], + settings: { + ram_policies_ignore_name: { + name: 'RAM Policies Ignore Name', + description: 'A comma-separated list indicating policy name which should be ignored without checking', + regex: '^[0-9A-Za-z/._-]{3,512}$', + default: '' + } + }, + + run: function(cache, settings, callback) { + var config = { + ram_policies_ignore_name: settings.ram_policies_ignore_name || this.settings.ram_policies_ignore_name.default + }; + + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listPolicies = helpers.addSource(cache, source, + ['ram', 'ListPolicies', region]); + + if (!listPolicies) return callback(null, results, source); + + if (listPolicies.err || !listPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query RAM policies' + helpers.addError(listPolicies), region); + return callback(null, results, source); + } + + if (!listPolicies.data.length) { + helpers.addResult(results, 0, 'No RAM policies found', region); + return callback(null, results, source); + } + + for (let policy of listPolicies.data) { + if (!policy.PolicyName || !policy.PolicyType || config.ram_policies_ignore_name.includes(policy.PolicyName)) continue; + if (policy.PolicyType && policy.PolicyType.toUpperCase() == 'SYSTEM' && policy.PolicyName.toUpperCase() != 'ADMINISTRATORACCESS') continue; + + let resource = helpers.createArn('ram', accountId, `${policy.PolicyType.toLowerCase()}policy`, policy.PolicyName); + let getPolicy = helpers.addSource(cache, source, + ['ram', 'GetPolicy', region, policy.PolicyName]); + + if (!getPolicy || getPolicy.err || !getPolicy.data || !getPolicy.data.PolicyDocument) { + helpers.addResult(results, 3, + `Unable to get RAM policy: ${getPolicy.err}`, region, resource); + } else { + let statements = helpers.normalizePolicyDocument(getPolicy.data.PolicyDocument); + let attachmentCount = (policy.AttachmentCount) ? policy.AttachmentCount : 0; + let adminPolicy = false; + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() == 'ALLOW' && + statement.Action && statement.Action.includes('*') && + statement.Resource && statement.Resource.includes('*')) { + adminPolicy = true; + break; + } + } + + if (adminPolicy && attachmentCount > 0) { + helpers.addResult(results, 2, + `Policy provides admin (*:*) access and attachment count is ${attachmentCount}`, region, resource); + } else if (adminPolicy) { + helpers.addResult(results, 0, + `Policy provides admin (*:*) access but attachment count is ${attachmentCount}`, region, resource); + } else { + helpers.addResult(results, 0, + 'Policy does not provide admin (*:*) access', region, resource); + } + } + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/ramAdminPolicy.spec.js b/plugins/alibaba/ram/ramAdminPolicy.spec.js new file mode 100644 index 000000000..fb9d2b965 --- /dev/null +++ b/plugins/alibaba/ram/ramAdminPolicy.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var ramAdminPolicy = require('./ramAdminPolicy') + +const listPolicies = [ + { + "UpdateDate": "2021-09-21T09:16:47Z", + "PolicyType": "Custom", + "Description": "", + "AttachmentCount": 0, + "DefaultVersion": "v1", + "PolicyName": "ECSFullAccess", + "CreateDate": "2021-09-21T09:16:47Z" + }, + { + "UpdateDate": "2021-09-22T08:44:56Z", + "PolicyType": "Custom", + "Description": "", + "AttachmentCount": 0, + "DefaultVersion": "v3", + "PolicyName": "adminPolicy", + "CreateDate": "2021-09-22T08:43:10Z" + }, + { + "UpdateDate": "2017-04-27T16:48:07Z", + "PolicyType": "System", + "Description": "管理所有阿里云资源的权限", + "AttachmentCount": 8, + "DefaultVersion": "v1", + "PolicyName": "AdministratorAccess", + "CreateDate": "2015-04-28T16:15:44Z" + }, +]; + +const getPolicy = [ + { + "VersionId": "v1", + "IsDefaultVersion": true, + "PolicyDocument": "{\"Version\":\"1\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"ecs:*\"],\"Resource\":[\"*\"],\"Condition\":{}}]}", + "CreateDate": "2021-09-21T09:16:47Z" + }, + { + + "VersionId": "v1", + "IsDefaultVersion": true, + "PolicyDocument": "\n{\n \"Statement\": [\n {\n \"Action\": \"*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"*\"\n }\n ],\n \"Version\": \"1\"\n}\n ", + "CreateDate": "2015-04-28T16:15:44Z" + }, + { + "VersionId": "v3", + "IsDefaultVersion": true, + "PolicyDocument": "{\n \"Statement\": [\n {\n \"Action\": \"*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"*\"\n }\n ],\n \"Version\": \"1\"\n}", + "CreateDate": "2021-09-22T08:44:56Z" + } +] + +const createCache = (listPolicies, getPolicy, listPoliciesErr, getPolicyErr) => { + let policyName = (listPolicies && listPolicies.length) ? listPolicies[0].PolicyName : null; + return { + ram: { + ListPolicies: { + 'cn-hangzhou': { + data: listPolicies, + err: listPoliciesErr + } + }, + GetPolicy: { + 'cn-hangzhou': { + [policyName]: { + data: getPolicy, + err: getPolicyErr + } + } + } + } + } +} + +describe('ramAdminPolicy', function () { + describe('run', function () { + it('should FAIL if Policy provides admin (*:*) access and attachment count is greater than 0', function (done) { + const cache = createCache([listPolicies[2]], getPolicy[1]); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Policy provides admin (*:*) access and attachment count is'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Policy provides admin (*:*) access but attachment count is 0', function (done) { + const cache = createCache([listPolicies[1]], getPolicy[2]); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Policy provides admin (*:*) access but attachment count is 0'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Policy does not provide admin (*:*) access', function (done) { + const cache = createCache([listPolicies[0]], getPolicy[0]); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Policy does not provide admin (*:*) access'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No RAM policies found', function (done) { + const cache = createCache([]); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RAM policies found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query RAM policies', function (done) { + const cache = createCache([], null, { err: 'Unable to query RAM policies' }); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM policies'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to get RAM policy', function (done) { + const cache = createCache([listPolicies[1]], {}, null, {err: 'Unable to query RAM policy'}); + ramAdminPolicy.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get RAM policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/ram/ramPolicyAttachments.js b/plugins/alibaba/ram/ramPolicyAttachments.js new file mode 100644 index 000000000..f1979a0be --- /dev/null +++ b/plugins/alibaba/ram/ramPolicyAttachments.js @@ -0,0 +1,63 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RAM Policy Attachments', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that RAM policies are not attached to RAM users and are only attached to groups and roles.', + more_info: 'Assigning RAM policies at the group or role level reduces the complexity of access management which in-turn can reduce the possibility of accidental access to users.', + link: 'https://www.alibabacloud.com/help/doc-detail/116815.htm', + recommended_action: 'Ensure that RAM policies are not attached with RAM users.', + apis: ['RAM:ListUsers', 'RAM:ListPoliciesForUser', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listUsers = helpers.addSource(cache, source, + ['ram', 'ListUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query RAM users' + helpers.addError(listUsers), region); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No RAM users found', region); + return callback(null, results, source); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + let resource = helpers.createArn('ram', accountId, 'user', user.UserName); + + let listPoliciesForUser = helpers.addSource(cache, source, + ['ram', 'ListPoliciesForUser', region, user.UserName]); + + if (!listPoliciesForUser || listPoliciesForUser.err || !listPoliciesForUser.data || !listPoliciesForUser.data.Policies) { + helpers.addResult(results, 3, + `Unable to query user policies: ${listPoliciesForUser}`, region, resource); + continue; + } + + if (listPoliciesForUser.data.Policies.Policy && listPoliciesForUser.data.Policies.Policy.length) { + helpers.addResult(results, 2, + `User has ${listPoliciesForUser.data.Policies.Policy.length} policy(s) attached`, region, resource); + } else { + helpers.addResult(results, 0, + 'No policies are attached to user', region, resource); + } + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/ramPolicyAttachments.spec.js b/plugins/alibaba/ram/ramPolicyAttachments.spec.js new file mode 100644 index 000000000..9ea1b550e --- /dev/null +++ b/plugins/alibaba/ram/ramPolicyAttachments.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var ramPolicyAttachments = require('./ramPolicyAttachments') + +const listUsers = [ + { + "UpdateDate": "2021-05-04T12:03:49Z", + "UserName": "khulnasoft", + "Comments": "", + "UserId": "254529020129829608", + "DisplayName": "khulnasoft", + "CreateDate": "2021-05-04T12:03:49Z" + }, + { + "UpdateDate": "2021-05-04T09:54:39Z", + "UserName": "cloudexploit", + "Comments": "", + "UserId": "283806919721151694", + "DisplayName": "cloudexploit", + "CreateDate": "2021-04-29T18:32:31Z" + } +]; + +const listPoliciesForUser = [ + { + "Policies": { + "Policy": [ + { + "PolicyType": "System", + "Description": "管理所有阿里云资源的权限", + "AttachDate": "2021-04-29T18:33:41Z", + "PolicyName": "AdministratorAccess", + "DefaultVersion": "v1" + } + ] + }, + "RequestId": "BF73EF1D-B99D-4B55-A9C1-C130FCEA40DA" + }, + { + "Policies": { + "Policy": [] + }, + } +] + +const createCache = (users, listPolicies, usersErr, listPoliciesErr) => { + let userName = (users && users.length) ? users[0].UserName : null; + return { + ram: { + ListUsers: { + 'cn-hangzhou': { + data: users, + err: usersErr + } + }, + ListPoliciesForUser: { + 'cn-hangzhou': { + [userName]: { + data: listPolicies, + err: listPoliciesErr + } + } + } + } + } +} + +describe('ramPolicyAttachments', function () { + describe('run', function () { + it('should FAIL if User has policies attached', function (done) { + const cache = createCache([listUsers[1]], listPoliciesForUser[0]); + ramPolicyAttachments.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('User has 1 policy(s) attached'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no policies are attached to user', function (done) { + const cache = createCache([listUsers[0]], listPoliciesForUser[1]); + ramPolicyAttachments.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No policies are attached to user'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No RAM users found', function (done) { + const cache = createCache([]); + ramPolicyAttachments.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RAM users found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query RAM users', function (done) { + const cache = createCache([], null, { err: 'Unable to query RAM users' }); + ramPolicyAttachments.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM users'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/ram/usersMfaEnabled.js b/plugins/alibaba/ram/usersMfaEnabled.js new file mode 100644 index 000000000..9c846e4d9 --- /dev/null +++ b/plugins/alibaba/ram/usersMfaEnabled.js @@ -0,0 +1,69 @@ +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Users MFA Enabled', + category: 'RAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures a multi-factor authentication device is enabled for all RAM users within the account', + more_info: 'RAM User should have an MFA device setup to enable two-factor authentication.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/119555.htm', + recommended_action: 'Enable an MFA device for the RAM users', + apis: ['RAM:ListUsers', 'RAM:GetUserMFAInfo', 'STS:GetCallerIdentity'], + compliance: { + hipaa: 'MFA helps provide additional assurance that the user accessing ' + + 'the Alibaba environment has been identified. HIPAA requires ' + + 'strong controls around entity authentication which can be ' + + 'enhanced through the use of MFA.', + pci: 'PCI requires MFA for all access to cardholder environments. ' + + 'Create an MFA key for RAM users.', + cis: '1.4 Ensure that multi-factor authentication is enabled for all RAM users that have a console password' + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', region, 'data']); + + var listUsers = helpers.addSource(cache, source, + ['ram', 'ListUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query RAM users' + helpers.addError(listUsers), region); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No RAM users found', region); + return callback(null, results, source); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + var getUserMfa = helpers.addSource(cache, source, + ['ram', 'GetUserMFAInfo', region, user.UserName]); + + var resource = helpers.createArn('ram', accountId, 'user', user.UserName); + + if (getUserMfa && getUserMfa.err && getUserMfa.err.code && getUserMfa.err.code === 'EntityNotExist.User.MFADevice') { + helpers.addResult(results, 2, + 'RAM user does not have MFA device configured', region, resource); + } else if (!getUserMfa || getUserMfa.err || !getUserMfa.data) { + helpers.addResult(results, 3, + 'Unable to query RAM user MFA info', region, resource); + } else { + helpers.addResult(results, 0, + 'RAM user has MFA device configured', region, resource); + } + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/ram/usersMfaEnabled.spec.js b/plugins/alibaba/ram/usersMfaEnabled.spec.js new file mode 100644 index 000000000..976ab40dd --- /dev/null +++ b/plugins/alibaba/ram/usersMfaEnabled.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var usersMfaEnabled = require('./usersMfaEnabled') + +const listUsers = [ + { + "UpdateDate": "2021-05-04T12:03:49Z", + "UserName": "khulnasoft", + "Comments": "", + "UserId": "254529020129829608", + "DisplayName": "khulnasoft", + "CreateDate": "2021-05-04T12:03:49Z" + }, + { + "UpdateDate": "2021-05-04T09:54:39Z", + "UserName": "cloudexploit", + "Comments": "", + "UserId": "283806919721151694", + "DisplayName": "cloudexploit", + "CreateDate": "2021-04-29T18:32:31Z" + } +]; + +const getUserMfa = [ + { + "MFADevice": { + "Type": "VMFA", + "SerialNumber": "acs:ram::0000111122223333:mfa/khulnasoft" + } + }, + { + code: "EntityNotExist.User.MFADevice" + } +]; + +const createCache = (users, userMfaInfo, usersErr, userMfaInfoErr) => { + let userName = (users && users.length) ? users[0].UserName : null; + return { + ram: { + ListUsers: { + 'cn-hangzhou': { + data: users, + err: usersErr + } + }, + GetUserMFAInfo: { + 'cn-hangzhou': { + [userName]: { + data: userMfaInfo, + err: userMfaInfoErr + } + } + } + } + } +} + +describe('usersMfaEnabled', function () { + describe('run', function () { + it('should FAIL if RAM user does not have MFA device configured', function (done) { + const cache = createCache([listUsers[1]], null, null, getUserMfa[1]); + usersMfaEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RAM user does not have MFA device configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RAM user has MFA device configured', function (done) { + const cache = createCache([listUsers[0]], getUserMfa[0]); + usersMfaEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RAM user has MFA device configured'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if No RAM users found', function (done) { + const cache = createCache([]); + usersMfaEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RAM users found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query RAM users', function (done) { + const cache = createCache([], null, { err: 'Unable to query RAM users' }); + usersMfaEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RAM users'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) diff --git a/plugins/alibaba/rds/rdsAuditingEnabled.js b/plugins/alibaba/rds/rdsAuditingEnabled.js new file mode 100644 index 000000000..ec0428829 --- /dev/null +++ b/plugins/alibaba/rds/rdsAuditingEnabled.js @@ -0,0 +1,86 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Auditing Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that RDS DB instances events and activities are being logged to help fix any suspicious activities or security issues.', + more_info: 'RDS instances provides auditing feature that can be used to log all the events and activities which can be used later in case of any suspicions or security reasons.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/118678.htm', + recommended_action: 'Modify RDS DB instances to enable the auditing', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeSQLCollectorPolicy', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var unsupportedEngines = ['sqlserver 2012', 'sqlserver 2016', 'sqlserver 2017', 'mariadb tx']; + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + let instanceEngine = (instance.Engine && instance.EngineVersion) ? `${instance.Engine.toLowerCase()} ${instance.EngineVersion}` : ''; + + if (unsupportedEngines.includes(instanceEngine)) { + helpers.addResult(results, 0, + `SQL auditing is not supported for ${instanceEngine} engine type`, + region, resource); + return cb(); + } + + var describeSqlAudit = helpers.addSource(cache, source, + ['rds', 'DescribeSQLCollectorPolicy', region, instance.DBInstanceId]); + + if (!describeSqlAudit || describeSqlAudit.err || !describeSqlAudit.data) { + helpers.addResult(results, 3, + `Unable to query DB sql auditing policy: ${helpers.addError(describeSqlAudit)}`, + region, resource); + return cb(); + } + + if (describeSqlAudit.data.SQLCollectorStatus == 'Enable') { + helpers.addResult(results, 0, + 'RDS DB instance have sql auditing enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have sql auditing enabled', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsAuditingEnabled.spec.js b/plugins/alibaba/rds/rdsAuditingEnabled.spec.js new file mode 100644 index 000000000..4caa12abf --- /dev/null +++ b/plugins/alibaba/rds/rdsAuditingEnabled.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +var rdsAuditingEnabled = require('./rdsAuditingEnabled.js'); + +const describeDBInstances = [ + { + "EngineVersion": "13.0", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "PostgreSQL" + }, + { + "EngineVersion": "13.0", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "MySQL" + } +]; + +const describeSqlAudit = [ + { + SQLCollectorStatus: 'Enable' + }, + { + SQLCollectorStatus: 'Disabled' + } +]; + +const createCache = (dbInstances, describeSqlAudit, dbInstancesErr, describeSqlAuditErr) => { + let instanceId = (dbInstances && dbInstances.length) ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: dbInstances, + err: dbInstancesErr + }, + }, + DescribeSQLCollectorPolicy: { + 'cn-hangzhou': { + [instanceId]: { + data: describeSqlAudit, + err: describeSqlAuditErr + } + } + } + }, + }; +}; + +describe('rdsAuditingEnabled', function () { + describe('run', function () { + it('should FAIL if RDS DB instance does not have sql auditing enabled', function (done) { + const cache = createCache(describeDBInstances, describeSqlAudit[1]); + rdsAuditingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RDS DB instance does not have sql auditing enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance have sql auditing enabled', function (done) { + const cache = createCache(describeDBInstances, describeSqlAudit[0]); + rdsAuditingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RDS DB instance have sql auditing enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsAuditingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsAuditingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query DB sql auditing policy', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query DB sql auditing policy' }); + rdsAuditingEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query DB sql auditing policy'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogConnectionEnabled.js b/plugins/alibaba/rds/rdsLogConnectionEnabled.js new file mode 100644 index 000000000..beaca616c --- /dev/null +++ b/plugins/alibaba/rds/rdsLogConnectionEnabled.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Log Connections Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that log_connections parameter is set to ON for RDS instances.', + more_info: 'RDS instance provide the feature of logging details of establishing a connection to the server ' + + 'to identify, troubleshoot, and repair configuration errors and suboptimal performance.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/26179.htm', + recommended_action: 'Modify RDS DB instance to set value for log_connections parameter to ON', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeParameters', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + var describeParameters = helpers.addSource(cache, source, + ['rds', 'DescribeParameters', region, instance.DBInstanceId]); + + if (!describeParameters || describeParameters.err || !describeParameters.data) { + helpers.addResult(results, 3, + `Unable to query DB parameters: ${helpers.addError(describeParameters)}`, + region, resource); + return cb(); + } + + if (describeParameters.data.RunningParameters && + describeParameters.data.RunningParameters.DBInstanceParameter && + describeParameters.data.RunningParameters.DBInstanceParameter.length) { + let parameters = describeParameters.data.RunningParameters.DBInstanceParameter; + let found = parameters.find(parameter => parameter.ParameterName == 'log_connections' && + parameter.ParameterValue && parameter.ParameterValue.toLowerCase() == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'RDS DB instance has log_connections parameter enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_connections parameter enabled', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_connections parameter enabled', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogConnectionEnabled.spec.js b/plugins/alibaba/rds/rdsLogConnectionEnabled.spec.js new file mode 100644 index 000000000..80df35ccd --- /dev/null +++ b/plugins/alibaba/rds/rdsLogConnectionEnabled.spec.js @@ -0,0 +1,572 @@ +var expect = require("chai").expect; +var rdsLogConnectionEnabled = require("./rdsLogConnectionEnabled.js"); + +const describeDBInstances = [ + { + EngineVersion: "13.0", + DBInstanceStatus: "Running", + ResourceGroupId: "rg-aekzsj44b4lt5fa", + DBInstanceNetType: "Intranet", + DBInstanceClass: "pg.n2.small.2c", + CreateTime: "2021-05-04T17:13:45Z", + VSwitchId: "vsw-rj94uhhrj5qz5008lwi1x", + DBInstanceType: "Primary", + PayType: "Postpaid", + LockMode: "Unlock", + MutriORsignle: false, + InstanceNetworkType: "VPC", + InsId: 1, + VpcId: "vpc-rj9vu86hdve3qr173ew17", + DBInstanceId: "pgm-2ev213kfnogf7mfi", + ConnectionMode: "Standard", + ReadOnlyDBInstanceIds: { + ReadOnlyDBInstanceId: [], + }, + VpcCloudInstanceId: "pgm-2ev213kfnogf7mfi", + ExpireTime: "", + LockReason: "", + Engine: "PostgreSQL", + }, +]; + +const describeParameters = [ + { + RunningParameters: { + DBInstanceParameter: [ + { + ParameterValue: "0", + ParameterName: "autovacuum_vacuum_cost_delay", + ParameterDescription: + "Vacuum cost delay in milliseconds, for autovacuum.", + }, + { + ParameterValue: "10000", + ParameterName: "autovacuum_vacuum_cost_limit", + ParameterDescription: + "Vacuum cost amount available before napping, for autovacuum.", + }, + { + ParameterValue: "0.02", + ParameterName: "autovacuum_vacuum_scale_factor", + ParameterDescription: + "When the table is updated or deleted tuples exceed autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * the total number of table tuples triggers automatic cleanup.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_analyze", + ParameterDescription: "Use EXPLAIN ANALYZE for plan logging.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_buffers", + ParameterDescription: "Log buffers usage.", + }, + { + ParameterValue: "text", + ParameterName: "auto_explain.log_format", + ParameterDescription: "EXPLAIN format to be used for plan logging.", + }, + { + ParameterValue: "-1", + ParameterName: "auto_explain.log_min_duration", + ParameterDescription: + "Sets the minimum execution time above which plans will be logged. Zero prints all plans. -1 turns this feature off.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_nested_statements", + ParameterDescription: "Log nested statements.", + }, + { + ParameterValue: "on", + ParameterName: "auto_explain.log_timing", + ParameterDescription: "Collect timing data, not just row counts.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_triggers", + ParameterDescription: + "Include trigger statistics in plans. This has no effect unless log_analyze is also set.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_verbose", + ParameterDescription: "Use EXPLAIN VERBOSE for plan logging.", + }, + { + ParameterValue: "1", + ParameterName: "auto_explain.sample_rate", + ParameterDescription: "Fraction of queries to process.", + }, + { + ParameterValue: "off", + ParameterName: "default_transaction_deferrable", + ParameterDescription: + "Sets the default deferrable status of new transactions.", + }, + { + ParameterValue: "on", + ParameterName: "enable_partitionwise_aggregate", + ParameterDescription: + "Enables partitionwise aggregation and grouping.", + }, + { + ParameterValue: "on", + ParameterName: "enable_partitionwise_join", + ParameterDescription: "Enables partitionwise join.", + }, + { + ParameterValue: "0", + ParameterName: "extra_float_digits", + ParameterDescription: + "Sets the number of digits displayed for floating-point values.", + }, + { + ParameterValue: "3600000", + ParameterName: "idle_in_transaction_session_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any idling transaction. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "jit", + ParameterDescription: "allow JIT compilation", + }, + { + ParameterValue: "0", + ParameterName: "lock_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any wait for a lock. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "log_connections", + ParameterDescription: "Logs each successful connection.", + }, + { + ParameterValue: "off", + ParameterName: "log_disconnections", + ParameterDescription: "Logs end of a session, including duration.", + }, + { + ParameterValue: "1000", + ParameterName: "log_min_duration_statement", + ParameterDescription: + "SQL with execution time exceeding this value will be logged. Note that a too small value may cause performance degradation and increase the amount of logs.", + }, + { + ParameterValue: "ddl", + ParameterName: "log_statement", + ParameterDescription: + "Sets the type of statements logged. Setting it to all or mod will cause performance degradation and increase the amount of logs.", + }, + { + ParameterValue: "131072", + ParameterName: "log_temp_files", + ParameterDescription: + "Log the use of temporary files larger than this number of kilobyte. Zero logs all files. The default is -1 turning this feature off.", + }, + { + ParameterValue: "-1", + ParameterName: "old_snapshot_threshold", + ParameterDescription: + "Time before a snapshot is too old to read pages changed after the snapshot was taken.", + }, + { + ParameterValue: "20", + ParameterName: "rds_max_log_files", + ParameterDescription: + "Sets the maximum number of log files. Each log file is 100 MB in size.", + }, + { + ParameterValue: "0", + ParameterName: "rds_sync_replication_timeout", + ParameterDescription: + "The maximum time in milliseconds to wait for WAL synchronous replication. When it is timeout, synchronous replication change to asynchronous replication until replication is catchup.", + }, + { + ParameterValue: "disable", + ParameterName: "sql_firewall.firewall", + ParameterDescription: + "The parameter is to detemine running mode of sql_firewall extension.", + }, + { + ParameterValue: "TLSv1", + ParameterName: "ssl_min_protocol_version", + ParameterDescription: + "Sets the minimum SSL/TLS protocol version to use", + }, + { + ParameterValue: "0", + ParameterName: "statement_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any statement. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "synchronous_commit", + ParameterDescription: + "Sets the current transaction's synchronization level.", + }, + { + ParameterValue: "''", + ParameterName: "synchronous_standby_names", + ParameterDescription: + "Number of synchronous standbys and list of names of potential synchronous ones.", + }, + { + ParameterValue: "Asia/Shanghai", + ParameterName: "timezone", + ParameterDescription: "timezone", + }, + { + ParameterValue: "off", + ParameterName: "track_commit_timestamp", + ParameterDescription: "Collects transaction commit time.", + }, + { + ParameterValue: "0", + ParameterName: "vacuum_defer_cleanup_age", + ParameterDescription: + "Number of transactions by which VACUUM and HOT cleanup should be deferred, if any.", + }, + { + ParameterValue: "1024", + ParameterName: "wal_keep_size", + ParameterDescription: + "Sets the size of WAL files held for standby servers (MB)", + }, + { + ParameterValue: "replica", + ParameterName: "wal_level", + ParameterDescription: + "Set the level of information written to the WAL.", + }, + ], + }, + EngineVersion: "13.0", + RequestId: "F9FF62D1-C157-4893-A095-EB45E6F6F36A", + ConfigParameters: { + DBInstanceParameter: [], + }, + Engine: "PostgreSQL", + }, + { + RunningParameters: { + DBInstanceParameter: [ + { + ParameterValue: "0", + ParameterName: "autovacuum_vacuum_cost_delay", + ParameterDescription: + "Vacuum cost delay in milliseconds, for autovacuum.", + }, + { + ParameterValue: "10000", + ParameterName: "autovacuum_vacuum_cost_limit", + ParameterDescription: + "Vacuum cost amount available before napping, for autovacuum.", + }, + { + ParameterValue: "0.02", + ParameterName: "autovacuum_vacuum_scale_factor", + ParameterDescription: + "When the table is updated or deleted tuples exceed autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * the total number of table tuples triggers automatic cleanup.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_analyze", + ParameterDescription: "Use EXPLAIN ANALYZE for plan logging.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_buffers", + ParameterDescription: "Log buffers usage.", + }, + { + ParameterValue: "text", + ParameterName: "auto_explain.log_format", + ParameterDescription: "EXPLAIN format to be used for plan logging.", + }, + { + ParameterValue: "-1", + ParameterName: "auto_explain.log_min_duration", + ParameterDescription: + "Sets the minimum execution time above which plans will be logged. Zero prints all plans. -1 turns this feature off.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_nested_statements", + ParameterDescription: "Log nested statements.", + }, + { + ParameterValue: "on", + ParameterName: "auto_explain.log_timing", + ParameterDescription: "Collect timing data, not just row counts.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_triggers", + ParameterDescription: + "Include trigger statistics in plans. This has no effect unless log_analyze is also set.", + }, + { + ParameterValue: "off", + ParameterName: "auto_explain.log_verbose", + ParameterDescription: "Use EXPLAIN VERBOSE for plan logging.", + }, + { + ParameterValue: "1", + ParameterName: "auto_explain.sample_rate", + ParameterDescription: "Fraction of queries to process.", + }, + { + ParameterValue: "off", + ParameterName: "default_transaction_deferrable", + ParameterDescription: + "Sets the default deferrable status of new transactions.", + }, + { + ParameterValue: "on", + ParameterName: "enable_partitionwise_aggregate", + ParameterDescription: + "Enables partitionwise aggregation and grouping.", + }, + { + ParameterValue: "on", + ParameterName: "enable_partitionwise_join", + ParameterDescription: "Enables partitionwise join.", + }, + { + ParameterValue: "0", + ParameterName: "extra_float_digits", + ParameterDescription: + "Sets the number of digits displayed for floating-point values.", + }, + { + ParameterValue: "3600000", + ParameterName: "idle_in_transaction_session_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any idling transaction. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "jit", + ParameterDescription: "allow JIT compilation", + }, + { + ParameterValue: "0", + ParameterName: "lock_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any wait for a lock. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "on", + ParameterName: "log_connections", + ParameterDescription: "Logs each successful connection.", + }, + { + ParameterValue: "on", + ParameterName: "log_disconnections", + ParameterDescription: "Logs end of a session, including duration.", + }, + { + ParameterValue: "1000", + ParameterName: "log_min_duration_statement", + ParameterDescription: + "SQL with execution time exceeding this value will be logged. Note that a too small value may cause performance degradation and increase the amount of logs.", + }, + { + ParameterValue: "ddl", + ParameterName: "log_statement", + ParameterDescription: + "Sets the type of statements logged. Setting it to all or mod will cause performance degradation and increase the amount of logs.", + }, + { + ParameterValue: "131072", + ParameterName: "log_temp_files", + ParameterDescription: + "Log the use of temporary files larger than this number of kilobyte. Zero logs all files. The default is -1 turning this feature off.", + }, + { + ParameterValue: "-1", + ParameterName: "old_snapshot_threshold", + ParameterDescription: + "Time before a snapshot is too old to read pages changed after the snapshot was taken.", + }, + { + ParameterValue: "20", + ParameterName: "rds_max_log_files", + ParameterDescription: + "Sets the maximum number of log files. Each log file is 100 MB in size.", + }, + { + ParameterValue: "0", + ParameterName: "rds_sync_replication_timeout", + ParameterDescription: + "The maximum time in milliseconds to wait for WAL synchronous replication. When it is timeout, synchronous replication change to asynchronous replication until replication is catchup.", + }, + { + ParameterValue: "disable", + ParameterName: "sql_firewall.firewall", + ParameterDescription: + "The parameter is to detemine running mode of sql_firewall extension.", + }, + { + ParameterValue: "TLSv1", + ParameterName: "ssl_min_protocol_version", + ParameterDescription: + "Sets the minimum SSL/TLS protocol version to use", + }, + { + ParameterValue: "0", + ParameterName: "statement_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any statement. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "synchronous_commit", + ParameterDescription: + "Sets the current transaction's synchronization level.", + }, + { + ParameterValue: "''", + ParameterName: "synchronous_standby_names", + ParameterDescription: + "Number of synchronous standbys and list of names of potential synchronous ones.", + }, + { + ParameterValue: "Asia/Shanghai", + ParameterName: "timezone", + ParameterDescription: "timezone", + }, + { + ParameterValue: "off", + ParameterName: "track_commit_timestamp", + ParameterDescription: "Collects transaction commit time.", + }, + { + ParameterValue: "0", + ParameterName: "vacuum_defer_cleanup_age", + ParameterDescription: + "Number of transactions by which VACUUM and HOT cleanup should be deferred, if any.", + }, + { + ParameterValue: "1024", + ParameterName: "wal_keep_size", + ParameterDescription: + "Sets the size of WAL files held for standby servers (MB)", + }, + { + ParameterValue: "replica", + ParameterName: "wal_level", + ParameterDescription: + "Set the level of information written to the WAL.", + }, + ], + }, + EngineVersion: "13.0", + RequestId: "F9FF62D1-C157-4893-A095-EB45E6F6F36A", + ConfigParameters: { + DBInstanceParameter: [], + }, + Engine: "PostgreSQL", + }, +]; + +const createCache = ( + dbInstances, + describeParameters, + dbInstancesErr, + describeParametersErr +) => { + let instanceId = + dbInstances && dbInstances.length ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + "cn-hangzhou": { + data: dbInstances, + err: dbInstancesErr, + }, + }, + DescribeParameters: { + "cn-hangzhou": { + [instanceId]: { + data: describeParameters, + err: describeParametersErr, + }, + }, + }, + }, + }; +}; + +describe("rdsLogConnectionEnabled", function () { + describe("run", function () { + it("should FAIL if RDS DB instance does not have log_connections parameter enabled", function (done) { + const cache = createCache(describeDBInstances, describeParameters[0]); + rdsLogConnectionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "RDS DB instance does not have log_connections parameter enabled" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should PASS if RDS DB instance has log_connections parameter enabled", function (done) { + const cache = createCache(describeDBInstances, describeParameters[1]); + rdsLogConnectionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "RDS DB instance has log_connections parameter enabled" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should PASS if no RDS DB instances found", function (done) { + const cache = createCache([]); + rdsLogConnectionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No RDS DB instances found"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should UNKNOWN if unable to query RDS DB instances", function (done) { + const cache = createCache([], null, { + err: "Unable to query RDS DB instances", + }); + rdsLogConnectionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include( + "Unable to query RDS DB instances" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should UNKNOWN if unable to query DB parameters", function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { + err: "Unable to query DB parameters", + }); + rdsLogConnectionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query DB parameters"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.js b/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.js new file mode 100644 index 000000000..1b0a6a639 --- /dev/null +++ b/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Log Disconnections Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that log_disconnections parameter is set to ON for RDS instances.', + more_info: 'RDS instance provide the feature of logging details of termination of a connection to the server ' + + 'to identify, troubleshoot, and repair configuration errors and suboptimal performance.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/26179.htm', + recommended_action: 'Modify RDS DB instance to set value for log_disconnections parameter to ON', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeParameters', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + if (instance.Engine && instance.Engine.toUpperCase() !== 'POSTGRESQL') { + helpers.addResult(results, 0, + `Log Disconnections is not supported for ${instance.Engine} engine type`, + region, resource); + return cb(); + } + + var describeParameters = helpers.addSource(cache, source, + ['rds', 'DescribeParameters', region, instance.DBInstanceId]); + + if (!describeParameters || describeParameters.err || !describeParameters.data) { + helpers.addResult(results, 3, + `Unable to query DB parameters: ${helpers.addError(describeParameters)}`, + region, resource); + return cb(); + } + + if (describeParameters.data.RunningParameters && + describeParameters.data.RunningParameters.DBInstanceParameter && + describeParameters.data.RunningParameters.DBInstanceParameter.length) { + let parameters = describeParameters.data.RunningParameters.DBInstanceParameter; + let found = parameters.find(parameter => parameter.ParameterName == 'log_disconnections' && + parameter.ParameterValue && parameter.ParameterValue.toLowerCase() == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'RDS DB instance has log_disconnections parameter enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_disconnections parameter enabled', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_disconnections parameter enabled', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.spec.js b/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.spec.js new file mode 100644 index 000000000..62065e714 --- /dev/null +++ b/plugins/alibaba/rds/rdsLogDisconnectionsEnabled.spec.js @@ -0,0 +1,211 @@ +var expect = require("chai").expect; +var rdsLogDisconnectionsEnabled = require("./rdsLogDisconnectionsEnabled.js"); + +const describeDBInstances = [ + { + EngineVersion: "13.0", + DBInstanceStatus: "Running", + ResourceGroupId: "rg-aekzsj44b4lt5fa", + DBInstanceNetType: "Intranet", + DBInstanceClass: "pg.n2.small.2c", + CreateTime: "2021-05-04T17:13:45Z", + VSwitchId: "vsw-rj94uhhrj5qz5008lwi1x", + DBInstanceType: "Primary", + PayType: "Postpaid", + LockMode: "Unlock", + MutriORsignle: false, + InstanceNetworkType: "VPC", + InsId: 1, + VpcId: "vpc-rj9vu86hdve3qr173ew17", + DBInstanceId: "pgm-2ev213kfnogf7mfi", + ConnectionMode: "Standard", + ReadOnlyDBInstanceIds: { + ReadOnlyDBInstanceId: [], + }, + VpcCloudInstanceId: "pgm-2ev213kfnogf7mfi", + ExpireTime: "", + LockReason: "", + Engine: "PostgreSQL", + }, +]; + +const describeParameters = [ + { + RunningParameters: { + DBInstanceParameter: [ + { + ParameterValue: "off", + ParameterName: "log_disconnections", + ParameterDescription: "Logs each successful connection.", + }, + { + ParameterValue: "0", + ParameterName: "statement_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any statement. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "off", + ParameterName: "synchronous_commit", + ParameterDescription: + "Sets the current transaction's synchronization level.", + }, + { + ParameterValue: "''", + ParameterName: "synchronous_standby_names", + ParameterDescription: + "Number of synchronous standbys and list of names of potential synchronous ones.", + }, + { + ParameterValue: "Asia/Shanghai", + ParameterName: "timezone", + ParameterDescription: "timezone", + }, + { + ParameterValue: "1024", + ParameterName: "wal_keep_size", + ParameterDescription: + "Sets the size of WAL files held for standby servers (MB)", + }, + { + ParameterValue: "replica", + ParameterName: "wal_level", + ParameterDescription: + "Set the level of information written to the WAL.", + }, + ], + }, + EngineVersion: "13.0", + RequestId: "F9FF62D1-C157-4893-A095-EB45E6F6F36A", + ConfigParameters: { + DBInstanceParameter: [], + }, + Engine: "PostgreSQL", + }, + { + RunningParameters: { + DBInstanceParameter: [ + { + ParameterValue: "0", + ParameterName: "lock_timeout", + ParameterDescription: + "Sets the maximum allowed duration of any wait for a lock. A value of 0 turns off the timeout.", + }, + { + ParameterValue: "on", + ParameterName: "log_disconnections", + ParameterDescription: "Logs end of a session, including duration.", + }, + { + ParameterValue: "1000", + ParameterName: "log_min_duration_statement", + ParameterDescription: + "SQL with execution time exceeding this value will be logged. Note that a too small value may cause performance degradation and increase the amount of logs.", + } + ], + }, + EngineVersion: "13.0", + RequestId: "F9FF62D1-C157-4893-A095-EB45E6F6F36A", + ConfigParameters: { + DBInstanceParameter: [], + }, + Engine: "PostgreSQL", + }, +]; + +const createCache = ( + dbInstances, + describeParameters, + dbInstancesErr, + describeParametersErr +) => { + let instanceId = + dbInstances && dbInstances.length ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + "cn-hangzhou": { + data: dbInstances, + err: dbInstancesErr, + }, + }, + DescribeParameters: { + "cn-hangzhou": { + [instanceId]: { + data: describeParameters, + err: describeParametersErr, + }, + }, + }, + }, + }; +}; + +describe("rdsLogDisconnectionsEnabled", function () { + describe("run", function () { + it("should FAIL if RDS DB instance does not have log_disconnections parameter enabled", function (done) { + const cache = createCache(describeDBInstances, describeParameters[0]); + rdsLogDisconnectionsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "RDS DB instance does not have log_disconnections parameter enabled" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should PASS if RDS DB instance has log_disconnections parameter enabled", function (done) { + const cache = createCache(describeDBInstances, describeParameters[1]); + rdsLogDisconnectionsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "RDS DB instance has log_disconnections parameter enabled" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should PASS if no RDS DB instances found", function (done) { + const cache = createCache([]); + rdsLogDisconnectionsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No RDS DB instances found"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should UNKNOWN if unable to query RDS DB instances", function (done) { + const cache = createCache([], null, { + err: "Unable to query RDS DB instances", + }); + rdsLogDisconnectionsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include( + "Unable to query RDS DB instances" + ); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + + it("should UNKNOWN if unable to query DB parameters", function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { + err: "Unable to query DB parameters", + }); + rdsLogDisconnectionsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query DB parameters"); + expect(results[0].region).to.equal("cn-hangzhou"); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogDuration.js b/plugins/alibaba/rds/rdsLogDuration.js new file mode 100644 index 000000000..7286e5e65 --- /dev/null +++ b/plugins/alibaba/rds/rdsLogDuration.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Log Duration', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that RDS DB instances have log_duration parameter enabled.', + more_info: 'Enabling log_duration parameter logs the duration of each completed SQL statement generating query and error logs ' + + 'which can be used to identify, troubleshoot, and repair configuration errors and sub-optimal performance.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/26179.htm', + recommended_action: 'Modify RDS DB instances to set value for log_duration parameter to ON', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeParameters', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + if (instance.Engine && instance.Engine.toUpperCase() !== 'POSTGRESQL') { + helpers.addResult(results, 0, + `Log Duration is not supported for ${instance.Engine} engine type`, + region, resource); + return cb(); + } + + var describeParameters = helpers.addSource(cache, source, + ['rds', 'DescribeParameters', region, instance.DBInstanceId]); + + if (!describeParameters || describeParameters.err || !describeParameters.data) { + helpers.addResult(results, 3, + `Unable to query DB parameters: ${helpers.addError(describeParameters)}`, + region, resource); + return cb(); + } + + if (describeParameters.data.RunningParameters && + describeParameters.data.RunningParameters.DBInstanceParameter && + describeParameters.data.RunningParameters.DBInstanceParameter.length) { + let parameters = describeParameters.data.RunningParameters.DBInstanceParameter; + let found = parameters.find(parameter => parameter.ParameterName == 'log_duration' && parameter.ParameterValue.toLowerCase() == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'RDS DB instance has log_duration parameter enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_duration parameter enabled', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have log_duration parameter enabled', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsLogDuration.spec.js b/plugins/alibaba/rds/rdsLogDuration.spec.js new file mode 100644 index 000000000..c4b556c41 --- /dev/null +++ b/plugins/alibaba/rds/rdsLogDuration.spec.js @@ -0,0 +1,193 @@ +var expect = require('chai').expect; +var rdsLogDuration = require('./rdsLogDuration.js'); + +const describeDBInstances = [ + { + "EngineVersion": "13.0", + "DBInstanceStatus": "Running", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DBInstanceNetType": "Intranet", + "DBInstanceClass": "pg.n2.small.2c", + "CreateTime": "2021-05-04T17:13:45Z", + "VSwitchId": "vsw-rj94uhhrj5qz5008lwi1x", + "DBInstanceType": "Primary", + "PayType": "Postpaid", + "LockMode": "Unlock", + "MutriORsignle": false, + "InstanceNetworkType": "VPC", + "InsId": 1, + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "ConnectionMode": "Standard", + "ReadOnlyDBInstanceIds": { + "ReadOnlyDBInstanceId": [] + }, + "VpcCloudInstanceId": "pgm-2ev213kfnogf7mfi", + "ExpireTime": "", + "LockReason": "", + "Engine": "PostgreSQL" + } +]; + +const describeParameters = [ + { + "RunningParameters": { + "DBInstanceParameter": [ + { + "ParameterValue": "on", + "ParameterName": "log_duration", + "ParameterDescription": "Logs the duration of each completed SQL statement." + }, + { + "ParameterValue": "off", + "ParameterName": "jit", + "ParameterDescription": "allow JIT compilation" + }, + { + "ParameterValue": "0", + "ParameterName": "lock_timeout", + "ParameterDescription": "Sets the maximum allowed duration of any wait for a lock. A value of 0 turns off the timeout." + }, + { + "ParameterValue": "off", + "ParameterName": "log_connections", + "ParameterDescription": "Logs each successful connection." + }, + { + "ParameterValue": "off", + "ParameterName": "log_disconnections", + "ParameterDescription": "Logs end of a session, including duration." + }, + { + "ParameterValue": "1000", + "ParameterName": "log_min_duration_statement", + "ParameterDescription": "SQL with execution time exceeding this value will be logged. Note that a too small value may cause performance degradation and increase the amount of logs." + }, + { + "ParameterValue": "ddl", + "ParameterName": "log_statement", + "ParameterDescription": "Sets the type of statements logged. Setting it to all or mod will cause performance degradation and increase the amount of logs." + }, + { + "ParameterValue": "-1", + "ParameterName": "old_snapshot_threshold", + "ParameterDescription": "Time before a snapshot is too old to read pages changed after the snapshot was taken." + }, + { + "ParameterValue": "disable", + "ParameterName": "sql_firewall.firewall", + "ParameterDescription": "The parameter is to detemine running mode of sql_firewall extension." + }, + { + "ParameterValue": "off", + "ParameterName": "ssl", + "ParameterDescription": "Enables SSL connections." + }, + { + "ParameterValue": "TLSv1", + "ParameterName": "ssl_min_protocol_version", + "ParameterDescription": "Sets the minimum SSL/TLS protocol version to use" + }, + { + "ParameterValue": "0", + "ParameterName": "statement_timeout", + "ParameterDescription": "Sets the maximum allowed duration of any statement. A value of 0 turns off the timeout." + }, + ] + }, + "EngineVersion": "13.0", + "Engine": "PostgreSQL" + }, + { + "RunningParameters": { + "DBInstanceParameter": [ + { + "ParameterValue": "off", + "ParameterName": "log_duration", + "ParameterDescription": "Logs the duration of each completed SQL statement." + }, + ] + } + } +]; + +const createCache = (dbInstances, describeParameters, dbInstancesErr, describeParametersErr) => { + let instanceId = (dbInstances && dbInstances.length) ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: dbInstances, + err: dbInstancesErr + }, + }, + DescribeParameters: { + 'cn-hangzhou': { + [instanceId]: { + data: describeParameters, + err: describeParametersErr + } + } + } + }, + }; +}; + +describe('rdsLogDuration', function () { + describe('run', function () { + it('should FAIL if RDS DB instance does not have log_duration parameter enabled', function (done) { + const cache = createCache(describeDBInstances, describeParameters[1]); + rdsLogDuration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RDS DB instance does not have log_duration parameter enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance has log_duration parameter enabled', function (done) { + const cache = createCache(describeDBInstances, describeParameters[0]); + rdsLogDuration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RDS DB instance has log_duration parameter enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsLogDuration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsLogDuration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query DB parameters', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query DB parameters' }); + rdsLogDuration.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query DB parameters'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsPublicAccess.js b/plugins/alibaba/rds/rdsPublicAccess.js new file mode 100644 index 000000000..9d96ed7d3 --- /dev/null +++ b/plugins/alibaba/rds/rdsPublicAccess.js @@ -0,0 +1,86 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Public Access', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure that RDS DB instances are not publicly accessible.', + more_info: 'Enabling public access increase chances of data insecurity. Public access should always be disabled and only know IP addresses should be whitelisted.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/26198.htm', + recommended_action: 'Modify security settings for RDS DB instances to disable the public access.', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeDBInstanceIPArrayList', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + var describeInstanceWhitelist = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstanceIPArrayList', region, instance.DBInstanceId]); + + if (!describeInstanceWhitelist || describeInstanceWhitelist.err || !describeInstanceWhitelist.data) { + helpers.addResult(results, 3, + `Unable to query DB IP Array List: ${helpers.addError(describeInstanceWhitelist)}`, + region, resource); + return cb(); + } + + if (describeInstanceWhitelist.data.Items && + describeInstanceWhitelist.data.Items.DBInstanceIPArray && + describeInstanceWhitelist.data.Items.DBInstanceIPArray.length) { + let ipArray = describeInstanceWhitelist.data.Items.DBInstanceIPArray; + let found = ipArray.find(ipObject => ipObject.SecurityIPList && ipObject.SecurityIPList.includes('0.0.0.0')); + + if (found) { + helpers.addResult(results, 2, + 'RDS DB instance is publicly accessible', + region, resource); + } else { + helpers.addResult(results, 0, + 'RDS DB instance is not publicly accessible', + region, resource); + } + } else { + helpers.addResult(results, 0, + 'RDS DB instance is not publicly accessible', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/rds/rdsPublicAccess.spec.js b/plugins/alibaba/rds/rdsPublicAccess.spec.js new file mode 100644 index 000000000..0a733ce8c --- /dev/null +++ b/plugins/alibaba/rds/rdsPublicAccess.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var rdsPublicAccess = require('./rdsPublicAccess.js'); + +const describeDBInstances = [ + { + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "PostgreSQL" + } +]; + +const describeInstanceWhitelist = [ + { + Items: { + DBInstanceIPArray: [ + { + DBInstanceIPArrayAttribute: "", + SecurityIPType: "IPv4", + SecurityIPList: "127.0.0.1", + WhitelistNetworkType: "MIX", + DBInstanceIPArrayName: "default", + }, + { + DBInstanceIPArrayAttribute: "hidden", + SecurityIPType: "IPv4", + SecurityIPList: "10.81.176.172,10.81.89.178,100.104.172.0/24,100.104.220.0/24,11.193.102.115,11.193.102.131,11.195.184.229,11.195.184.232,11.195.184.233,11.195.184.234,11.195.184.83,11.195.184.84,11.195.184.89,11.195.184.93", + WhitelistNetworkType: "MIX", + DBInstanceIPArrayName: "hdm_security_ips", + }], + }, + }, + { + Items: { + DBInstanceIPArray: [ + { + DBInstanceIPArrayAttribute: "", + SecurityIPType: "IPv4", + SecurityIPList: "127.0.0.1,0.0.0.0/0", + WhitelistNetworkType: "MIX", + DBInstanceIPArrayName: "default", + }, + { + DBInstanceIPArrayAttribute: "hidden", + SecurityIPType: "IPv4", + SecurityIPList: "10.81.176.172,10.81.89.178,100.104.172.0/24,100.104.220.0/24,11.193.102.115,11.193.102.131,11.195.184.229,11.195.184.232,11.195.184.233,11.195.184.234,11.195.184.83,11.195.184.84,11.195.184.89,11.195.184.93", + WhitelistNetworkType: "MIX", + DBInstanceIPArrayName: "hdm_security_ips", + }], + }, + } +]; + +const createCache = (dbInstances, describeInstanceWhitelist, dbInstancesErr, describeInstanceWhitelistErr) => { + let instanceId = (dbInstances && dbInstances.length) ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: dbInstances, + err: dbInstancesErr + }, + }, + DescribeDBInstanceIPArrayList: { + 'cn-hangzhou': { + [instanceId]: { + data: describeInstanceWhitelist, + err: describeInstanceWhitelistErr + } + } + } + }, + }; +}; + +describe('rdsPublicAccess', function () { + describe('run', function () { + it('should FAIL if RDS DB instance is publicly accessible', function (done) { + const cache = createCache(describeDBInstances, describeInstanceWhitelist[1], null, null); + rdsPublicAccess.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RDS DB instance is publicly accessible'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance is not publicly accessible', function (done) { + const cache = createCache(describeDBInstances, describeInstanceWhitelist[0]); + rdsPublicAccess.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RDS DB instance is not publicly accessible'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsPublicAccess.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsPublicAccess.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query DB IP Array List', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query DB IP Array List' }); + rdsPublicAccess.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query DB IP Array List'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.js b/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.js new file mode 100644 index 000000000..da46a3cac --- /dev/null +++ b/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS SQL Audit Log Retention Period', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that RDS DB instances SQL Audit Log retention period is configured to be greater than set days limit', + more_info: 'RDS instances provides auditing feature that can be used to log all the events and activities which can be used later in case of any suspicions or security reasons.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/118678.htm', + recommended_action: 'Modify RDS DB instances to set SQL Audit Log retention period to be greater than set days limit', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeSQLCollectorRetention', 'STS:GetCallerIdentity'], + settings: { + sqlAuditRetentionPeriod: { + name: 'SQL Audit Retention Period ', + description: 'Number of days for which SQL Audit logs will be retained', + regex: '^(30|180|365|1095|1825)$', + default: '180', + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var sqlAuditRetentionPeriod = parseInt(settings.sqlAuditRetentionPeriod || this.settings.sqlAuditRetentionPeriod.default); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + var describeSqlAuditRetention = helpers.addSource(cache, source, + ['rds', 'DescribeSQLCollectorRetention', region, instance.DBInstanceId]); + + if (!describeSqlAuditRetention || describeSqlAuditRetention.err || !describeSqlAuditRetention.data) { + helpers.addResult(results, 3, + `Unable to query DB sql audit log retention: ${helpers.addError(describeSqlAuditRetention)}`, + region, resource); + return cb(); + } + + const currentRetentionPeriod = describeSqlAuditRetention.data.ConfigValue ? + parseInt(describeSqlAuditRetention.data.ConfigValue) : 0; + + if (currentRetentionPeriod >= sqlAuditRetentionPeriod) { + helpers.addResult(results, 0, + `RDS DB instance sql audit log retention is ${currentRetentionPeriod} which is greater than or equal to ${sqlAuditRetentionPeriod} days`, + region, resource); + } else { + helpers.addResult(results, 2, + `RDS DB instance sql audit log retention is ${currentRetentionPeriod} which is lesser than ${sqlAuditRetentionPeriod} days`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.spec.js b/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.spec.js new file mode 100644 index 000000000..c514b2864 --- /dev/null +++ b/plugins/alibaba/rds/rdsSqlAuditRetentionPeriod.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var rdsSqlAuditRetentionPeriod = require('./rdsSqlAuditRetentionPeriod.js'); + +const describeDBInstances = [ + { + "EngineVersion": "13.0", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "PostgreSQL" + }, + { + "EngineVersion": "13.0", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "MySQL" + } +]; + +const describeSqlCollectorRetention = [ + { + "ConfigValue": "30", + "RequestId": "1C8130FE-6388-4EA8-90B0-9C0541D3D37F" + }, + { + "ConfigValue": "1095", + "RequestId": "1C8130FE-6388-4EA8-90B0-9C0541D3D37F" + } +]; + +const createCache = (describeDBInstances, describeSqlCollectorRetention, describeDBInstancesErr, describeSqlCollectorRetentionErr) => { + let instanceId = (describeDBInstances && describeDBInstances.length) ? describeDBInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: describeDBInstances, + err: describeDBInstancesErr + }, + }, + DescribeSQLCollectorRetention: { + 'cn-hangzhou': { + [instanceId]: { + data: describeSqlCollectorRetention, + err: describeSqlCollectorRetentionErr + } + } + } + }, + }; +}; + +describe('rdsSqlAuditRetentionPeriod', function () { + describe('run', function () { + it('should FAIL if RDS DB instance does not have sql audit log retention greater than 180 days', function (done) { + const cache = createCache(describeDBInstances, describeSqlCollectorRetention[0]); + rdsSqlAuditRetentionPeriod.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].message).to.include('is lesser than 180 days'); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it(`should FAIL if RDS DB instance does not have sql audit log retention greater than set days limit`, function (done) { + const cache = createCache(describeDBInstances, describeSqlCollectorRetention[0]); + rdsSqlAuditRetentionPeriod.run(cache, { sqlAuditRetentionPeriod: '300', china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].message).to.include(`is lesser than`); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance have sql audit log retention greater than 180 days', function (done) { + const cache = createCache(describeDBInstances, describeSqlCollectorRetention[1]); + rdsSqlAuditRetentionPeriod.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].message).to.include('is greater than'); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it(`should PASS if RDS DB instance have sql audit log retention greater than set days limit`, function (done) { + const cache = createCache(describeDBInstances, describeSqlCollectorRetention[1]); + rdsSqlAuditRetentionPeriod.run(cache, { sqlAuditRetentionPeriod: '365', china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].message).to.include(`is greater than or equal to`); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsSqlAuditRetentionPeriod.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsSqlAuditRetentionPeriod.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query DB sql audit log retention', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query DB sql audit log retention' }); + rdsSqlAuditRetentionPeriod.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query DB sql audit log retention'); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsSslEncryptionEnabled.js b/plugins/alibaba/rds/rdsSslEncryptionEnabled.js new file mode 100644 index 000000000..a9e0ee1c3 --- /dev/null +++ b/plugins/alibaba/rds/rdsSslEncryptionEnabled.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS SSL Encryption Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure that RDS instances enforce all incoming connections to use SSL.', + more_info: 'To enhance link security, you should enable Secure Sockets Layer (SSL) encryption for RDS instances. ' + + 'SSL is used on the transport layer to encrypt network connections. SSL not only increases the security and integrity of communication data, but also increases the response time for network connection.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/32474.htm', + recommended_action: 'Enable SSL ecnryption for RDS instances', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeDBInstanceSSL', 'STS:GetCallerIdentity'], + compliance: { + hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + + 'RDS SSL connection should be used to ensure internal ' + + 'services are always connecting over a secure channel.', + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var instanceSslInfo = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstanceSSL', region, instance.DBInstanceId]); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + if (!instanceSslInfo || instanceSslInfo.err || !instanceSslInfo.data) { + helpers.addResult(results, 3, + `Unable to query RDS instance SSL info: ${helpers.addError(instanceSslInfo)}`, + region, resource); + return cb(); + } + + if (instanceSslInfo.data.RequireUpdate && instanceSslInfo.data.RequireUpdate.toLowerCase() == 'yes') { + helpers.addResult(results, 0, + 'RDS instance has SSL encryption enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'RDS instance does not have SSL encryption enabled', region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsSslEncryptionEnabled.spec.js b/plugins/alibaba/rds/rdsSslEncryptionEnabled.spec.js new file mode 100644 index 000000000..0573b2720 --- /dev/null +++ b/plugins/alibaba/rds/rdsSslEncryptionEnabled.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var rdsSslEncryptionEnabled = require('./rdsSslEncryptionEnabled.js'); + +const describeDBInstances = [ + { + "EngineVersion": "13.0", + "DBInstanceStatus": "Running", + "ResourceGroupId": "rg-aekzsj44b4lt5fa", + "DBInstanceNetType": "Intranet", + "DBInstanceClass": "pg.n2.small.2c", + "CreateTime": "2021-05-04T17:13:45Z", + "VSwitchId": "vsw-rj94uhhrj5qz5008lwi1x", + "DBInstanceType": "Primary", + "PayType": "Postpaid", + "LockMode": "Unlock", + "MutriORsignle": false, + "InstanceNetworkType": "VPC", + "InsId": 1, + "VpcId": "vpc-rj9vu86hdve3qr173ew17", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "ConnectionMode": "Standard", + "ReadOnlyDBInstanceIds": { + "ReadOnlyDBInstanceId": [] + }, + "VpcCloudInstanceId": "pgm-2ev213kfnogf7mfi", + "ExpireTime": "", + "LockReason": "", + "Engine": "PostgreSQL" + } +]; + +const describeDBInstanceSSL = [ + { + "SSLExpireTime": "", + "RequestId": "B61DFDF9-627C-41BD-81C6-5DF77D2A63ED", + "RequireUpdateReason": "", + "ConnectionString": "", + "RequireUpdate": "Yes" + }, + { + "SSLExpireTime": "", + "RequestId": "B61DFDF9-627C-41BD-81C6-5DF77D2A63ED", + "RequireUpdateReason": "", + "ConnectionString": "", + "RequireUpdate": "No" + } +]; + +const createCache = (dbInstances, dbSslData, dbInstancesErr, dbSslErr) => { + let instanceId = (dbInstances && dbInstances.length) ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: dbInstances, + err: dbInstancesErr + }, + }, + DescribeDBInstanceSSL: { + 'cn-hangzhou': { + [instanceId]: { + data: dbSslData, + err: dbSslErr + } + } + } + }, + }; +}; + +describe('rdsSslEncryptionEnabled', function () { + describe('run', function () { + it('should FAIL if RDS instance does not have SSL encryption enabled', function (done) { + const cache = createCache([describeDBInstances[0]], describeDBInstanceSSL[1]); + rdsSslEncryptionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RDS instance does not have SSL encryption enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS instance has SSL encryption enabled', function (done) { + const cache = createCache([describeDBInstances[0]], describeDBInstanceSSL[0]); + rdsSslEncryptionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RDS instance has SSL encryption enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsSslEncryptionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsSslEncryptionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS instance SSL info', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query RDS instance SSL info' }); + rdsSslEncryptionEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS instance SSL info'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsTdeEnabled.js b/plugins/alibaba/rds/rdsTdeEnabled.js new file mode 100644 index 000000000..572b3d412 --- /dev/null +++ b/plugins/alibaba/rds/rdsTdeEnabled.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'RDS Transparent Data Encryption Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure that RDS instances have Transparent Data Encryption enabled.', + more_info: 'TDE should be enabled to protect against the threat of malicious activities. Real-time encryption and decryption of the database,' + + 'associated backups, and log files is performed at rest without requiring any change to the application.', + link: 'https://partners-intl.aliyun.com/help/doc-detail/26256.htm', + recommended_action: 'Enable TDE for RDS instances', + apis: ['RDS:DescribeDBInstances', 'RDS:DescribeDBInstanceTDE', 'STS:GetCallerIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var supportedEngines = ['sqlserver 2012_ent_ag', 'sqlserver 2016_ent_ag', 'sqlserver 2017_ent_ag', 'sqlserver 2019_ent_ag', 'mysql 5.6']; + var accountId = helpers.addSource(cache, source, ['sts', 'GetCallerIdentity', defaultRegion, 'data']); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found', region); + return rcb(); + } + + async.each(describeDBInstances.data, function(instance, cb){ + if (!instance.DBInstanceId) return cb(); + + var resource = helpers.createArn('rds', accountId, 'instance', instance.DBInstanceId, region); + + let instanceEngine = (instance.Engine && instance.EngineVersion) ? `${instance.Engine.toLowerCase()} ${instance.EngineVersion}` : ''; + + if (!supportedEngines.includes(instanceEngine)) { + helpers.addResult(results, 0, + `TDE is not supported for ${instanceEngine} engine type`, + region, resource); + return cb(); + } + + var describeDbInstanceTde = helpers.addSource(cache, source, + ['rds', 'DescribeDBInstanceTDE', region, instance.DBInstanceId]); + + if (!describeDbInstanceTde || describeDbInstanceTde.err || !describeDbInstanceTde.data) { + helpers.addResult(results, 3, + `Unable to query RDS DB instance TDE: ${helpers.addError(describeDbInstanceTde)}`, + region, resource); + return cb(); + } + + if (describeDbInstanceTde.data.TDEStatus && describeDbInstanceTde.data.TDEStatus.toUpperCase() == 'ENABLED') { + helpers.addResult(results, 0, + 'RDS DB instance has TDE enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'RDS DB instance does not have TDE enabled', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/rds/rdsTdeEnabled.spec.js b/plugins/alibaba/rds/rdsTdeEnabled.spec.js new file mode 100644 index 000000000..dc66d140d --- /dev/null +++ b/plugins/alibaba/rds/rdsTdeEnabled.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var rdsTdeEnabled = require('./rdsTdeEnabled.js'); + +const describeDBInstances = [ + { + "EngineVersion": "5.6", + "DBInstanceId": "rm-7go88dw3m8uw51ayj", + "Engine": "MySQL" + }, + { + "EngineVersion": "13.0", + "DBInstanceId": "pgm-2ev213kfnogf7mfi", + "Engine": "PostgreSQL" + } +]; + +const describeDBTde = [ + { + TDEStatus: 'Enabled' + }, + { + TDEStatus: 'Disabled' + } +]; + +const createCache = (dbInstances, describeDBTde, dbInstancesErr, describeDBTdeErr) => { + let instanceId = (dbInstances && dbInstances.length) ? dbInstances[0].DBInstanceId : null; + return { + rds: { + DescribeDBInstances: { + 'cn-hangzhou': { + data: dbInstances, + err: dbInstancesErr + }, + }, + DescribeDBInstanceTDE: { + 'cn-hangzhou': { + [instanceId]: { + data: describeDBTde, + err: describeDBTdeErr + } + } + } + }, + }; +}; + +describe('rdsTdeEnabled', function () { + describe('run', function () { + it('should FAIL if RDS DB instance does not have TDE enabled', function (done) { + const cache = createCache(describeDBInstances, describeDBTde[1]); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RDS DB instance does not have TDE enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance have TDE enabled', function (done) { + const cache = createCache(describeDBInstances, describeDBTde[0]); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RDS DB instance has TDE enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if RDS DB instance have engine type other MySQL 5.6 and SQL Server Enterprise Edition', function (done) { + const cache = createCache(describeDBInstances, describeDBTde[0]); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(1); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include(`TDE is not supported for postgresql 13.0 engine type`); + expect(results[1].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No RDS DB instances found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instances', function (done) { + const cache = createCache([], null, { err: 'Unable to query RDS DB instances' }); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instances'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if unable to query RDS DB instance TDE', function (done) { + const cache = createCache([describeDBInstances[0]], {}, null, { err: 'Unable to query RDS DB instance TDE' }); + rdsTdeEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query RDS DB instance TDE'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/securityAgentInstalled.js b/plugins/alibaba/securitycenter/securityAgentInstalled.js new file mode 100644 index 000000000..ca0205d29 --- /dev/null +++ b/plugins/alibaba/securitycenter/securityAgentInstalled.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Security Agent Installed', + category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that all assets are configured to be installed with Security Agent.', + more_info: 'Security center provides a set of comprehensive endpoint intrusion detection and protection capabilities, ' + + 'such as remote logon detection, webshell detection and removal, anomaly detection, and detection of changes in key files and suspicious accounts in systems and applications. ' + + 'This requires an agent to be installed on the endpoint to work.', + link: 'https://www.alibabacloud.com/help/doc-detail/111650.htm', + recommended_action: 'Go to Security Center console, select Settings, click Agent, on Client to be installed tab, select all items, ' + + 'and click on One-click installation.', + apis: ['TDS:DescribeFieldStatistics'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.tds, function(region, rcb) { + var describeFieldStatistics = helpers.addSource(cache, source, + ['tds', 'DescribeFieldStatistics', region]); + + if (!describeFieldStatistics) { + return rcb(); + } + + if (describeFieldStatistics.err || !describeFieldStatistics.data) { + helpers.addResult(results, 3, + `Unable to query TDS field statistics: ${helpers.addError(describeFieldStatistics)}`, + region); + return rcb(); + } + + if (describeFieldStatistics.data.UnprotectedInstanceCount) { + let msg = (describeFieldStatistics.data.UnprotectedInstanceCount == 1) ? + 'There is 1 unprotected asset' : `There are ${describeFieldStatistics.data.UnprotectedInstanceCount} unprotected assets`; + helpers.addResult(results, 2, + msg, region); + } else { + helpers.addResult(results, 0, + 'There are no unprotected assets', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/securityAgentInstalled.spec.js b/plugins/alibaba/securitycenter/securityAgentInstalled.spec.js new file mode 100644 index 000000000..5230da7f8 --- /dev/null +++ b/plugins/alibaba/securitycenter/securityAgentInstalled.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var securityAgentInstalled = require('./securityAgentInstalled.js'); + +const describeFieldStatistics = [ + { + "GroupCount": 1, + "ExposedInstanceCount": 0, + "RiskInstanceCount": 0, + "TencentInstanceCount": 0, + "InstanceSyncTaskCount": 0, + "ImportantAssetCount": 0, + "GeneralAssetCount": 1, + "IdcInstanceCount": 0, + "TestAssetCount": 0, + "UnprotectedInstanceCount": 0, + "InstanceCount": 1, + "OfflineInstanceCount": 1, + "VpcCount": 1, + "AliYunInstanceCount": 1, + "RegionCount": 1 + }, + { + "GroupCount": 1, + "ExposedInstanceCount": 0, + "RiskInstanceCount": 0, + "TencentInstanceCount": 0, + "InstanceSyncTaskCount": 0, + "ImportantAssetCount": 0, + "GeneralAssetCount": 1, + "IdcInstanceCount": 0, + "TestAssetCount": 0, + "UnprotectedInstanceCount": 2, + "InstanceCount": 1, + "OfflineInstanceCount": 1, + "VpcCount": 1, + "AliYunInstanceCount": 1, + "RegionCount": 1 + } +]; + +const createCache = (describeFieldStatistics, describeFieldStatisticsErr) => { + return { + tds: { + DescribeFieldStatistics: { + 'cn-hangzhou': { + data: describeFieldStatistics, + err: describeFieldStatisticsErr + }, + } + } + }; +}; + +describe('securityAgentInstalled', function () { + describe('run', function () { + it('should FAIL if there are unprotected assets', function (done) { + const cache = createCache(describeFieldStatistics[1]); + securityAgentInstalled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('There are 2 unprotected assets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if there are no unprotected assets', function (done) { + const cache = createCache(describeFieldStatistics[0]); + securityAgentInstalled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no unprotected assets'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query TDS field statistics', function (done) { + const cache = createCache([], { err: 'Unable to query TDS field statictics' }); + securityAgentInstalled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query TDS field statistics'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/securityCenterEdition.js b/plugins/alibaba/securitycenter/securityCenterEdition.js new file mode 100644 index 000000000..254d116da --- /dev/null +++ b/plugins/alibaba/securitycenter/securityCenterEdition.js @@ -0,0 +1,61 @@ +var helpers = require('../../../helpers/alibaba'); +const async = require('async'); + +module.exports = { + title: 'Security Center Edition', + category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that your cloud Security Center edition is Advanced or plus.', + more_info: 'Premium Security Center editions like Advanced or Enterprise Edition provides crucial features liekthreat detection for network and endpoints, ' + + 'providing malware detection, webshell detection and anomaly detection in Security Center.', + link: 'https://www.alibabacloud.com/help/product/28498.htm', + recommended_action: 'Upgrade your Security Center edition to at least Advanced.', + apis: ['TDS:DescribeVersionConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + // Below map might not be accurate as I checked with Anti-virus and Advanced editions and API is returning + // 6 and 5 respectively against the version key. As it will be costly to try all editions to get the acrual + // version key for all editions, I am taking an assumption and assuming this map so in future if there is + // a contradict, this might be the issue. + var versionIdNameMap = { + 1: 'Basic', + 2: 'Value-added Plan', + 3: 'Ultimate', + 4: 'Enterprise', + 5: 'Advanced', + 6: 'Anti-virus' + }; + async.each(regions.tds, function(region, rcb) { + var describeVersionConfig = helpers.addSource(cache, source, + ['tds', 'DescribeVersionConfig', region]); + + if (!describeVersionConfig) { + return rcb(); + } + + if (describeVersionConfig.err || !describeVersionConfig.data) { + helpers.addResult(results, 3, + `Unable to query Security Center version config: ${helpers.addError(describeVersionConfig)}`, + region); + return rcb(); + } + + let securityVersion = describeVersionConfig.data.Version ? describeVersionConfig.data.Version : 1; + + if (securityVersion == 1 || securityVersion == 6) { + helpers.addResult(results, 2, `Security Center edition is ${versionIdNameMap[securityVersion]}`, region); + } else { + helpers.addResult(results, 0, `Security Center edition is ${versionIdNameMap[securityVersion]}`, region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/securitycenter/securityCenterEdition.spec.js b/plugins/alibaba/securitycenter/securityCenterEdition.spec.js new file mode 100644 index 000000000..54941dc75 --- /dev/null +++ b/plugins/alibaba/securitycenter/securityCenterEdition.spec.js @@ -0,0 +1,119 @@ +var expect = require('chai').expect; +var securityCenterEdition = require('./securityCenterEdition.js'); + +const describeVersionConfig = [ + { + "IsPaidUser": true, + "ImageScanCapacity": 0, + "AppWhiteListAuthCount": 0, + "SasLog": 0, + "Version": 5, + "AvdsFlag": 1, + "HighestVersion": 5, + "WebLockAuthCount": 0, + "SlsCapacity": 0, + "UserDefinedAlarms": 0, + "AllowPartialBuy": 0, + "WebLock": 0, + "IsOverBalance": false, + "VmCores": 1, + "HoneypotCapacity": 0, + "RequestId": "0C1856EE-8FF4-54A0-82AC-F18F63873B38", + "AssetLevel": 1, + "InstanceId": "sas-cqtrivxc8k3b", + "LastInstanceReleaseTime": 1636473600000, + "CreateTime": 1633794973000, + "SasScreen": 0, + "IsSasOpening": false, + "LogCapacity": 50, + "Flag": 1, + "MVAuthCount": 0, + "GmtCreate": 1633791804000, + "ReleaseTime": 1636473600000, + "IsTrialVersion": 0, + "MVUnusedAuthCount": 0, + "AppWhiteList": 0 + }, + { + "IsPaidUser": true, + "ImageScanCapacity": 0, + "AppWhiteListAuthCount": 0, + "SasLog": 0, + "Version": 6, + "AvdsFlag": 1, + "HighestVersion": 6, + "WebLockAuthCount": 0, + "SlsCapacity": 0, + "UserDefinedAlarms": 0, + "AllowPartialBuy": 0, + "WebLock": 0, + "IsOverBalance": false, + "VmCores": 1, + "HoneypotCapacity": 0, + "RequestId": "0C1856EE-8FF4-54A0-82AC-F18F63873B38", + "AssetLevel": 1, + "InstanceId": "sas-cqtrivxc8k3b", + "LastInstanceReleaseTime": 1636473600000, + "CreateTime": 1633794973000, + "SasScreen": 0, + "IsSasOpening": false, + "LogCapacity": 50, + "Flag": 1, + "MVAuthCount": 0, + "GmtCreate": 1633791804000, + "ReleaseTime": 1636473600000, + "IsTrialVersion": 0, + "MVUnusedAuthCount": 0, + "AppWhiteList": 0 + } +]; + +const createCache = (describeVersionConfig, describeVersionConfigErr) => { + return { + tds: { + DescribeVersionConfig: { + 'cn-hangzhou': { + data: describeVersionConfig, + err: describeVersionConfigErr + }, + } + } + }; +}; + +describe('securityCenterEdition', function () { + describe('run', function () { + it('should FAIL if Security Center edition is Basic or Anti-virus', function (done) { + const cache = createCache(describeVersionConfig[1]); + securityCenterEdition.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security Center edition is Anti-virus'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if Security Center edition is Advanced or plus', function (done) { + const cache = createCache(describeVersionConfig[0]); + securityCenterEdition.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security Center edition is Advanced'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query Security Center version config', function (done) { + const cache = createCache([], { err: 'Unable to query' }); + securityCenterEdition.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Security Center version config'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/securityNotificationsEnabled.js b/plugins/alibaba/securitycenter/securityNotificationsEnabled.js new file mode 100644 index 000000000..737622078 --- /dev/null +++ b/plugins/alibaba/securitycenter/securityNotificationsEnabled.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Security Notifications Enabled', + category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that notifications are enabled for all risk items in Vulnerability, Baseline Risks, Alerts and Accesskey Leak event detection categories.', + more_info: 'Alibaba Cloud sends notification via email, SMS or internal message whenever security events happen. ' + + 'Enable notifications for security aletrs in order to receive notifications as soon as security events happens.', + link: 'https://www.alibabacloud.com/help/doc-detail/111648.htm', + recommended_action: 'Enable email, SMS or internal message notifications under Security Center settings.', + apis: ['TDS:DescribeNoticeConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var securityItemsMap = { + 'yundun_security_Weekreport': 'Vulnerabilities', + 'weeklyreport': 'Baseline Risks', + 'sas_suspicious': 'Alerts', + 'yundun_aegis_AV_true': 'Precision Defense', + 'yundun_sas_ak_leakage': 'AccessKey leakage info', + 'yundun_sas_config_alert': 'Config Assessment', + 'yundun_sas_vul_Emergency': 'Emergency Vul Intelligence', + 'yundun_webguard_event': 'Anti-Tampering of web pages', + 'yundun_sas_cloud_native_firewall_Defense': 'Container firewall proactive defense notification', + 'yundun_sas_cloud_native_firewall': 'Container firewall exception alert notification', + 'yundun_IP_Blocking': 'Malicious IP interception alert' + }; + + var emailOnlyAlerts = ['yundun_IP_Blocking', 'yundun_sas_cloud_native_firewall', 'yundun_sas_cloud_native_firewall_Defense']; + + async.each(regions.tds, function(region, rcb) { + var describeNoticeConfig = helpers.addSource(cache, source, + ['tds', 'DescribeNoticeConfig', region]); + + if (!describeNoticeConfig) { + return rcb(); + } + + if (describeNoticeConfig.err || !describeNoticeConfig.data) { + helpers.addResult(results, 3, + `Unable to query TDS notice config: ${helpers.addError(describeNoticeConfig)}`, + region); + return rcb(); + } + + if (!describeNoticeConfig.data.length) { + helpers.addResult(results, 0, 'No TDS notice config found', region); + return rcb(); + } + + var disabledConfigs = []; + for (let config of describeNoticeConfig.data) { + if ((config.Project && securityItemsMap[config.Project] && !config.Route) || + (config.Project && emailOnlyAlerts.includes(config.Project) && (!config.Route || config.Route == 5))) + disabledConfigs.push(securityItemsMap[config.Project]); + } + + if (disabledConfigs.length) { + helpers.addResult(results, 2, + `Security notifications are not enabled for: ${disabledConfigs.join(', ')}`, region); + } else { + helpers.addResult(results, 0, + 'Security notifications are enabled for all alerts', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/securityNotificationsEnabled.spec.js b/plugins/alibaba/securitycenter/securityNotificationsEnabled.spec.js new file mode 100644 index 000000000..d4163dfb6 --- /dev/null +++ b/plugins/alibaba/securitycenter/securityNotificationsEnabled.spec.js @@ -0,0 +1,259 @@ +var expect = require('chai').expect; +var securityNotificationsEnabled = require('./securityNotificationsEnabled.js'); + +const describeNoticeConfig = [ + [ + { + "Project": "yundun_sas_cloud_native_firewall_Defense", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_cloud_native_firewall", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_aegis_AV_true", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_security_Weekreport", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 2, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_vul_Emergency", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 2, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_webguard_event", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_config_alert", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_ak_leakage", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "sas_suspicious", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "sas_healthcheck", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "sas_vulnerability", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "weeklyreport", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "agent", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_anti_Virus", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_IP_Blocking", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "bruteforcesuccess", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "webshell", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "suspicious", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "patch", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "virusScheduleTask", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "remotelogin", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "health", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 7, + "AliUid": "5103119194921620" + } + ], + [ + { + "Project": "yundun_sas_cloud_native_firewall_Defense", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 0, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_cloud_native_firewall", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 0, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_aegis_AV_true", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 7, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_security_Weekreport", + "CurrentPage": 1, + "TimeLimit": 1, + "Route": 2, + "AliUid": "5103119194921620" + }, + { + "Project": "yundun_sas_vul_Emergency", + "CurrentPage": 1, + "TimeLimit": 0, + "Route": 2, + "AliUid": "5103119194921620" + } + ] +]; + +const createCache = (describeNoticeConfig, describeNoticeConfigErr) => { + return { + tds: { + DescribeNoticeConfig: { + 'cn-hangzhou': { + data: describeNoticeConfig, + err: describeNoticeConfigErr + }, + } + } + }; +}; + +describe('securityNotificationsEnabled', function () { + describe('run', function () { + it('should FAIL if security notifications are not enabled', function (done) { + const cache = createCache(describeNoticeConfig[1]); + securityNotificationsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security notifications are not enabled'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if security notifications are enabled for all alerts', function (done) { + const cache = createCache(describeNoticeConfig[0]); + securityNotificationsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security notifications are enabled for all alerts'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no TDS notice config found', function (done) { + const cache = createCache([]); + securityNotificationsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No TDS notice config found'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query TDS notice config', function (done) { + const cache = createCache([], { err: 'Unable to query TDS notice config' }); + securityNotificationsEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query TDS notice config'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/alibaba/securitycenter/vulnerabilityScanEnabled.js b/plugins/alibaba/securitycenter/vulnerabilityScanEnabled.js new file mode 100644 index 000000000..5e6344c71 --- /dev/null +++ b/plugins/alibaba/securitycenter/vulnerabilityScanEnabled.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/alibaba'); + +module.exports = { + title: 'Vulnerability Scan Enabled', + category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that scheduled vulnerability scan is enabled on all servers.', + more_info: 'Ensure that vulnerability scan is performed periodically to discover system vulnerabilities in time.', + link: 'https://www.alibabacloud.com/help/en/security-center/user-guide/view-and-handle-vulnerabilities', + recommended_action: 'Ensure scheduled vulnerability scan is enabled on all servers by applying all types of vulnerabilities with High and Medium scan levels enabled.', + apis: ['TDS:DescribeVulConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.tds, function(region, rcb) { + var describeVulConfig = helpers.addSource(cache, source, + ['tds', 'DescribeVulConfig', region]); + + if (!describeVulConfig) { + return rcb(); + } + + if ( describeVulConfig.err || ! describeVulConfig.data) { + helpers.addResult(results, 3, + `Unable to query TDS vulnerability config: ${helpers.addError( describeVulConfig)}`, + region); + return rcb(); + } + + if (!describeVulConfig.data.TargetConfigs) { + helpers.addResult(results, 0, 'No TDS vulnerability config', region); + return rcb(); + } + + var hasDisabledConfig = describeVulConfig.data.TargetConfigs.some(config => config.Config === 'off'); + + if (hasDisabledConfig) { + helpers.addResult(results, 2, 'Vulnerability scan is not enabled for all servers', region); + } else { + helpers.addResult(results, 0, 'Vulnerability scan is enabled for all servers', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/alibaba/securitycenter/vulnnerabilityScanEnabled.spec.js b/plugins/alibaba/securitycenter/vulnnerabilityScanEnabled.spec.js new file mode 100644 index 000000000..81e74a772 --- /dev/null +++ b/plugins/alibaba/securitycenter/vulnnerabilityScanEnabled.spec.js @@ -0,0 +1,141 @@ +var expect = require('chai').expect; +var vulnerabilityScanEnabled = require('./vulnerabilityScanEnabled.js'); + +var describeVulConfig =[ + { + "TotalCount": 8, + "TargetConfigs": [ + { + "Type": "app", + "Config": "on" + }, + { + "Type": "yum", + "Config": "on" + }, + { + "Type": "cve", + "Config": "on" + }, + { + "Type": "imageVulClean", + "Config": "90" + }, + { + "Type": "cms", + "Config": "on" + }, + { + "Type": "scanMode", + "Config": "all" + }, + { + "Type": "sys", + "Config": "on" + }, + { + "Type": "emg", + "Config": "on" + } + ], + "RequestId": "C78D1769-D3F3-5395-A6D1-08F547F1FDB3" + }, + { + "TotalCount": 8, + "TargetConfigs": [ + { + "Type": "app", + "Config": "on" + }, + { + "Type": "yum", + "Config": "on" + }, + { + "Type": "cve", + "Config": "off" + }, + { + "Type": "imageVulClean", + "Config": "90" + }, + { + "Type": "cms", + "Config": "off" + }, + { + "Type": "scanMode", + "Config": "all" + }, + { + "Type": "sys", + "Config": "on" + }, + { + "Type": "emg", + "Config": "on" + } + ], + "RequestId": "C78D1769-D3F3-5395-A6D1-08F547F1FDB3" + } +]; + +const createCache = (describeVulConfig, describeVulConfigErr) => { + return { + tds: { + DescribeVulConfig: { + 'cn-hangzhou': { + data: describeVulConfig, + err: describeVulConfigErr + }, + } + } + }; +}; + +describe('vulnerabilityScanEnabled', function () { + describe('run', function () { + it('should FAIL if Vulnerability scan is not enabled on all servers', function (done) { + const cache = createCache(describeVulConfig[1]); + vulnerabilityScanEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Vulnerability scan is not enabled for all servers'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + it('should PASS if Vulnerability scan is enabled on all servers', function (done) { + const cache = createCache(describeVulConfig[0]); + vulnerabilityScanEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Vulnerability scan is enabled for all servers'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should PASS if no vulnerabity config found ', function (done) { + const cache = createCache([]); + vulnerabilityScanEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No TDS vulnerability config'); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query ', function (done) { + const cache = createCache([], { err: 'Unable to query TDS vul config' }); + vulnerabilityScanEnabled.run(cache, { china: true }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query TDS vulnerability config: '); + expect(results[0].region).to.equal('cn-hangzhou'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/.DS_Store b/plugins/aws/.DS_Store new file mode 100644 index 000000000..9b9b4dfd8 Binary files /dev/null and b/plugins/aws/.DS_Store differ diff --git a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js new file mode 100644 index 000000000..ca801a7e3 --- /dev/null +++ b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Access Analyzer Active Findings', + category: 'IAM', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that IAM Access analyzer findings are reviewed and resolved by taking all necessary actions.', + more_info: 'IAM Access Analyzer helps you evaluate access permissions across your AWS cloud environment and gives insights into intended access to your resources. It can monitor the access policies associated with S3 buckets, KMS keys, SQS queues, IAM roles and Lambda functions for permissions changes. ' + + 'You can view IAM Access Analyzer findings at any time. Work through all of the findings in your account until you have zero active findings.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-work-with-findings.html', + recommended_action: 'Investigate into active findings in your account and do the needful until you have zero active findings.', + apis: ['AccessAnalyzer:listAnalyzers', 'AccessAnalyzer:listFindings', 'AccessAnalyzer:listFindingsV2'], + realtime_triggers: ['accessanalyzer:CreateAnalyzer','accessanalyzer:DeleteAnalyzer','accessanalyzer:CreateArchiveRule','accessanalyzer:StartResourceScan'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.accessanalyzer, function(region, rcb){ + var listAnalyzers = helpers.addSource(cache, source, + ['accessanalyzer', 'listAnalyzers', region]); + + if (!listAnalyzers) return rcb(); + + if (listAnalyzers.err || !listAnalyzers.data) { + helpers.addResult(results, 3, + `Unable to query for IAM Access Analyzer analyzers: ${helpers.addError(listAnalyzers)}`, region); + return rcb(); + } + + if (!listAnalyzers.data.length) { + helpers.addResult(results, 0, 'No IAM Access Analyzer analyzers found', region); + return rcb(); + } + + for (let analyzer of listAnalyzers.data) { + if (!analyzer.arn) continue; + + let resource = analyzer.arn; + let totalFiltered = []; + + var listFindings = helpers.addSource(cache, source, + ['accessanalyzer', 'listFindings', region, analyzer.arn]); + + if (listFindings && !listFindings.err && listFindings.data) { + let filtered = listFindings.data.findings.filter(finding => finding.status === 'ACTIVE'); + totalFiltered = totalFiltered.concat(filtered); + } + + var listFindingsV2 = helpers.addSource(cache, source, + ['accessanalyzer', 'listFindingsV2', region, analyzer.arn]); + + if (listFindingsV2 && !listFindingsV2.err && listFindingsV2.data) { + let filteredv2 = listFindingsV2.data.findings.filter(finding => finding.status === 'ACTIVE'); + totalFiltered = totalFiltered.concat(filteredv2); + } + + if ((!listFindings || listFindings.err || !listFindings.data) && (!listFindingsV2 || listFindingsV2.err || !listFindingsV2.data)) { + helpers.addResult(results, 3, + `Unable to IAM Access Analyzer findings: ${helpers.addError(listFindings)} ${helpers.addError(listFindingsV2)}`, + region, resource); + continue; + } + + if (!totalFiltered.length) { + helpers.addResult(results, 0, + 'Amazon IAM Access Analyzer has no active findings', + region, resource); + } else { + helpers.addResult(results, 2, + 'Amazon IAM Access Analyzer has active findings', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js new file mode 100644 index 000000000..649f0572d --- /dev/null +++ b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js @@ -0,0 +1,312 @@ +var expect = require('chai').expect; +var accessAnalyzerActiveFindings = require('./accessAnalyzerActiveFindings'); + +const listAnalyzers = [ + { + "arn": "arn:aws:access-analyzer:us-east-1:000011112222:analyzer/ConsoleAnalyzer-c1a86385-8c13-42cd-96bf-31b10d2050ca", + "createdAt": "2022-01-12T13:43:05+00:00", + "lastResourceAnalyzed": "arn:aws:sqs:us-east-1:000011112222:akhtarqueue", + "lastResourceAnalyzedAt": "2022-01-12T13:43:05.455000+00:00", + "name": "ConsoleAnalyzer-c1a86385-8c13-42cd-96bf-31b10d2050ca", + "status": "ACTIVE", + "tags": {}, + "type": "ACCOUNT" + } +]; + +const listFindings = [ + { + "findings": [ + { + "action": [ + "kms:RetireGrant" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "084f6bb6-a331-4c2d-87db-b2f595947ba0", + "isPublic": false, + "principal": { + "AWS": "108297888182" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "ACTIVE", + "updatedAt": "2022-01-12T13:43:05.433000+00:00" + }, + { + "action": [ + "kms:Decrypt", + "kms:Encrypt" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "ca358041-0222-4493-8288-e5f5f0c77d19", + "isPublic": false, + "principal": { + "AWS": "560213429563" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "ARCHIVED", + "updatedAt": "2022-01-12T13:48:20+00:00" + }, + { + "action": [ + "kms:Decrypt", + "kms:Encrypt" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "ca358041-0222-4493-8288-e5f5f0c77d19", + "isPublic": false, + "principal": { + "AWS": "560213429563" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "RESOLVED", + "updatedAt": "2022-01-12T13:48:20+00:00" + } + ] + }, + { + "findings": [ + { + "action": [ + "kms:RetireGrant" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "084f6bb6-a331-4c2d-87db-b2f595947ba0", + "isPublic": false, + "principal": { + "AWS": "108297888182" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "ARCHIVED", + "updatedAt": "2022-01-12T13:43:05.433000+00:00" + }, + { + "action": [ + "kms:Decrypt", + "kms:Encrypt" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "ca358041-0222-4493-8288-e5f5f0c77d19", + "isPublic": false, + "principal": { + "AWS": "560213429563" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "ARCHIVED", + "updatedAt": "2022-01-12T13:48:20+00:00" + }, + { + "action": [ + "kms:Decrypt", + "kms:Encrypt" + ], + "analyzedAt": "2022-01-12T13:43:05.433000+00:00", + "condition": {}, + "createdAt": "2022-01-12T13:43:05.433000+00:00", + "id": "ca358041-0222-4493-8288-e5f5f0c77d19", + "isPublic": false, + "principal": { + "AWS": "560213429563" + }, + "resource": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "resourceOwnerAccount": "000011112222", + "resourceType": "AWS::KMS::Key", + "status": "RESOLVED", + "updatedAt": "2022-01-12T13:48:20+00:00" + } + ] + } + +]; + +const listFindingsV2 = [ + { + "findings": [ + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "1a234567-bc6d-7yui-h5j7-4f5f9j8987y0", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-p1-AsdfghTfjdudnjkDkjg-Z9JgMyMzcxOZ", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "938r4848-4h4j-8449-76d8-8768dh5dhh4u", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-AsdfghTfjdudnjkDkjg-6vzrTVSqTaNe", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:55+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "7484f848-984j-498l-784s-yryh74748f45", + "resource": "arn:aws:iam::123456789123:role/service-role/sdfghyFj-FGH-njkkjg-plgd-6uhjn9ok", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedPermission" + }, + ] + }, + { + "findings": [ + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "1a234567-bc6d-7yui-h5j7-4f5f9j8987y0", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-p1-AsdfghTfjdudnjkDkjg-Z9JgMyMzcxOZ", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ARCHIVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "938r4848-4h4j-8449-76d8-8768dh5dhh4u", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-AsdfghTfjdudnjkDkjg-6vzrTVSqTaNe", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ARCHIVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:55+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "7484f848-984j-498l-784s-yryh74748f45", + "resource": "arn:aws:iam::123456789123:role/service-role/sdfghyFj-FGH-njkkjg-plgd-6uhjn9ok", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "RESOLVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedPermission" + }, + ] + } + +] + +const createCache = (analyzer, listFindings, analyzerErr, listFindingsErr) => { + var analyzerArn = (analyzer && analyzer.length) ? analyzer[0].arn: null; + return { + accessanalyzer: { + listAnalyzers: { + 'us-east-1': { + err: analyzerErr, + data: analyzer + }, + }, + listFindings: { + 'us-east-1': { + [analyzerArn]: { + data:listFindings, + err: listFindingsErr + } + } + } + }, + }; +}; + +describe('accessAnalyzerActiveFindings', function () { + describe('run', function () { + it('should FAIL if Amazon IAM access analyzer V1 has active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindings[0]); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon IAM Access Analyzer has active findings'); + done(); + }); + }); + + it('should FAIL if Amazon IAM access analyzer v2 has active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindingsV2[0]); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon IAM Access Analyzer has active findings'); + done(); + }); + }); + + it('should PASS if Amazon IAM access analyzer V1 have no active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindings[1]); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon IAM Access Analyzer has no active findings'); + + done(); + }); + }); + + + it('should PASS if Amazon IAM access analyzer V2 have no active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindingsV2[1]); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon IAM Access Analyzer has no active findings'); + + done(); + }); + }); + + it('should PASS if no analyzers found', function (done) { + const cache = createCache([]); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No IAM Access Analyzer analyzers found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for IAM access analyzers', function (done) { + const cache = createCache(null, null, { message: "Unable to query for IAM access analyzers" }); + accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for IAM Access Analyzer analyzers'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/accessanalyzer/accessAnalyzerEnabled.js b/plugins/aws/accessanalyzer/accessAnalyzerEnabled.js new file mode 100644 index 000000000..2b28ac683 --- /dev/null +++ b/plugins/aws/accessanalyzer/accessAnalyzerEnabled.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Access Analyzer Enabled', + category: 'IAM', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that IAM Access analyzer is enabled for all regions.', + more_info: 'Access Analyzer allow you to determine if an unintended user is allowed, making it easier for administrators to monitor least privileges access. It analyzes only policies that are applied to resources in the same AWS region.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html', + recommended_action: 'Enable Access Analyzer for all regions', + apis: ['AccessAnalyzer:listAnalyzers'], + realtime_triggers: ['accessanalyzer:CreateAnalyzer','accessanalyzer:DeleteAnalyzer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + async.each(regions.accessanalyzer, function(region, rcb){ + var listAnalyzers = helpers.addSource(cache, source, + ['accessanalyzer', 'listAnalyzers', region]); + + if (!listAnalyzers) return rcb(); + + if (listAnalyzers.err || !listAnalyzers.data) { + helpers.addResult(results, 3, + 'Unable to list Access Analyzers: ' + helpers.addError(listAnalyzers), region); + return rcb(); + } + + if (!listAnalyzers.data.length) { + helpers.addResult(results, 2, + 'Access Analyzer is not configured', region); + return rcb(); + } + + var found = listAnalyzers.data.find(analyzer => analyzer.status.toLowerCase() == 'active'); + if (found) { + helpers.addResult(results, 0, + 'Access Analyzer is enabled', region, found.arn); + } else { + helpers.addResult(results, 2, + 'Access Analyzer is not enabled', region); + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/accessanalyzer/accessAnalyzerEnabled.spec.js b/plugins/aws/accessanalyzer/accessAnalyzerEnabled.spec.js new file mode 100644 index 000000000..16f1a2c39 --- /dev/null +++ b/plugins/aws/accessanalyzer/accessAnalyzerEnabled.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect;; +var accessAnalyzerEnabled = require('./accessAnalyzerEnabled'); + +const listAnalyzers = [ + { + "arn": "arn:aws:access-analyzer:us-east-1:111111111111:analyzer/ConsoleAnalyzer-NVirginia", + "createdAt": "2021-07-26T11:09:04.000Z", + "lastResourceAnalyzed": "arn:aws:iam::111111111111:role/khulnasoft-cspm-security-scanner-KhulnasoftCSPMRole-1EQQWMHKF061P", + "lastResourceAnalyzedAt": "2021-07-26T11:09:04.992Z", + "name": "ConsoleAnalyzer-NVirginia", + "status": "ACTIVE", + "tags": {}, + "type": "ACCOUNT" + }, + { + "arn": "arn:aws:access-analyzer:us-east-1:111111111111:analyzer/ConsoleAnalyzer-NVirginia", + "createdAt": "2021-07-26T11:09:04.000Z", + "lastResourceAnalyzed": "arn:aws:iam::111111111111:role/khulnasoft-cspm-security-scanner-KhulnasoftCSPMRole-1EQQWMHKF061P", + "lastResourceAnalyzedAt": "2021-07-26T11:09:04.992Z", + "name": "ConsoleAnalyzer-NVirginia", + "status": "DISABLED", + "tags": {}, + "type": "ACCOUNT" + } +]; + +const createCache = (analyzers) => { + return { + accessanalyzer: { + listAnalyzers: { + "us-east-1": { + data: analyzers }, + } + } + } +} + +const createNullCache = () => { + return { + accessanalyzer: { + listAnalyzers: { + "us-east-1": { + data: null + } + } + } + } +} + +describe('accessAnalyzerEnabled', () => { + describe('run', () => { + it('should PASS if Access Analyzer is enabled', () => { + const cache = createCache([listAnalyzers[0]]); + accessAnalyzerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + it('should FAIL if Access Analyzer is not enabled', () => { + const cache = createCache([listAnalyzers[1]]); + accessAnalyzerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + it('should FAIL if Access Analyzer not configured', () => { + const cache = createCache([]); + accessAnalyzerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + it('should UNKNOWN if unable to list Access analyzer', () => { + const cache = createNullCache(); + accessAnalyzerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + }) + }); + it('should not return anything if list Access Analyzers response is not found', () => { + accessAnalyzerEnabled.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/acm/acmCertificateExpiry.js b/plugins/aws/acm/acmCertificateExpiry.js index 1b37212ab..0a293b07f 100644 --- a/plugins/aws/acm/acmCertificateExpiry.js +++ b/plugins/aws/acm/acmCertificateExpiry.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ACM Certificate Expiry', category: 'ACM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Detect upcoming expiration of ACM certificates', more_info: 'Certificates that have expired will trigger warnings in all major browsers. AWS will attempt to automatically renew the certificate but may be unable to do so if email or DNS validation cannot be confirmed.', link: 'https://docs.aws.amazon.com/acm/latest/userguide/managed-renewal.html', @@ -26,6 +28,7 @@ module.exports = { default: 30 } }, + realtime_triggers: ['acm:RequestCertificate','acm:ImportCertificate','acm:DeleteCertificate'], run: function(cache, settings, callback) { var config = { @@ -80,24 +83,18 @@ module.exports = { var then = new Date(certificate.NotAfter); var now = new Date(); - var difference = helpers.daysBetween(then, now); + var difference = Math.round((new Date(then).getTime() - new Date(now).getTime())/(24*60*60*1000)); var expiresInMsg = 'Certificate for domain: ' + certificate.DomainName + ' expires in ' + Math.abs(difference) + ' days'; var expiredMsg = 'Certificate: for domain: ' + certificate.DomainName + ' expired ' + Math.abs(difference) + ' days ago'; - // Expired already - if (then < now) { - helpers.addResult(results, 2, expiredMsg, region, certificate.CertificateArn); + if (difference > config.acm_certificate_expiry_pass) { + helpers.addResult(results, 0, expiresInMsg, region, certificate.CertificateArn, custom); + } else if (difference > config.acm_certificate_expiry_warn) { + helpers.addResult(results, 1, expiresInMsg, region, certificate.CertificateArn, custom); + } else if (difference > 0) { + helpers.addResult(results, 2, expiresInMsg, region, certificate.CertificateArn, custom); } else { - // Expires in the future - if (difference > config.acm_certificate_expiry_pass) { - helpers.addResult(results, 0, expiresInMsg, region, certificate.CertificateArn, custom); - } else if (difference > config.acm_certificate_expiry_warn) { - helpers.addResult(results, 1, expiresInMsg, region, certificate.CertificateArn, custom); - } else if (difference > 0) { - helpers.addResult(results, 2, expiresInMsg, region, certificate.CertificateArn, custom); - } else { - helpers.addResult(results, 0, expiredMsg, region, certificate.CertificateArn, custom); - } + helpers.addResult(results, 2, expiredMsg, region, certificate.CertificateArn, custom); } } }); @@ -106,4 +103,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/acm/acmCertificateExpiry.spec.js b/plugins/aws/acm/acmCertificateExpiry.spec.js new file mode 100644 index 000000000..e5d09082a --- /dev/null +++ b/plugins/aws/acm/acmCertificateExpiry.spec.js @@ -0,0 +1,235 @@ +var expect = require('chai').expect; +var acmCertificateExpiry = require('./acmCertificateExpiry'); + +var cerExpiryWarn = new Date(); +cerExpiryWarn.setMonth(cerExpiryWarn.getMonth() + 1); + +var cerExpiryPass = new Date(); +cerExpiryPass.setMonth(cerExpiryPass.getMonth() + 2); + +var cerExpiryFail = new Date(); +cerExpiryFail.setMonth(cerExpiryFail.getMonth() + 1); + +var cerExpired = new Date(); +cerExpired.setMonth(cerExpired.getMonth() - 1); + +const listCertificates = [ + { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com" + } +]; + +const describeCertificate = [ + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com", + "Subject": "CN=www.xyz.com", + "Issuer": "Amazon", + "CreatedAt": "2021-10-05T08:56:23.000Z", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "ELIGIBLE", + "NotAfter": cerExpiryPass, + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com", + "Subject": "CN=www.xyz.com", + "Issuer": "Amazon", + "CreatedAt": "2021-10-05T08:56:23.000Z", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "ELIGIBLE", + "NotAfter": cerExpiryFail, + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com", + "Subject": "CN=www.xyz.com", + "Issuer": "Amazon", + "CreatedAt": "2021-10-05T08:56:23.000Z", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "ELIGIBLE", + "NotAfter": cerExpired, + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com", + "Subject": "CN=www.xyz.com", + "Issuer": "Amazon", + "CreatedAt": "2021-10-05T08:56:23.000Z", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "ELIGIBLE", + "NotAfter": cerExpiryWarn, + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011112222:certificate/a59bf6c5-faba-4e45-8b20-054d90e41500", + "DomainName": "www.xyz.com", + "Subject": "CN=www.xyz.com", + "Issuer": "Amazon", + "CreatedAt": "2021-10-05T08:56:23.000Z", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + } +]; + +const createCache = (listData, listErr, describeData, describeErr) => { + var certArn = (listData && listData.length) ? listData[0].CertificateArn : null; + return { + acm: { + listCertificates: { + 'us-east-1': { + data: listData, + err: listErr + }, + }, + describeCertificate: { + 'us-east-1': { + [certArn]: { + data: describeData, + err: describeErr + }, + }, + } + }, + }; +}; + +describe('acmCertificateExpiry', function () { + describe('run', function () { + it('should PASS if certificate expiration date exceeds set PASS number of days in the future', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[0]); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if certificate expiration date does not exceed set WARN number of days in the future', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[1]); + acmCertificateExpiry.run(cache, { acm_certificate_expiry_warn: '35' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if certificate has already expired', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[2]); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('expired'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should WARN if certificate expiration date exceed set WARN number of days in the future', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[3]); + acmCertificateExpiry.run(cache, { acm_certificate_expiry_warn: '25' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should WARN if certificate is not eligible for renewal', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[4]); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No ACM certificates found', function (done) { + const cache = createCache([]); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list ACM certificates', function (done) { + const cache = createCache(null, { message: 'err' }); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe ACM certificate', function (done) { + const cache = createCache([listCertificates[0]], null, null, { message: 'err' }); + acmCertificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/acm/acmCertificateHasTags.js b/plugins/aws/acm/acmCertificateHasTags.js new file mode 100644 index 000000000..93c76380b --- /dev/null +++ b/plugins/aws/acm/acmCertificateHasTags.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ACM Certificate Has Tags', + category: 'ACM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that ACM Certificates have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/acm/latest/userguide/tags.html', + recommended_action: 'Modify ACM certificate and add tags.', + apis: ['ACM:listCertificates', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['acm:RequestCertificate','acm:ImportCertificate','acm:DeleteCertificate','acm:AddTagsToCertificate', 'acm:RemoveTagsFromCertificate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.acm, function(region, rcb){ + var listCertificates = helpers.addSource(cache, source, + ['acm', 'listCertificates', region]); + + if (!listCertificates) return rcb(); + + if (listCertificates.err || !listCertificates.data) { + helpers.addResult(results, 3, + 'Unable to list ACM certificates: ' + helpers.addError(listCertificates), region); + return rcb(); + } + + if (!listCertificates.data.length) { + helpers.addResult(results, 0, 'No ACM certificates found', region); + return rcb(); + } + const ARNList= []; + for (var cert of listCertificates.data){ + if (!cert.CertificateArn) continue; + + ARNList.push(cert.CertificateArn); + } + helpers.checkTags(cache, 'ACM certificate', ARNList, region, results, settings); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/acm/acmCertificateHasTags.spec.js b/plugins/aws/acm/acmCertificateHasTags.spec.js new file mode 100644 index 000000000..13e008f5c --- /dev/null +++ b/plugins/aws/acm/acmCertificateHasTags.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +var acmCertificateHasTags = require('./acmCertificateHasTags'); + +const createCache = (clsuterData, rgData) => { + return { + acm: { + listCertificates: { + 'us-east-1': { + err: null, + data: clsuterData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + } +}; + +describe('acmCertificateHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list acm certificates', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list ACM certificates'); + done() + }; + + const cache = createCache(null, []); + acmCertificateHasTags.run(cache, {}, callback); + }); + + it('should give passing result if acm certificates not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No ACM certificates found'); + done(); + }; + const cache = createCache([], null); + acmCertificateHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources'); + done(); + }; + + const cache = createCache( + [{ + CertificateArn: 'arn:aws:acm:us-east-1:000011112222:certificate/f256ec8d-80d9-4473-a2b2-ac32cb6fe6e8' + }], + null + ); + + acmCertificateHasTags.run(cache, {}, callback); + }); + + it('should give passing result if acm certificates have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ACM certificate has tags'); + done(); + }; + const cache = createCache( + [{ + CertificateArn: 'arn:aws:acm:us-east-1:000011112222:certificate/f256ec8d-80d9-4473-a2b2-ac32cb6fe6e8' + }], + [{ + "ResourceARN": 'arn:aws:acm:us-east-1:000011112222:certificate/f256ec8d-80d9-4473-a2b2-ac32cb6fe6e8', + "Tags": [{key:"key1", value:"value"}], + }] + ); + acmCertificateHasTags.run(cache, {}, callback); + }); + + it('should give failing result if eks cluster does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ACM certificate does not have any tags'); + done(); + }; + + const cache = createCache( + [{ + CertificateArn: 'arn:aws:acm:us-east-1:000011112222:certificate/f256ec8d-80d9-4473-a2b2-ac32cb6fe6e8' + }], + [{ + "ResourceARN":'arn:aws:acm:us-east-1:000011112222:certificate/f256ec8d-80d9-4473-a2b2-ac32cb6fe6e8', + "Tags": [], + }] + ); + + acmCertificateHasTags.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/acm/acmSingleDomainNameCertificate.js b/plugins/aws/acm/acmSingleDomainNameCertificate.js new file mode 100644 index 000000000..f3c91b07a --- /dev/null +++ b/plugins/aws/acm/acmSingleDomainNameCertificate.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ACM Single Domain Name Certificates', + category: 'ACM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that ACM single domain name certificates are used instead of wildcard certificates within your AWS account.', + more_info: 'Using wildcard certificates can compromise the security of all sites i.e. domains and subdomains if the private key of a certificate is hacked. So it is recommended to use ACM single domain name certificates instead of wildcard certificates.', + link: 'https://docs.aws.amazon.com/acm/latest/userguide/acm-certificate.html', + recommended_action: 'Configure ACM managed certificates to use single name domain instead of wildcards.', + apis: ['ACM:listCertificates', 'ACM:describeCertificate'], + realtime_triggers: ['acm:RequestCertificate','acm:ImportCertificate','acm:DeleteCertificate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.acm, function(region, rcb){ + var listCertificates = helpers.addSource(cache, source, + ['acm', 'listCertificates', region]); + + if (!listCertificates) return rcb(); + + if (listCertificates.err || !listCertificates.data) { + helpers.addResult(results, 3, + 'Unable to list ACM certificates: ' + helpers.addError(listCertificates), region); + return rcb(); + } + + if (!listCertificates.data.length) { + helpers.addResult(results, 0, 'No ACM certificates found', region); + return rcb(); + } + + // Loop through certificates + listCertificates.data.forEach(function(cert){ + if (!cert.CertificateArn) return; + var describeCertificate = helpers.addSource(cache, source, + ['acm', 'describeCertificate', region, cert.CertificateArn]); + + if (!describeCertificate || describeCertificate.err || !describeCertificate.data) { + helpers.addResult(results, 3, + 'Unable to describe ACM certificate: ' + helpers.addError(describeCertificate), region, + cert.CertificateArn); + return; + } + + if (describeCertificate.data.Certificate && + describeCertificate.data.Certificate.DomainName && + describeCertificate.data.Certificate.DomainName.includes('*')) { + helpers.addResult(results, 2, + 'ACM certificate is a wildcard certificate', region, + cert.CertificateArn); + } else { + helpers.addResult(results, 0, + 'ACM certificate is a single domain name certificate', region, + cert.CertificateArn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/acm/acmSingleDomainNameCertificate.spec.js b/plugins/aws/acm/acmSingleDomainNameCertificate.spec.js new file mode 100644 index 000000000..98a01bffe --- /dev/null +++ b/plugins/aws/acm/acmSingleDomainNameCertificate.spec.js @@ -0,0 +1,170 @@ +var expect = require('chai').expect; +var acmSingleDomainNameCertificate = require('./acmSingleDomainNameCertificate'); + +const listCertificates = [ + { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/4eb67297-7e64-4f9b-bfd1-38f962df491c", + "DomainName": "CoolDude69.com" + }, + { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/8951d876-8417-4d5c-8caa-5f73a3b1211e", + "DomainName": "*.viteace.com" + } +]; + +const describeCertificate = [ + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/4eb67297-7e64-4f9b-bfd1-38f962df491c", + "DomainName": "cooldude69.com", + "SubjectAlternativeNames": [ + "cooldude69.com" + ], + "DomainValidationOptions": [ + { + "DomainName": "cooldude69.com", + "ValidationDomain": "cooldude69.com", + "ValidationStatus": "PENDING_VALIDATION", + "ResourceRecord": { + "Name": "_fe4c0c0adae010605a723ccc178d8c77.cooldude69.com.", + "Type": "CNAME", + "Value": "_788985d4b083a63504b1d46c7db65abb.gbwdrhjxvn.acm-validations.aws." + }, + "ValidationMethod": "DNS" + } + ], + "Subject": "CN=cooldude69.com", + "Issuer": "Amazon", + "CreatedAt": "2022-03-09T17:39:31.095000+05:00", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/8951d876-8417-4d5c-8caa-5f73a3b1211e", + "DomainName": "*.viteace.com", + "SubjectAlternativeNames": [ + "*.viteace.com" + ], + "DomainValidationOptions": [ + { + "DomainName": "*.viteace.com", + "ValidationDomain": "*.viteace.com", + "ValidationStatus": "PENDING_VALIDATION", + "ResourceRecord": { + "Name": "_4f0a6aaf33ac9cc46e53f8ca0a6f9763.viteace.com.", + "Type": "CNAME", + "Value": "_2711d4a887c089c7518bfdb01655daf3.gbwdrhjxvn.acm-validations.aws." + }, + "ValidationMethod": "DNS" + } + ], + "Subject": "CN=*.viteace.com", + "Issuer": "Amazon", + "CreatedAt": "2022-03-09T17:58:27.078000+05:00", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } +} +]; + +const createCache = (listData, listErr, describeData, describeErr) => { + var certArn = (listData && listData.length) ? listData[0].CertificateArn : null; + return { + acm: { + listCertificates: { + 'us-east-1': { + data: listData, + err: listErr + }, + }, + describeCertificate: { + 'us-east-1': { + [certArn]: { + data: describeData, + err: describeErr + }, + }, + } + }, + }; +}; + +describe('acmSingleDomainNameCertificate', function () { + describe('run', function () { + it('should PASS if ACM certificate is a single domain name certificate', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[0]); + acmSingleDomainNameCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ACM certificate is a single domain name certificate') + done(); + }); + }); + + it('should FAIL if ACM certificate is a wildcard certificate', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[1]); + acmSingleDomainNameCertificate.run(cache, { acm_certificate_expiry_warn: '35' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ACM certificate is a wildcard certificate') + done(); + }); + }); + + it('should PASS if No ACM certificates found', function (done) { + const cache = createCache([]); + acmSingleDomainNameCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No ACM certificates found') + done(); + }); + }); + + it('should UNKNOWN if unable to list ACM certificates', function (done) { + const cache = createCache(null, { message: 'err' }); + acmSingleDomainNameCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list ACM certificates') + done(); + }); + }); + + it('should UNKNOWN if unable to describe ACM certificate', function (done) { + const cache = createCache([listCertificates[0]], null, null, { message: 'err' }); + acmSingleDomainNameCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to describe ACM certificate') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/acm/acmValidation.js b/plugins/aws/acm/acmValidation.js index a6a8c823c..09ad0257b 100644 --- a/plugins/aws/acm/acmValidation.js +++ b/plugins/aws/acm/acmValidation.js @@ -4,12 +4,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ACM Certificate Validation', category: 'ACM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'ACM certificates should be configured to use DNS validation.', more_info: 'With DNS validation, ACM will automatically renew certificates before they expire, as long as the DNS CNAME record is in place.', link: 'https://aws.amazon.com/blogs/security/easier-certificate-validation-using-dns-with-aws-certificate-manager/', - cs_link: 'https://cloudsploit.com/remediations/aws/acm/acm-certificate-validation', + cs_link: 'https://cloudexploit.com/remediations/aws/acm/acm-certificate-validation', recommended_action: 'Configure ACM managed certificates to use DNS validation.', apis: ['ACM:listCertificates', 'ACM:describeCertificate'], + realtime_triggers: ['acm:RequestCertificate','acm:ImportCertificate','acm:DeleteCertificate'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/acm/acmValidation.spec.js b/plugins/aws/acm/acmValidation.spec.js new file mode 100644 index 000000000..07a843a75 --- /dev/null +++ b/plugins/aws/acm/acmValidation.spec.js @@ -0,0 +1,217 @@ +var expect = require('chai').expect; +var acmValidation = require('./acmValidation'); + +const listCertificates = [ + { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/4eb67297-7e64-4f9b-bfd1-38f962df491c", + "DomainName": "CoolDude69.com" + }, + { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/8951d876-8417-4d5c-8caa-5f73a3b1211e", + "DomainName": "*.viteace.com" + } +]; + +const describeCertificate = [ + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/4eb67297-7e64-4f9b-bfd1-38f962df491c", + "DomainName": "cooldude69.com", + "SubjectAlternativeNames": [ + "cooldude69.com" + ], + "DomainValidationOptions": [ + { + "DomainName": "cooldude69.com", + "ValidationDomain": "cooldude69.com", + "ValidationStatus": "SUCCESS", + "ResourceRecord": { + "Name": "_fe4c0c0adae010605a723ccc178d8c77.cooldude69.com.", + "Type": "CNAME", + "Value": "_788985d4b083a63504b1d46c7db65abb.gbwdrhjxvn.acm-validations.aws." + }, + "ValidationMethod": "DNS" + } + ], + "Subject": "CN=cooldude69.com", + "Issuer": "Amazon", + "CreatedAt": "2022-03-09T17:39:31.095000+05:00", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/8951d876-8417-4d5c-8caa-5f73a3b1211e", + "DomainName": "*.viteace.com", + "SubjectAlternativeNames": [ + "*.viteace.com" + ], + "DomainValidationOptions": [ + { + "DomainName": "*.viteace.com", + "ValidationDomain": "*.viteace.com", + "ValidationStatus": "PENDING_VALIDATION", + "ResourceRecord": { + "Name": "_4f0a6aaf33ac9cc46e53f8ca0a6f9763.viteace.com.", + "Type": "CNAME", + "Value": "_2711d4a887c089c7518bfdb01655daf3.gbwdrhjxvn.acm-validations.aws." + }, + "ValidationMethod": "DNS" + } + ], + "Subject": "CN=*.viteace.com", + "Issuer": "Amazon", + "CreatedAt": "2022-03-09T17:58:27.078000+05:00", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } + }, + { + "Certificate": { + "CertificateArn": "arn:aws:acm:us-east-1:000011122222:certificate/8951d876-8417-4d5c-8caa-5f73a3b1211e", + "DomainName": "*.viteace.com", + "SubjectAlternativeNames": [ + "*.viteace.com" + ], + "DomainValidationOptions": [ + { + "DomainName": "*.viteace.com", + "ValidationDomain": "*.viteace.com", + "ValidationStatus": "SUCCESS", + "ResourceRecord": { + "Name": "_4f0a6aaf33ac9cc46e53f8ca0a6f9763.viteace.com.", + "Type": "CNAME", + "Value": "_2711d4a887c089c7518bfdb01655daf3.gbwdrhjxvn.acm-validations.aws." + }, + "ValidationMethod": "EMAIL" + } + ], + "Subject": "CN=*.viteace.com", + "Issuer": "Amazon", + "CreatedAt": "2022-03-09T17:58:27.078000+05:00", + "Status": "PENDING_VALIDATION", + "KeyAlgorithm": "RSA-2048", + "SignatureAlgorithm": "SHA256WITHRSA", + "InUseBy": [], + "Type": "AMAZON_ISSUED", + "KeyUsages": [], + "ExtendedKeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + } + } +} +]; + +const createCache = (listData, listErr, describeData, describeErr) => { + var certArn = (listData && listData.length) ? listData[0].CertificateArn : null; + return { + acm: { + listCertificates: { + 'us-east-1': { + data: listData, + err: listErr + }, + }, + describeCertificate: { + 'us-east-1': { + [certArn]: { + data: describeData, + err: describeErr + }, + }, + } + }, + }; +}; + +describe('acmValidation', function () { + describe('run', function () { + it('should PASS if ACM certificate is using DNS validations', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[0]); + acmValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('is using DNS validation') + done(); + }); + }); + + it('should FAIL if ACM certificate has failed validations', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[1]); + acmValidation.run(cache, { acm_certificate_expiry_warn: '35' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('has failed DNS validation') + done(); + }); + }); + + it('should WARN if ACM certificate is using EMAIL validation', function (done) { + const cache = createCache([listCertificates[0]], null, describeCertificate[2]); + acmValidation.run(cache, { acm_certificate_expiry_warn: '35' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('is using EMAIL validation') + done(); + }); + }); + + it('should PASS if No ACM certificates found', function (done) { + const cache = createCache([]); + acmValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No ACM certificates found') + done(); + }); + }); + + it('should UNKNOWN if unable to list ACM certificates', function (done) { + const cache = createCache(null, { message: 'err' }); + acmValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list ACM certificates') + done(); + }); + }); + + it('should UNKNOWN if unable to describe ACM certificate', function (done) { + const cache = createCache([listCertificates[0]], null, null, { message: 'err' }); + acmValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to describe ACM certificate') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apiStageLevelCacheEncryption.js b/plugins/aws/apigateway/apiStageLevelCacheEncryption.js new file mode 100644 index 000000000..be02e50f5 --- /dev/null +++ b/plugins/aws/apigateway/apiStageLevelCacheEncryption.js @@ -0,0 +1,91 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Stage-Level Cache Encryption', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensure that your Amazon API Gateway REST APIs are configured to encrypt API cached responses.', + more_info: 'It is strongly recommended to enforce encryption for API cached responses in order to protect your data from unauthorized access.', + recommended_action: 'Modify API Gateway API stages to enable encryption on cache data', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/data-protection-encryption.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:DeleteRestApi','apigateway:CreateStage','apigateway:UpdateStage','apigateway:DeleteStage'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway rest APIs found', region); + return rcb(); + } + + for (let api of getRestApis.data){ + if (!api.id) continue; + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query API Gateway stages: ${helpers.addError(getStages)}`, + region, apiArn); + continue; + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No rest API Gateway stages found', + region, apiArn); + continue; + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + if (!stage.methodSettings || !stage.methodSettings['*/*'] || !stage.methodSettings['*/*'].cachingEnabled) { + helpers.addResult(results, 0, + 'Response caching is not enabled for the API stage', region, stageArn); + return; + } + + if (stage.methodSettings['*/*'].cacheDataEncrypted) { + helpers.addResult(results, 0, + 'API Gateway stage encrypts cache data', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway stage does not encrypt cache data', + region, stageArn); + } + }); + } + + rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apiStageLevelCacheEncryption.spec.js b/plugins/aws/apigateway/apiStageLevelCacheEncryption.spec.js new file mode 100644 index 000000000..544692854 --- /dev/null +++ b/plugins/aws/apigateway/apiStageLevelCacheEncryption.spec.js @@ -0,0 +1,256 @@ +var expect = require('chai').expect; +var apiStageLevelCacheEncryption = require('./apiStageLevelCacheEncryption'); + +const getRestApis = [ + { + "id": "mr5lhq7we8", + "name": "FoodLeaStack", + "createdDate": "2022-02-25T17:26:23+05:00", + "version": "1.0", + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "EDGE" + ] + }, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApi", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:232489870748:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "disableExecuteApiEndpoint": false + }, + { + "id": "rkmadwkbe4", + "name": "FoodLeaAPIFunction-API", + "description": "Created by AWS Lambda", + "createdDate": "2021-11-23T22:09:15+05:00", + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "disableExecuteApiEndpoint": false + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "7wo1i4", + "stageName": "Prod", + "cacheClusterEnabled": true, + "cacheClusterSize": "0.5", + "cacheClusterStatus": "AVAILABLE", + "methodSettings": { + "*/*": { + "metricsEnabled": false, + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": true, + "cacheTtlInSeconds": 0, + "cacheDataEncrypted": true, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "tracingEnabled": false, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApiProdStage", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:232489870748:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "createdDate": "2022-02-25T17:26:32+05:00", + "lastUpdatedDate": "2022-03-08T13:27:14+05:00" + }, + ] + }, + { + "item": [ + { + "deploymentId": "7wo1i4", + "stageName": "Prod", + "cacheClusterEnabled": true, + "cacheClusterSize": "0.5", + "cacheClusterStatus": "AVAILABLE", + "methodSettings": { + "*/*": { + "metricsEnabled": false, + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": true, + "cacheTtlInSeconds": 0, + "cacheDataEncrypted": false, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "tracingEnabled": false, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApiProdStage", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:232489870748:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "createdDate": "2022-02-25T17:26:32+05:00", + "lastUpdatedDate": "2022-03-08T13:27:14+05:00" + }, + ] + }, + { + "item": [ + { + "deploymentId": "8ev3a3", + "stageName": "Stage", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": "2022-02-25T17:26:28+05:00", + "lastUpdatedDate": "2022-02-25T17:26:28+05:00" + } + ] + } +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apiStageLevelCacheEncryption', function () { + describe('run', function () { + it('should PASS if API Gateway stage encrypts cache data', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('API Gateway stage encrypts cache data') + done(); + }); + }); + + it('should FAIL if API Gateway stage does not encrypt cache data', function (done) { + const cache = createCache([getRestApis[1]], getStages[1]); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('API Gateway stage does not encrypt cache data') + done(); + }); + }); + + it('should PASS if Response caching is not enabled for the API stage', function (done) { + const cache = createCache([getRestApis[0]], getStages[2]); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Response caching is not enabled for the API stage') + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No API Gateway rest APIs found') + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item: []}); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No rest API Gateway stages found') + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for API Gateway rest APIs') + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apiStageLevelCacheEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query API Gateway stages') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayAuthorization.js b/plugins/aws/apigateway/apigatewayAuthorization.js new file mode 100644 index 000000000..1ea0bed7a --- /dev/null +++ b/plugins/aws/apigateway/apigatewayAuthorization.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Authorization', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Amazon API Gateway APIs are using authorizer', + more_info: 'API Gateway API should be using authorizer to enforce security measures and control access to API resources.', + recommended_action: 'Modify API Gateway configuration and ensure that appropriate authorizers are set up for each API.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getAuthorizers'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:DeleteRestApi','apigateway:ImportRestApi','apigateway:CreateAuthorizer','apigateway:DeleteAuthorizer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + getRestApis.data.forEach(api => { + if (!api.id) return; + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getAuthorizers = helpers.addSource(cache, source, + ['apigateway', 'getAuthorizers', region, api.id]); + + if (!getAuthorizers || getAuthorizers.err || !getAuthorizers.data || !getAuthorizers.data.items) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Authorizers: ${helpers.addError(getAuthorizers)}`, + region, apiArn); + return; + } + + if (!getAuthorizers.data.items.length) { + helpers.addResult(results, 2, + 'No authorizers found for API Gateway Rest API ', + region, apiArn ); + } else { + helpers.addResult(results, 0, + 'Authorizers found for API Gateway Rest API ', + region, apiArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayAuthorization.spec.js b/plugins/aws/apigateway/apigatewayAuthorization.spec.js new file mode 100644 index 000000000..633087906 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayAuthorization.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var apigatewayAuthorization = require('./apigatewayAuthorization'); + +const createCache = (getRestApisData, getAuthorizersData) => { + if (getRestApisData && getRestApisData.length && getRestApisData[0].id) var restApiId = getRestApisData[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: getRestApisData + } + }, + getAuthorizers: { + 'us-east-1': { + [restApiId]: { + data: { + items: getAuthorizersData + } + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Rest APIs' + }, + }, + }, + getAuthorizers: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Authorizers' + }, + }, + } + + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getAuthorizers: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayAuthorization', function () { + describe('run', function () { + it('should return UNKNOWN if unable to query for API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayAuthorization.run(cache, {} , (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('Unable to query for API Gateway Rest APIs:'); + done(); + }); + }); + + it('should return PASS if no API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayAuthorization.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No API Gateway Rest APIs found'); + done(); + }); + }); + + it('should return FAIL if no authorizers exist for API Gateway Rest API', function (done) { + const getRestApisData = [ + { + id: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getAuthorizersData = []; + const cache = createCache(getRestApisData, getAuthorizersData); + apigatewayAuthorization.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No authorizers found for API Gateway Rest API '); + done(); + }); + }); + + it('should return PASS if authorizers exist for API Gateway Rest API', function (done) { + const getRestApisData = [ + { + id: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getAuthorizersData = [ + { + name: 'authorizer1', + type: 'REQUEST', + authType: 'custom', + authorizerUri: 'arn:aws:lambda:us-east-1:123456789012:function:authorizer1', + identitySource: 'method.request.header.Authorization', + authorizerResultTtlInSeconds: 300 + }, + { + name: 'authorizer2', + type: 'REQUEST', + authType: 'custom', + authorizerUri: 'arn:aws:lambda:us-east-1:123456789012:function:authorizer2', + identitySource: 'method.request.header.Authorization', + authorizerResultTtlInSeconds: 300 + } + ]; + const cache = createCache(getRestApisData, getAuthorizersData); + apigatewayAuthorization.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Authorizers found for API Gateway Rest API ') + done(); + }); + }); + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayAuthorization.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/apigateway/apigatewayCertificateRotation.js b/plugins/aws/apigateway/apigatewayCertificateRotation.js new file mode 100644 index 000000000..860a2a49b --- /dev/null +++ b/plugins/aws/apigateway/apigatewayCertificateRotation.js @@ -0,0 +1,120 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Certificate Rotation', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Amazon API Gateway APIs have certificates with expiration date more than the rotation limit.', + more_info: 'API Gateway APIs should have certificates with long term expiry date to avoid API insecurity after certificate expiration.', + recommended_action: 'Rotate the certificate attached to API Gateway API', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-client-side-ssl-authentication.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages', 'APIGateway:getClientCertificate'], + settings: { + api_certificate_rotation_limit: { + name: 'Certificate Rotation Limit', + description: 'Number of days before expiration date when certificate should be rotated', + regex: '^[0-9]*$', + default: '30', + } + }, + realtime_triggers: ['apigateway:CreateRestApi','apigateway:DeleteRestApi','apigateway:ImportRestApi','apigateway:CreateStage','apigateway:DeleteStage','apigateway:GenerateClientCertificate','apigateway:DeleteClientCertificate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + var api_certificate_rotation_limit = parseInt(settings.api_certificate_rotation_limit || this.settings.api_certificate_rotation_limit.default); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, + 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, acb){ + if (!api.id) return acb(); + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return acb(); + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No Rest API Stages found', + region, apiArn); + return acb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName || !stage.clientCertificateId) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + var getClientCertificate = helpers.addSource(cache, source, + ['apigateway', 'getClientCertificate', region, stage.clientCertificateId]); + + if (!getClientCertificate || getClientCertificate.err || !getClientCertificate.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stage Client Certificate: ${helpers.addError(getClientCertificate)}`, + region, stageArn); + return; + } + + if (!getClientCertificate.data.expirationDate) { + helpers.addResult(results, 0, + 'No Client Certificate information found', + region, stageArn); + return; + } + + var then = new Date(getClientCertificate.data.expirationDate); + var difference = Math.round((new Date(then).getTime() - new Date().getTime())/(24*60*60*1000)); + + if (difference > api_certificate_rotation_limit) { + helpers.addResult(results, 0, + `API Gateway API stage does not need client certificate rotation as it expires in ${difference} days ` + + `of ${api_certificate_rotation_limit} days limit`, + region, stageArn); + } else if (difference >= 0){ + helpers.addResult(results, 2, + `API Gateway API stage client certificate needs rotation as it expires in ${difference} days ` + + `of ${api_certificate_rotation_limit} days limit`, + region, stageArn); + } else { + helpers.addResult(results, 2, + `API Gateway API stage client certificate needs rotation as it expired ${Math.abs(difference)} days ago`, + region, stageArn); + } + }); + + acb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayCertificateRotation.spec.js b/plugins/aws/apigateway/apigatewayCertificateRotation.spec.js new file mode 100644 index 000000000..5ad6adef8 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayCertificateRotation.spec.js @@ -0,0 +1,248 @@ +var expect = require('chai').expect; +var apigatewayCertificateRotation = require('./apigatewayCertificateRotation'); + +var cerExpiryFail = new Date(); +cerExpiryFail.setMonth(cerExpiryFail.getMonth() + 1); + +var cerExpiryPass = new Date(); +cerExpiryPass.setMonth(cerExpiryPass.getMonth() + 1); + +var cerExpired = new Date(); +cerExpired.setMonth(cerExpired.getMonth() - 1); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "minimumCompressionSize": 1000, + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "clientCertificateId": "1bawn2", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + +const clientCertificate = [ + { + "clientCertificateId": "1bawn2", + "pemEncodedCertificate": "-----BEGIN CERTIFICATE-----\r\nMIIC6TCCAdGgAwIBAgIJAOyAuaO+1d1OMA0GCSqGSIb3DQEBCwUAMDQxCzAJBgNV\r\nBAYTAlVTMRAwDgYDVQQHEwdTZWF0dGxlMRMwEQYDVQQDEwpBcGlHYXRld2F5MB4X\r\nDTIwMTIxMTIxMzU0M1oXDTIxMTIxMTIxMzU0M1owNDELMAkGA1UEBhMCVVMxEDAO\r\nBgNVBAcTB1NlYXR0bGUxEzARBgNVBAMTCkFwaUdhdGV3YXkwggEiMA0GCSqGSIb3\r\nDQEBKHULNASOFTA4IBDwAwggEKAoIBAQCB3FraOYqorcdIebM66HTgPwbO5BVWDdmqxZfC\r\n1r2vjZq7LPHn+EuPgVh/BMnV2HAk4PiksXGJls3OZpoIArUHbuuO18cWEACkmvBv\r\n6fhB5D4reV75gBTFx12P4bwGl8E8tkq+o2SqnjqqHHdFHg4UhWQglBpWINS01MIh\r\n84DuzMhpvLfCqM1sW5+klGScMu5L9WvrHjtgKlIzvJkU5EWjkVgELhakF8GOKPun\r\nPD0RnbeVC0KaaPP1SpcwnCSj6h2EZhZYmS1Q9hO9tBnNuenzYhXsnK4HtkyVcL0/\r\nwO9xDP/H2hzTD2pEqKj+35B+aSu6fWdYLrsGMGlzN1Wtk1o3AgMBAAEwDQYJKoZI\r\nhvcNAQELBQADggEBAGKjj5qiad9S7H8FYILOhY/6fb3LRvo9lY3pR0D4MVG4nPdT\r\nkLJkBbFj7qqg9UqBqcZp3m7XHKm6+DdISkv/zfgutEikrlLD5lnYMsHljRKLwRFS\r\nZgOM7SnPX2dxRwNUSwBCjZIojf2+H8ENAcdpvHG3G/nwA/fWyoHY+UQfF29BkTtF\r\n9H5n3DjCQz3tm5njh+T1dVyVTuzXx3OdZulFrVi1sLmE81ca7ckdCZDs4DHMGwA9\r\nf3yPvk3B+DulD5YUnVSFDX84AK8f2xRc/5OUM5J+1DjD7bLKzikVzv+E+87ygEJb\r\nlNmz7yXJSkCzQ8jKCAva+Gw/lwC+K3AUCVFpxuU=\r\n-----END CERTIFICATE-----", + "createdDate": "2020-12-12T02:35:43+05:00", + "expirationDate": cerExpiryPass, + "tags": { + "cert": "cert" + } + }, + { + "clientCertificateId": "1bawn2", + "pemEncodedCertificate": "-----BEGIN CERTIFICATE-----\r\nMIIC6TCCAdGgAwIBAgIJAOyAuaO+1d1OMA0GCSqGSIb3DQEBCwUAMDQxCzAJBgNV\r\nBAYTAlVTMRAwDgYDVQQHEwdTZWF0dGxlMRMwEQYDVQQDEwpBcGlHYXRld2F5MB4X\r\nDTIwMTIxMTIxMzU0M1oXDTIxMTIxMTIxMzU0M1owNDELMAkGA1UEBhMCVVMxEDAO\r\nBgNVBAcTB1NlYXR0bGUxEzARBgNVBAMTCkFwaUdhdGV3YXkwggEiMA0GCSqGSIb3\r\nDQEBKHULNASOFTA4IBDwAwggEKAoIBAQCB3FraOYqorcdIebM66HTgPwbO5BVWDdmqxZfC\r\n1r2vjZq7LPHn+EuPgVh/BMnV2HAk4PiksXGJls3OZpoIArUHbuuO18cWEACkmvBv\r\n6fhB5D4reV75gBTFx12P4bwGl8E8tkq+o2SqnjqqHHdFHg4UhWQglBpWINS01MIh\r\n84DuzMhpvLfCqM1sW5+klGScMu5L9WvrHjtgKlIzvJkU5EWjkVgELhakF8GOKPun\r\nPD0RnbeVC0KaaPP1SpcwnCSj6h2EZhZYmS1Q9hO9tBnNuenzYhXsnK4HtkyVcL0/\r\nwO9xDP/H2hzTD2pEqKj+35B+aSu6fWdYLrsGMGlzN1Wtk1o3AgMBAAEwDQYJKoZI\r\nhvcNAQELBQADggEBAGKjj5qiad9S7H8FYILOhY/6fb3LRvo9lY3pR0D4MVG4nPdT\r\nkLJkBbFj7qqg9UqBqcZp3m7XHKm6+DdISkv/zfgutEikrlLD5lnYMsHljRKLwRFS\r\nZgOM7SnPX2dxRwNUSwBCjZIojf2+H8ENAcdpvHG3G/nwA/fWyoHY+UQfF29BkTtF\r\n9H5n3DjCQz3tm5njh+T1dVyVTuzXx3OdZulFrVi1sLmE81ca7ckdCZDs4DHMGwA9\r\nf3yPvk3B+DulD5YUnVSFDX84AK8f2xRc/5OUM5J+1DjD7bLKzikVzv+E+87ygEJb\r\nlNmz7yXJSkCzQ8jKCAva+Gw/lwC+K3AUCVFpxuU=\r\n-----END CERTIFICATE-----", + "createdDate": "2020-12-12T02:35:43+05:00", + "expirationDate": cerExpiryFail, + "tags": { + "cert": "cert" + } + }, + { + "clientCertificateId": "1bawn2", + "pemEncodedCertificate": "-----BEGIN CERTIFICATE-----\r\nMIIC6TCCAdGgAwIBAgIJAOyAuaO+1d1OMA0GCSqGSIb3DQEBCwUAMDQxCzAJBgNV\r\nBAYTAlVTMRAwDgYDVQQHEwdTZWF0dGxlMRMwEQYDVQQDEwpBcGlHYXRld2F5MB4X\r\nDTIwMTIxMTIxMzU0M1oXDTIxMTIxMTIxMzU0M1owNDELMAkGA1UEBhMCVVMxEDAO\r\nBgNVBAcTB1NlYXR0bGUxEzARBgNVBAMTCkFwaUdhdGV3YXkwggEiMA0GCSqGSIb3\r\nDQEBKHULNASOFTA4IBDwAwggEKAoIBAQCB3FraOYqorcdIebM66HTgPwbO5BVWDdmqxZfC\r\n1r2vjZq7LPHn+EuPgVh/BMnV2HAk4PiksXGJls3OZpoIArUHbuuO18cWEACkmvBv\r\n6fhB5D4reV75gBTFx12P4bwGl8E8tkq+o2SqnjqqHHdFHg4UhWQglBpWINS01MIh\r\n84DuzMhpvLfCqM1sW5+klGScMu5L9WvrHjtgKlIzvJkU5EWjkVgELhakF8GOKPun\r\nPD0RnbeVC0KaaPP1SpcwnCSj6h2EZhZYmS1Q9hO9tBnNuenzYhXsnK4HtkyVcL0/\r\nwO9xDP/H2hzTD2pEqKj+35B+aSu6fWdYLrsGMGlzN1Wtk1o3AgMBAAEwDQYJKoZI\r\nhvcNAQELBQADggEBAGKjj5qiad9S7H8FYILOhY/6fb3LRvo9lY3pR0D4MVG4nPdT\r\nkLJkBbFj7qqg9UqBqcZp3m7XHKm6+DdISkv/zfgutEikrlLD5lnYMsHljRKLwRFS\r\nZgOM7SnPX2dxRwNUSwBCjZIojf2+H8ENAcdpvHG3G/nwA/fWyoHY+UQfF29BkTtF\r\n9H5n3DjCQz3tm5njh+T1dVyVTuzXx3OdZulFrVi1sLmE81ca7ckdCZDs4DHMGwA9\r\nf3yPvk3B+DulD5YUnVSFDX84AK8f2xRc/5OUM5J+1DjD7bLKzikVzv+E+87ygEJb\r\nlNmz7yXJSkCzQ8jKCAva+Gw/lwC+K3AUCVFpxuU=\r\n-----END CERTIFICATE-----", + "createdDate": "2020-12-12T02:35:43+05:00", + "expirationDate": cerExpired, + "tags": { + "cert": "cert" + } + } +]; + +const createCache = (apis, stages, cert) => { + var restApiId = (apis && apis.length && apis[0].id) ? apis[0].id : null; + var clientCertificateId = (stages && stages.item && stages.item.length && stages.item[0].clientCertificateId) ? stages.item[0].clientCertificateId : null; + + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + getClientCertificate: { + 'us-east-1': { + [clientCertificateId]: { + data: cert + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Rest API Stages' + }, + }, + }, + getClientCertificate: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stage client certificate' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + }, + getClientCertificate: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayCertificateRotation', function () { + describe('run', function () { + it('should PASS if API Gateway API stages do not need client certificate rotation', function (done) { + const cache = createCache([getRestApis[0]], getStages[0], clientCertificate[0]); + apigatewayCertificateRotation.run(cache, { api_certificate_rotation_limit: '20' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API stage needs client certificate rotation', function (done) { + const cache = createCache([getRestApis[0]], getStages[0], clientCertificate[1]); + apigatewayCertificateRotation.run(cache, { api_certificate_rotation_limit: '40' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if API Gateway API stage client certificate has already expired', function (done) { + const cache = createCache([getRestApis[0]], getStages[0], clientCertificate[2]); + apigatewayCertificateRotation.run(cache, { api_certificate_rotation_limit: '40' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API stages found', function (done) { + const cache = createCache([getRestApis[0]], {item:[]}); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API stage client certificate found', function (done) { + const cache = createCache([getRestApis[0]], getStages[0], {}); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayCertificateRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayClientCertificate.js b/plugins/aws/apigateway/apigatewayClientCertificate.js new file mode 100644 index 000000000..bc2f53962 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayClientCertificate.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Client Certificate', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Amazon API Gateway API stages use client certificates.', + more_info: 'API Gateway API stages should use client certificates to ensure API security authorization.', + recommended_action: 'Attach client certificate to API Gateway API stages', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-client-side-ssl-authentication.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:DeleteRestApi','apigateway:ImportRestApi','apigateway:CreateStage','apigateway:DeleteStage','apigateway:UpdateStage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, cb){ + if (!api.id) return cb(); + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return cb(); + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No Rest API Stages found', + region, apiArn); + return cb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + if (stage.clientCertificateId) { + helpers.addResult(results, 0, + 'API Gateway API stage uses a client certificate', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway API stage does not use a client certificate', + region, stageArn); + } + }); + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayClientCertificate.spec.js b/plugins/aws/apigateway/apigatewayClientCertificate.spec.js new file mode 100644 index 000000000..df0b4c0bb --- /dev/null +++ b/plugins/aws/apigateway/apigatewayClientCertificate.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var apigatewayClientCertificate = require('./apigatewayClientCertificate'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "clientCertificateId": "1bawn2", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": true, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayClientCertificate', function () { + describe('run', function () { + it('should PASS if API Gateway API use client certificate for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API does not use client certificate for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[1]); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item: []}); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayClientCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayCloudwatchLogs.js b/plugins/aws/apigateway/apigatewayCloudwatchLogs.js new file mode 100644 index 000000000..10d7b41ee --- /dev/null +++ b/plugins/aws/apigateway/apigatewayCloudwatchLogs.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway CloudWatch Logs', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Amazon API Gateway API stages have Amazon CloudWatch Logs enabled.', + more_info: 'API Gateway API stages should have Amazon CloudWatch Logs enabled to help debug issues related to request execution or client access to your API.', + recommended_action: 'Modify API Gateway API stages to enable CloudWatch Logs', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateStage','apigateway:UpdateStage','apigateway:DeleteStage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, cb){ + if (!api.id) return cb(); + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return cb(); + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No Rest API Stages found', + region, apiArn); + return cb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + if (stage.methodSettings && stage.methodSettings['*/*'] && stage.methodSettings['*/*'].loggingLevel) { + helpers.addResult(results, 0, + 'API Gateway API stage has CloudWatch Logs enabled', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway API stage does not have CloudWatch Logs enabled', + region, stageArn); + } + }); + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayCloudwatchLogs.spec.js b/plugins/aws/apigateway/apigatewayCloudwatchLogs.spec.js new file mode 100644 index 000000000..abe2cf6ce --- /dev/null +++ b/plugins/aws/apigateway/apigatewayCloudwatchLogs.spec.js @@ -0,0 +1,190 @@ +var expect = require('chai').expect; +var apigatewayCloudwatchLogs = require('./apigatewayCloudwatchLogs'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": { + '*/*': { + "metricsEnabled": true, + "loggingLevel": "ERROR", + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": false, + "cacheTtlInSeconds": 300, + "cacheDataEncrypted": false, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + }, + }, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayCloudwatchLogs', function () { + describe('run', function () { + it('should PASS if API Gateway API has CloudWatch Logs enabled for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if API Gateway API does not have CloudWatch Logs enabled for stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[1]); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item: []}); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayContentEncoding.js b/plugins/aws/apigateway/apigatewayContentEncoding.js new file mode 100644 index 000000000..e764307c3 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayContentEncoding.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Content Encoding', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Amazon API Gateway APIs have content encoding enabled.', + more_info: 'API Gateway API should have content encoding enabled to enable compression of response payload.', + recommended_action: 'Enable content encoding and set minimum compression size of API Gateway API response', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-gzip-compression-decompression.html', + apis: ['APIGateway:getRestApis'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:UpdateRestApi','apigateway:DeleteRestApi'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + getRestApis.data.forEach(api => { + if (!api.id) return rcb(); + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + if (api.minimumCompressionSize) { + helpers.addResult(results, 0, + `API Gateway "${api.name}" has content encoding enabled`, + region, apiArn); + } else { + helpers.addResult(results, 2, + `API Gateway "${api.name}" does not have content encoding enabled`, + region, apiArn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayContentEncoding.spec.js b/plugins/aws/apigateway/apigatewayContentEncoding.spec.js new file mode 100644 index 000000000..61ebd1031 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayContentEncoding.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var apigatewayContentEncoding = require('./apigatewayContentEncoding'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "minimumCompressionSize": 1000, + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + }, + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const createCache = (apis) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + } + }, + }; +}; + +describe('apigatewayContentEncoding', function () { + describe('run', function () { + it('should PASS if API Gateway API stage has content encoding enabled', function (done) { + const cache = createCache([getRestApis[0]]); + apigatewayContentEncoding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API stage does not have content encoding enabled', function (done) { + const cache = createCache([getRestApis[1]]); + apigatewayContentEncoding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayContentEncoding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayContentEncoding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayContentEncoding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.js b/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.js new file mode 100644 index 000000000..cf29ec37a --- /dev/null +++ b/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Default Endpoint Disabled', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensure default execute-api endpoint is disabled for your API Gateway.', + more_info: 'By default, clients can invoke your API by using the execute-api endpoint that API Gateway generates for your API. To ensure that clients can access your API only by using a custom domain name, disable the default execute-api endpoint.', + recommended_action: 'Modify API Gateway to disable default execute-api endpoint.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html', + apis: ['APIGateway:getRestApis'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:UpdateRestApi','apigateway:DeleteRestApi'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway rest APIs found', region); + return rcb(); + } + + for (let api of getRestApis.data){ + if (!api.id) continue; + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + if (api.disableExecuteApiEndpoint) { + helpers.addResult(results, 0, + `API Gateway "${api.name}" is not accessible through default endpoint`, + region, apiArn); + } else { + helpers.addResult(results, 2, + `API Gateway "${api.name}" is accessible through default endpoint`, + region, apiArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.spec.js b/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.spec.js new file mode 100644 index 000000000..ed8381c6b --- /dev/null +++ b/plugins/aws/apigateway/apigatewayDefaultEndpointDisabled.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +var apigatewayTlsDefaultEndpoint = require('./apigatewayDefaultEndpointDisabled'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "disableExecuteApiEndpoint": false + }, + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "disableExecuteApiEndpoint": true + } +]; + +const createCache = (apis) => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + }, + }; +}; + +describe('apigatewayTlsDefaultEndpoint', function () { + describe('run', function () { + it('should PASS if No API Gateway rest APIs found', function (done) { + const cache = createCache([]); + apigatewayTlsDefaultEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No API Gateway rest APIs found'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if API Gateway is not accessible through default endpoint', function (done) { + const cache = createCache([getRestApis[1]]); + apigatewayTlsDefaultEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is not accessible through default endpoint'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway is accessible through default endpoint', function (done) { + const cache = createCache([getRestApis[0]]); + apigatewayTlsDefaultEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is accessible through default endpoint'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for API Gateways', function (done) { + const cache = createErrorCache(); + apigatewayTlsDefaultEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for API Gateway rest APIs'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayPrivateEndpoints.js b/plugins/aws/apigateway/apigatewayPrivateEndpoints.js new file mode 100644 index 000000000..bdb63e5e0 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayPrivateEndpoints.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Private Endpoints', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Amazon API Gateway APIs are only accessible through private endpoints.', + more_info: 'API Gateway APIs should be only accessible through private endpoints to ensure API security.', + recommended_action: 'Set API Gateway API endpoint configuration to private', + link: 'https://aws.amazon.com/blogs/compute/introducing-amazon-api-gateway-private-endpoints', + apis: ['APIGateway:getRestApis'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:UpdateRestApi','apigateway:DeleteRestApi'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + getRestApis.data.forEach(api => { + if (!api.id || !api.name) return; + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + if (!api.endpointConfiguration || !api.endpointConfiguration.types || !api.endpointConfiguration.types.length) { + helpers.addResult(results, 2, + `API Gateway API "${api.name}" does not have endpoint configuration enabled`, + region, apiArn); + } + + var publicEndpoint = api.endpointConfiguration.types.find(type => type.toUpperCase() !== 'PRIVATE'); + + if (publicEndpoint) { + helpers.addResult(results, 2, + `API Gateway API "${api.name}" is accessible through public endpoints`, + region, apiArn); + } else { + helpers.addResult(results, 0, + `API Gateway API "${api.name}" is only accessible through private endpoints`, + region, apiArn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayPrivateEndpoints.spec.js b/plugins/aws/apigateway/apigatewayPrivateEndpoints.spec.js new file mode 100644 index 000000000..367e6103b --- /dev/null +++ b/plugins/aws/apigateway/apigatewayPrivateEndpoints.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var apigatewayPrivateEndpoints = require('./apigatewayPrivateEndpoints'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "minimumCompressionSize": 1000, + "endpointConfiguration": { + "types": [ + "PRIVATE" + ] + } + }, + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const createCache = (apis) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + } + }, + }; +}; + +describe('apigatewayPrivateEndpoints', function () { + describe('run', function () { + it('should PASS if API Gateway API is only accessible through private endpoints', function (done) { + const cache = createCache([getRestApis[0]]); + apigatewayPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API is accessible through public endpoints', function (done) { + const cache = createCache([getRestApis[1]]); + apigatewayPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayRequestValidation.js b/plugins/aws/apigateway/apigatewayRequestValidation.js new file mode 100644 index 000000000..4c56b20c3 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayRequestValidation.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Request Validation', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Amazon API Gateway method has request validation enabled.', + more_info: 'Enabling request validation for API Gateway allows to perform basic validation of an API request before proceeding with the integration request and publishes the validation results in CloudWatch Logs. When request validation fails, API Gateway immediately fails the request reducing unnecessary calls to the backend.', + recommended_action: 'Modify API Gateway configuration and ensure that appropriate request validators are set for each API.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-request-validation.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getRequestValidators'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:DeleteRestApi','apigateway:ImportRestApi','apigateway:CreateRequestValidator','apigateway:UpdateRequestValidator','apigateway:DeleteRequestValidator'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + getRestApis.data.forEach(api => { + if (!api.id) return; + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getRequestValidators = helpers.addSource(cache, source, + ['apigateway', 'getRequestValidators', region, api.id]); + + if (!getRequestValidators || getRequestValidators.err || !getRequestValidators.data || !getRequestValidators.data.items) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Request Validators: ${helpers.addError(getRequestValidators)}`, + region, apiArn); + return; + } + + if (!getRequestValidators.data.items.length) { + helpers.addResult(results, 2, + 'No request validators found for API Gateway Rest API', + region, apiArn); + } else { + helpers.addResult(results, 0, + 'Request validators found for API Gateway Rest API', + region, apiArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayRequestValidaton.spec.js b/plugins/aws/apigateway/apigatewayRequestValidaton.spec.js new file mode 100644 index 000000000..debe9d3f9 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayRequestValidaton.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var apigatewayRequestValidation = require('./apigatewayRequestValidation'); + +const createCache = (getRestApisData, getRequestValidatorsData) => { + if (getRestApisData && getRestApisData.length && getRestApisData[0].id) var restApiId = getRestApisData[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: getRestApisData + } + }, + getRequestValidators: { + 'us-east-1': { + [restApiId]: { + data: { + items: getRequestValidatorsData + } + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Rest APIs' + }, + }, + }, + getRequestValidators: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway Request Validators' + }, + }, + } + + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getRequestValidators: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayRequestValidation', function () { + describe('run', function () { + it('should return UNKNOWN if unable to query for API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayRequestValidation.run(cache, {} , (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('Unable to query for API Gateway Rest APIs:'); + done(); + }); + }); + + it('should return PASS if no API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayRequestValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No API Gateway Rest APIs found'); + done(); + }); + }); + + it('should return FAIL if no request validators exist for API Gateway Rest API', function (done) { + const getRestApisData = [ + { + id: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getRequestValidatorsData = []; + const cache = createCache(getRestApisData, getRequestValidatorsData); + apigatewayRequestValidation.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No request validators found for API Gateway Rest API'); + done(); + }); + }); + + it('should return PASS if validators exist for API Gateway Rest API', function (done) { + const getRestApisData = [ + { + id: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getRequestValidatorsData = [ + [ + { + id: "70wn19", + name: "Validate body", + validateRequestBody: true, + validateRequestParameters: false, + }, + { + id: "z06eap", + name: "Validate query string parameters and headers", + validateRequestBody: false, + validateRequestParameters: true, + }, + ] + ]; + const cache = createCache(getRestApisData, getRequestValidatorsData); + apigatewayRequestValidation.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Request validators found for API Gateway Rest API') + done(); + }); + }); + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayRequestValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/apigateway/apigatewayResponseCaching.js b/plugins/aws/apigateway/apigatewayResponseCaching.js new file mode 100644 index 000000000..b7987c48f --- /dev/null +++ b/plugins/aws/apigateway/apigatewayResponseCaching.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Response Caching', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that response caching is enabled for your Amazon API Gateway REST APIs.', + more_info: 'A REST API in API Gateway is a collection of resources and methods that are integrated with backend HTTP endpoints, Lambda functions, or other AWS services.You can enable API caching in Amazon API Gateway to cache your endpoint responses. ' + + 'With caching, you can reduce the number of calls made to your endpoint and also improve the latency of requests to your API.', + recommended_action: 'Modify API Gateway API stages to enable API cache', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:DeleteRestApi','apigateway:CreateStage','apigateway:UpdateStage','apigateway:DeleteStage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway rest APIs found', region); + return rcb(); + } + + for (let api of getRestApis.data){ + if (!api.id) continue; + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for API Gateway rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + continue; + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No rest API Stages found', + region, apiArn); + continue; + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + if (stage.cacheClusterEnabled) { + helpers.addResult(results, 0, + 'Response caching is enabled for API Gateway API stage', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'Response caching is not enabled for API Gateway API stage', + region, stageArn); + } + }); + } + + rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayResponseCaching.spec.js b/plugins/aws/apigateway/apigatewayResponseCaching.spec.js new file mode 100644 index 000000000..6245f5cbb --- /dev/null +++ b/plugins/aws/apigateway/apigatewayResponseCaching.spec.js @@ -0,0 +1,241 @@ +var expect = require('chai').expect; +var apigatewayResponseCaching = require('./apigatewayResponseCaching'); + +const getRestApis = [ + { + "id": "mr5lhq7we8", + "name": "FoodLeaStack", + "createdDate": "2022-02-25T17:26:23+05:00", + "version": "1.0", + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "EDGE" + ] + }, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApi", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:000011112222S:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "disableExecuteApiEndpoint": false + }, + { + "id": "rkmadwkbe4", + "name": "FoodLeaAPIFunction-API", + "description": "Created by AWS Lambda", + "createdDate": "2021-11-23T22:09:15+05:00", + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "disableExecuteApiEndpoint": false + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "7wo1i4", + "stageName": "Prod", + "cacheClusterEnabled": true, + "cacheClusterSize": "0.5", + "cacheClusterStatus": "AVAILABLE", + "methodSettings": { + "*/*": { + "metricsEnabled": false, + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": true, + "cacheTtlInSeconds": 0, + "cacheDataEncrypted": true, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "tracingEnabled": false, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApiProdStage", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:000011112222S:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "createdDate": "2022-02-25T17:26:32+05:00", + "lastUpdatedDate": "2022-03-08T13:27:14+05:00" + }, + ] + }, + { + "item": [ + { + "deploymentId": "7wo1i4", + "stageName": "Prod", + "cacheClusterEnabled": false, + "cacheClusterSize": "0.5", + "cacheClusterStatus": "AVAILABLE", + "methodSettings": { + "*/*": { + "metricsEnabled": false, + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": true, + "cacheTtlInSeconds": 0, + "cacheDataEncrypted": false, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "tracingEnabled": false, + "tags": { + "AWSServerlessAppNETCore": "true", + "aws:cloudformation:logical-id": "ServerlessRestApiProdStage", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-1:000011112222S:stack/FoodLeaStack/0cffbdd0-9636-11ec-be95-062821a5a16f", + "aws:cloudformation:stack-name": "FoodLeaStack" + }, + "createdDate": "2022-02-25T17:26:32+05:00", + "lastUpdatedDate": "2022-03-08T13:27:14+05:00" + }, + ] + }, + +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayResponseCaching', function () { + describe('run', function () { + it('should PASS if Response caching is enabled for API Gateway API stage', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Response caching is enabled for API Gateway API stage') + done(); + }); + }); + + it('should FAIL if Response caching is not enabled for API Gateway API stage', function (done) { + const cache = createCache([getRestApis[1]], getStages[1]); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Response caching is not enabled for API Gateway API stage') + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No API Gateway rest APIs found') + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item: []}); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No rest API Stages found') + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for API Gateway rest APIs') + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for API Gateway rest API Stages') + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayResponseCaching.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayTracingEnabled.js b/plugins/aws/apigateway/apigatewayTracingEnabled.js new file mode 100644 index 000000000..468a783b5 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayTracingEnabled.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Tracing Enabled', + category: 'API Gateway', + domain: 'Availability', + severity: 'Low', + description: 'Ensures that Amazon API Gateway API stages have tracing enabled for AWS X-Ray.', + more_info: 'API Gateway API stages should have tracing enabled to send traces to AWS X-Ray for enhanced distributed tracing.', + recommended_action: 'Enable tracing on API Gateway API stages', + link: 'https://docs.aws.amazon.com/xray/latest/devguide/xray-services-apigateway.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:DeleteRestApi','apigateway:CreateStage','apigateway:UpdateStage','apigateway:DeleteStage'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, + 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, cb){ + if (!api.id) return cb(); + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return cb(); + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, 'No Rest API Stages found', region, apiArn); + return cb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + if (stage.tracingEnabled) { + helpers.addResult(results, 0, + 'API Gateway API stage has tracing enabled', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway API stage does not have tracing enabled', + region, stageArn); + } + }); + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayTracingEnabled.spec.js b/plugins/aws/apigateway/apigatewayTracingEnabled.spec.js new file mode 100644 index 000000000..1bb1d0b00 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayTracingEnabled.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var apigatewayTracingEnabled = require('./apigatewayTracingEnabled'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": true, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayTracingEnabled', function () { + describe('run', function () { + it('should PASS if API Gateway API has tracing enabled for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API does not have tracing enabled for stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[1]); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item : []}); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayV2AccessLogging.js b/plugins/aws/apigateway/apigatewayV2AccessLogging.js new file mode 100644 index 000000000..bf5b107d8 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayV2AccessLogging.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway V2 Access Logging', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Amazon API Gateway V2 API stages have access logging enabled.', + more_info: 'API Gateway V2 access logs provide detailed information about APIs and how the caller accessed the API. These logs are useful for security and access audits which helps to analyze traffic patterns and troubleshoot issues.', + recommended_action: 'Modify API Gateway V2 configuration and ensure that access logging is configured for each stage.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-logging.html', + apis: ['ApiGatewayV2:getApis','ApiGatewayV2:getStages'], + realtime_triggers: ['ApiGatewayV2:createApi','ApiGatewayV2:deleteApi','ApiGatewayV2:importApi','ApiGatewayv2:CreateStage','ApiGatewayv2:UpdateStage','ApiGatewayv2:DeleteStage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigatewayv2, function(region, rcb){ + var getApis = helpers.addSource(cache, source, + ['apigatewayv2', 'getApis', region]); + + if (!getApis) return rcb(); + + if (getApis.err || !getApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway V2 APIs: ${helpers.addError(getApis)}`, region); + return rcb(); + } + + if (!getApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway V2 APIs found', region); + return rcb(); + } + + getApis.data.forEach(api => { + if (!api.ApiId) return; + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/apis/${api.ApiId}`; + + var getStages = helpers.addSource(cache, source, + ['apigatewayv2', 'getStages', region, api.ApiId]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.Items) { + helpers.addResult(results, 3, + `Unable to query for API Gateway V2 API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return; + } + + if (!getStages.data.Items.length) { + helpers.addResult(results, 0, + 'No API Gateway V2 API Stages found', + region, apiArn); + return; + } + + getStages.data.Items.forEach(stage => { + if (!stage.StageName) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/apis/${api.ApiId}/stages/${stage.StageName}`; + if (stage.AccessLogSetting) { + helpers.addResult(results, 0, + 'API Gateway V2 API stage has access logging enabled', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway V2 API stage does not have access logging enabled', + region, stageArn); + } + }); + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayV2AccessLogging.spec.js b/plugins/aws/apigateway/apigatewayV2AccessLogging.spec.js new file mode 100644 index 000000000..f6dd1a6cc --- /dev/null +++ b/plugins/aws/apigateway/apigatewayV2AccessLogging.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var apigatewayV2AccessLogging = require('./apigatewayV2AccessLogging'); + +const createCache = (getApis, getStages) => { + if (getApis && getApis.length && getApis[0].ApiId) var restApiId = getApis[0].ApiId; + return { + apigatewayv2: { + getApis: { + 'us-east-1': { + data: getApis + } + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: { + Items: getStages + } + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + apigatewayv2: { + getApis: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway v2 APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway v2 stages' + }, + }, + } + + }, + }; +}; + +const createUnknownForStage = (api) => { + return { + apigatewayv2: { + getApis: { + 'us-east-1': { + data: api + } + }, + getStages: { + 'us-east-1': 'err' + } + } + }; +}; + +describe('apigatewayV2AccessLogging', function () { + describe('run', function () { + it('should return UNKNOWN if unable to query for API Gateway v2 APIs', function (done) { + const cache = createErrorCache(); + apigatewayV2AccessLogging.run(cache, {} , (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('Unable to query for API Gateway V2 APIs:'); + done(); + }); + }); + + it('should return PASS if no API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayV2AccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No API Gateway V2 APIs found'); + done(); + }); + }); + + it('should return PASS if no stages found', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getStages = []; + const cache = createCache(getApis, getStages); + apigatewayV2AccessLogging.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No API Gateway V2 API Stages found'); + done(); + }); + }); + + it('should return PASS if Api gateway v2 stage has access logging enabled', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getStages = [ + { + "AutoDeploy": true, + "CreatedDate": "2023-12-11T20:07:28.000Z", + "DefaultRouteSettings": { + "DetailedMetricsEnabled": false + }, + "DeploymentId": "biw5qf", + "Description": "Created by AWS Lambda", + "LastDeploymentStatusMessage": "Successfully deployed stage with deployment ID 'biw5qf'", + "LastUpdatedDate": "2023-12-11T20:07:29.000Z", + "RouteSettings": {}, + "StageName": "default", + "StageVariables": {}, + "Tags": {}, + "AccessLogSetting": { + "LogArn": "arn:aws:1234:log" + } + } + ]; + const cache = createCache(getApis, getStages); + apigatewayV2AccessLogging.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('API Gateway V2 API stage has access logging enabled') + done(); + }); + }); + + it('should return PASS if Api gateway v2 stage has access logging enabled', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getStages = [ + { + "AutoDeploy": true, + "CreatedDate": "2023-12-11T20:07:28.000Z", + "DefaultRouteSettings": { + "DetailedMetricsEnabled": false + }, + "DeploymentId": "biw5qf", + "Description": "Created by AWS Lambda", + "LastDeploymentStatusMessage": "Successfully deployed stage with deployment ID 'biw5qf'", + "LastUpdatedDate": "2023-12-11T20:07:29.000Z", + "RouteSettings": {}, + "StageName": "default", + "StageVariables": {}, + "Tags": {}, + } + ]; + const cache = createCache(getApis, getStages); + apigatewayV2AccessLogging.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('API Gateway V2 API stage does not have access logging enabled') + done(); + }); + }); + + it('should return UNKNOWN if unable to query for api stages', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + + const cache = createUnknownForStage(getApis); + apigatewayV2AccessLogging.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for API Gateway V2 API Stages:') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/apigateway/apigatewayV2Authorization.js b/plugins/aws/apigateway/apigatewayV2Authorization.js new file mode 100644 index 000000000..974166533 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayV2Authorization.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway V2 Authorization', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Amazon API Gateway V2 APIs are using authorizer.', + more_info: 'API Gateway V2 APIs should be configured to use authorizer to enforce security measures and restrict access to API to only authorized users or processes.', + recommended_action: 'Modify API Gateway V2 configuration and ensure that appropriate authorizers are set up for each API.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html', + apis: ['ApiGatewayV2:getApis','ApiGatewayV2:getAuthorizers'], + realtime_triggers: ['ApiGatewayV2:createApi','ApiGatewayV2:deleteApi','ApiGatewayV2:importApi','ApiGatewayV2:createAuthorizer','ApiGatewayV2:deleteAuthorizer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigatewayv2, function(region, rcb){ + var getApis = helpers.addSource(cache, source, + ['apigatewayv2', 'getApis', region]); + + if (!getApis) return rcb(); + + if (getApis.err || !getApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway V2 APIs: ${helpers.addError(getApis)}`, region); + return rcb(); + } + + if (!getApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway V2 APIs found', region); + return rcb(); + } + + getApis.data.forEach(api => { + if (!api.ApiId) return; + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/apis/${api.ApiId}`; + + var getAuthorizers = helpers.addSource(cache, source, + ['apigatewayv2', 'getAuthorizers', region, api.ApiId]); + + if (!getAuthorizers || getAuthorizers.err || !getAuthorizers.data || !getAuthorizers.data.Items) { + helpers.addResult(results, 3, + `Unable to query for API Gateway V2 authorizers: ${helpers.addError(getAuthorizers)}`, + region, apiArn); + return; + } + + if (!getAuthorizers.data.Items.length) { + helpers.addResult(results, 2, + 'No authorizers found for API Gateway V2 API', + region, apiArn ); + } else { + helpers.addResult(results, 0, + 'Authorizers found for API Gateway V2 API', + region, apiArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + \ No newline at end of file diff --git a/plugins/aws/apigateway/apigatewayV2Authorization.spec.js b/plugins/aws/apigateway/apigatewayV2Authorization.spec.js new file mode 100644 index 000000000..0762e9fac --- /dev/null +++ b/plugins/aws/apigateway/apigatewayV2Authorization.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var apigatewayV2Authorization = require('./apigatewayV2Authorization'); + +const createCache = (getApis, getAuthorizersData) => { + if (getApis && getApis.length && getApis[0].ApiId) var restApiId = getApis[0].ApiId; + return { + apigatewayv2: { + getApis: { + 'us-east-1': { + data: getApis + } + }, + getAuthorizers: { + 'us-east-1': { + [restApiId]: { + data: { + Items: getAuthorizersData + } + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + apigatewayv2: { + getApis: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway v2 APIs' + }, + }, + }, + getAuthorizers: { + 'us-east-1': { + err: { + message: 'error fetching API Gateway v2 Authorizers' + }, + }, + } + + }, + }; +}; + +const createNullCache = () => { + return { + apigatewayv2: { + getApis: { + 'us-east-1': null + }, + getAuthorizers: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayV2Authorization', function () { + describe('run', function () { + it('should return UNKNOWN if unable to query for API Gateway v2 APIs', function (done) { + const cache = createErrorCache(); + apigatewayV2Authorization.run(cache, {} , (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('Unable to query for API Gateway V2 APIs:'); + done(); + }); + }); + + it('should return PASS if no API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayV2Authorization.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No API Gateway V2 APIs found'); + done(); + }); + }); + + it('should return FAIL if no authorizers exist for API Gateway Rest API', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getAuthorizersData = []; + const cache = createCache(getApis, getAuthorizersData); + apigatewayV2Authorization.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect (results[0].message).to.include('No authorizers found for API Gateway V2 API'); + done(); + }); + }); + + it('should return PASS if authorizers exist for API Gateway V2 API', function (done) { + const getApis = [ + { + ApiId: 'api-id', + name: 'TestAPI', + description: 'Test API', + createdDate: 1621916018, + apiKeySource: 'HEADER', + endpointConfiguration: { + types: ['REGIONAL'] + } + } + ]; + const getAuthorizersData = [ + { + name: 'authorizer1', + type: 'REQUEST', + authType: 'custom', + authorizerUri: 'arn:aws:lambda:us-east-1:123456789012:function:authorizer1', + identitySource: 'method.request.header.Authorization', + authorizerResultTtlInSeconds: 300 + }, + { + name: 'authorizer2', + type: 'REQUEST', + authType: 'custom', + authorizerUri: 'arn:aws:lambda:us-east-1:123456789012:function:authorizer2', + identitySource: 'method.request.header.Authorization', + authorizerResultTtlInSeconds: 300 + } + ]; + const cache = createCache(getApis, getAuthorizersData); + apigatewayV2Authorization.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Authorizers found for API Gateway V2 API') + done(); + }); + }); + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayV2Authorization.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/apigateway/apigatewayWafEnabled.js b/plugins/aws/apigateway/apigatewayWafEnabled.js new file mode 100644 index 000000000..fb4cac60d --- /dev/null +++ b/plugins/aws/apigateway/apigatewayWafEnabled.js @@ -0,0 +1,86 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway WAF Enabled', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that API Gateway APIs are associated with a Web Application Firewall.', + more_info: 'API Gateway APIs should be associated with a Web Application Firewall to ensure API security.', + recommended_action: 'Associate API Gateway API with Web Application Firewall', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-aws-waf.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:DeleteRestApi','apigateway:CreateStage','apigateway:DeleteStage','wafregional:AssociateWebACL'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, + 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, cb){ + if (!api.id) return cb(); + + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return cb(); + } + + if (!getStages.data.item || !getStages.data.item.length) { + helpers.addResult(results, 0, + 'No Rest API Stages found', + region, apiArn); + return cb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + if (stage.webAclArn) { + helpers.addResult(results, 0, + 'API Gateway Stage has WAF enable', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway Stage does not have WAF enabled', + region, stageArn); + } + }); + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/apigatewayWafEnabled.spec.js b/plugins/aws/apigateway/apigatewayWafEnabled.spec.js new file mode 100644 index 000000000..25c793124 --- /dev/null +++ b/plugins/aws/apigateway/apigatewayWafEnabled.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var apigatewayWafEnabled = require('./apigatewayWafEnabled'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('apigatewayWafEnabled', function () { + describe('run', function () { + it('should PASS if API Gateway API has WAF enabled for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API does not have WAF enabled for stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[1]); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], []); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + apigatewayWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/apigateway/customDomainTlsVersion.js b/plugins/aws/apigateway/customDomainTlsVersion.js new file mode 100644 index 000000000..693bd0398 --- /dev/null +++ b/plugins/aws/apigateway/customDomainTlsVersion.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Custom Domain TLS Version', + category: 'API Gateway', + domain: 'Availability', + severity: 'High', + description: 'Ensure API Gateway custom domains are using current minimum TLS version.', + more_info: 'A security policy is a predefined combination of minimum TLS version and cipher suite offered by Amazon API Gateway. Choose either a TLS version 1.2 or TLS version 1.0 security policy.', + recommended_action: 'Modify API Gateway custom domain security policy and specify new TLS version.', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-custom-domain-tls-version.html', + apis: ['APIGateway:getDomainNames'], + realtime_triggers: ['apigateway:CreateDomainName','apigateway:UpdateDomainName','apigateway:DeleteDomainName'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getDomainNames = helpers.addSource(cache, source, + ['apigateway', 'getDomainNames', region]); + + if (!getDomainNames) return rcb(); + + if (getDomainNames.err || !getDomainNames.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Custom Domain: ${helpers.addError(getDomainNames)}`, region); + return rcb(); + } + + if (!getDomainNames.data.length) { + helpers.addResult(results, 0, 'No API Gateway Custom Domains found', region); + return rcb(); + } + for (let domain of getDomainNames.data){ + if (!domain.domainName) continue; + + var domainArn = `arn:${awsOrGov}:apigateway:${region}::/domainnames/${domain.domainName}`; + + if (domain.securityPolicy && domain.securityPolicy === 'TLS_1_2'){ + helpers.addResult(results, 0, + `API Gateway Custom Domain is using current minimum TLS version ${domain.securityPolicy}`, region, domainArn); + } else { + helpers.addResult(results, 2, + `API Gateway Custom Domain is using deprecated TLS version ${domain.securityPolicy}`, region, domainArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/apigateway/customDomainTlsVersion.spec.js b/plugins/aws/apigateway/customDomainTlsVersion.spec.js new file mode 100644 index 000000000..f08545240 --- /dev/null +++ b/plugins/aws/apigateway/customDomainTlsVersion.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var apigatewayCustomDomainDeprecatedProtocol = require('./customDomainTlsVersion'); + +const getDomainNames = [ + { + "domainName": "www.customDomain.com", + "certificateUploadDate": "2022-11-10T15:24:06+05:00", + "regionalDomainName": "d-kblr8fvsx5.execute-api.us-east-1.amazonaws.com", + "regionalHostedZoneId": "Z1UJRXOUMOOFQ8", + "regionalCertificateArn": "arn:aws:acm:us-east-1:1223334444:certificate/000f-1111g-8888t", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "domainNameStatus": "AVAILABLE", + "securityPolicy": "TLS_1" + }, + { + "domainName": "www.customDomain.com", + "certificateUploadDate": "2022-11-10T15:24:06+05:00", + "regionalDomainName": "d-kblr8fvsx5.execute-api.us-east-1.amazonaws.com", + "regionalHostedZoneId": "Z1UJRXOUMOOFQ8", + "regionalCertificateArn": "arn:aws:acm:us-east-1:1223334444:certificate/000f-1111g-8888t", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + }, + "domainNameStatus": "AVAILABLE", + "securityPolicy": "TLS_1_2" + } +]; + +const createCache = (domains) => { + return { + apigateway: { + getDomainNames: { + 'us-east-1': { + data: domains + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getDomainNames: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Custom Domains' + }, + }, + }, + }, + }; +}; + +describe('apigatewayCustomDomainDeprecatedProtocol', function () { + describe('run', function () { + it('should PASS if No API Gateway Custom Domains found', function (done) { + const cache = createCache([]); + apigatewayCustomDomainDeprecatedProtocol.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No API Gateway Custom Domains found'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if API Gateway Custom Domain is using current minimum TLS version', function (done) { + const cache = createCache([getDomainNames[1]]); + apigatewayCustomDomainDeprecatedProtocol.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is using current minimum TLS version'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway Custom Domain is using deprecated TLS version', function (done) { + const cache = createCache([getDomainNames[0]]); + apigatewayCustomDomainDeprecatedProtocol.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is using deprecated TLS version'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for API Gateways', function (done) { + const cache = createErrorCache(); + apigatewayCustomDomainDeprecatedProtocol.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for API Gateway Custom Domain'); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/apigateway/detailedCloudWatchMetrics.js b/plugins/aws/apigateway/detailedCloudWatchMetrics.js new file mode 100644 index 000000000..013112816 --- /dev/null +++ b/plugins/aws/apigateway/detailedCloudWatchMetrics.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway Detailed CloudWatch Metrics', + category: 'API Gateway', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that API Gateway API stages have detailed CloudWatch metrics enabled.', + more_info: 'API Gateway API stages should have detailed CloudWatch metrics enabled to monitor logs and events.', + recommended_action: 'Add CloudWatch role ARN to API settings and enabled detailed metrics for each stage', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-metrics.html', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + realtime_triggers: ['apigateway:CreateRestApi','apigateway:ImportRestApi','apigateway:DeleteRestApi','apigateway:CreateStage','apigateway:UpdateStage','apigateway:DeleteStage'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.apigateway, function(region, rcb){ + var getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!getRestApis) return rcb(); + + if (getRestApis.err || !getRestApis.data) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest APIs: ${helpers.addError(getRestApis)}`, region); + return rcb(); + } + + if (!getRestApis.data.length) { + helpers.addResult(results, 0, 'No API Gateway Rest APIs found', region); + return rcb(); + } + + async.each(getRestApis.data, function(api, cb){ + if (!api.id) return cb(); + var apiArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}`; + + var getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + if (!getStages || getStages.err || !getStages.data || !getStages.data.item) { + helpers.addResult(results, 3, + `Unable to query for API Gateway Rest API Stages: ${helpers.addError(getStages)}`, + region, apiArn); + return cb(); + } + + if (!getStages.data.item.length) { + helpers.addResult(results, 0, + 'No Rest API Stages found', + region, apiArn); + return cb(); + } + + getStages.data.item.forEach(stage => { + if (!stage.stageName) return; + var stageArn = `arn:${awsOrGov}:apigateway:${region}::/restapis/${api.id}/stages/${stage.stageName}`; + + if (stage.methodSettings && stage.methodSettings['*/*'] && stage.methodSettings['*/*'].metricsEnabled) { + helpers.addResult(results, 0, + 'API Gateway API stage has detailed CloudWatch metrics enabled', + region, stageArn); + } else { + helpers.addResult(results, 2, + 'API Gateway API stage does not have detailed CloudWatch metrics enabled', + region, stageArn); + } + }); + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apigateway/detailedCloudWatchMetrics.spec.js b/plugins/aws/apigateway/detailedCloudWatchMetrics.spec.js new file mode 100644 index 000000000..e00842bcb --- /dev/null +++ b/plugins/aws/apigateway/detailedCloudWatchMetrics.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +var detailedCloudWatchMetrics = require('./detailedCloudWatchMetrics'); + +const getRestApis = [ + { + "id": "98mjrkp8ia", + "name": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "createdDate": 1604621029, + "apiKeySource": "HEADER", + "endpointConfiguration": { + "types": [ + "REGIONAL" + ] + } + } +]; + +const getStages = [ + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": { + '*/*': { + "metricsEnabled": true, + "loggingLevel": "ERROR", + "dataTraceEnabled": false, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "cachingEnabled": false, + "cacheTtlInSeconds": 300, + "cacheDataEncrypted": false, + "requireAuthorizationForCacheControl": true, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + }, + }, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + "webAclArn": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-waf/ca44237b-b1d8-46b2-abad-ada48c7f0894", + } + ] + }, + { + "item": [ + { + "deploymentId": "se8o93", + "stageName": "dev", + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": false, + "createdDate": 1604621158, + "lastUpdatedDate": 1604621158, + } + ] + } +]; + + +const createCache = (apis, stages) => { + if (apis && apis.length && apis[0].id) var restApiId = apis[0].id; + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: apis + }, + }, + getStages: { + 'us-east-1': { + [restApiId]: { + data: stages + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest APIs' + }, + }, + }, + getStages: { + 'us-east-1': { + err: { + message: 'error getting API Gateway Rest API Stages' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + apigateway: { + getRestApis: { + 'us-east-1': null + }, + getStages: { + 'us-east-1': null + } + } + }; +}; + +describe('detailedCloudWatchMetrics', function () { + describe('run', function () { + it('should PASS if API Gateway API has detailed CloudWatch metrics enabled for all stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[0]); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if API Gateway API does not have detailed CloudWatch metrics enabled for stages', function (done) { + const cache = createCache([getRestApis[0]], getStages[1]); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No API Gateway Rest APIs found', function (done) { + const cache = createCache([]); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No API Gateway Rest API Stages found', function (done) { + const cache = createCache([getRestApis[0]], {item: []}); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest APIs', function (done) { + const cache = createErrorCache(); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get API Gateway Rest API Stages', function (done) { + const cache = createCache([getRestApis[0]], null); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get Rest APIs response is not found', function (done) { + const cache = createNullCache(); + detailedCloudWatchMetrics.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/appflow/flowEncrypted.js b/plugins/aws/appflow/flowEncrypted.js new file mode 100644 index 000000000..557c69a78 --- /dev/null +++ b/plugins/aws/appflow/flowEncrypted.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AppFlow Flow Encrypted', + category: 'AppFlow', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that your Amazon AppFlow flows are encrypted with desired encryption level.', + more_info: 'Amazon AppFlow encrypts your access tokens, secret keys, and data in transit and data at rest with AWS-manager keys by default. ' + + 'Encrypt them using customer-managed keys in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create AppFlow flows with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/appflow/latest/userguide/data-protection.html', + apis: ['Appflow:listFlows', 'Appflow:describeFlow', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + appflow_flow_encryption_level: { + name: 'AppFlow flow Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awscmk', + } + }, + realtime_triggers: ['appflow:CreateFlow','appflow:DeleteFlow'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.appflow_flow_encryption_level || this.settings.appflow_flow_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.appflow, function(region, rcb){ + var listFlows = helpers.addSource(cache, source, + ['appflow', 'listFlows', region]); + + if (!listFlows) return rcb(); + + if (listFlows.err || !listFlows.data) { + helpers.addResult(results, 3, + `Unable to list AppFlow flows: ${helpers.addError(listFlows)}`, region); + return rcb(); + } + + if (!listFlows.data.length) { + helpers.addResult(results, 0, + 'No AppFlow flows found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let flow of listFlows.data) { + if (!flow.flowName || !flow.flowArn) continue; + + let resource = flow.flowArn; + + let describeFlow = helpers.addSource(cache, source, + ['appflow', 'describeFlow', region, flow.flowName]); + + if (!describeFlow || describeFlow.err || !describeFlow.data) { + helpers.addResult(results, 3, + `Unable to describe flow: ${helpers.addError(describeFlow)}`, region, resource); + continue; + } + + if (describeFlow.data.kmsArn) { + var kmsKeyId = describeFlow.data.kmsArn.split('/')[1] ? describeFlow.data.kmsArn.split('/')[1] : describeFlow.data.kmsArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, describeFlow.data.kmsArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `AppFlow flow is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `AppFlow flow is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/appflow/flowEncrypted.spec.js b/plugins/aws/appflow/flowEncrypted.spec.js new file mode 100644 index 000000000..266c5d00e --- /dev/null +++ b/plugins/aws/appflow/flowEncrypted.spec.js @@ -0,0 +1,288 @@ +var expect = require('chai').expect; +var flowEncrypted = require('./flowEncrypted'); + +const listFlows = [ + { + "flowArn": "arn:aws:appflow:us-east-1:000011112222:flow/test-flow", + "description": null, + "flowName": "test-flow", + "flowStatus": "Active", + "sourceConnectorType": "S3", + "destinationConnectorType": "S3", + "triggerType": "OnDemand", + "createdAt": "2021-11-08T14:13:50.894Z", + "lastUpdatedAt": "2021-11-08T14:25:30.775Z", + "createdBy": "arn:aws:iam::000011112222:user/khulnasoft.cloudexploit", + "lastUpdatedBy": "arn:aws:iam::000011112222:user/khulnasoft.cloudexploit", + "tags": {} + } +]; + +const describeFlow = [ + { + "flowArn": "arn:aws:appflow:us-east-1:000011112222:flow/test-flow", + "description": null, + "flowName": "test-flow", + "kmsArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "flowStatus": "Active", + "flowStatusMessage": null, + "sourceFlowConfig": { + "connectorType": "S3", + "connectorProfileName": null, + "sourceConnectorProperties": { + "S3": { + "bucketName": "test-data-bucket", + "bucketPrefix": "data/" + } + } + }, + "destinationFlowConfigList": [ + { + "connectorType": "S3", + "connectorProfileName": null, + "destinationConnectorProperties": { + "S3": { + "bucketName": "test-data-bucket", + "bucketPrefix": "data", + "s3OutputFormatConfig": { + "fileType": "JSON", + "prefixConfig": { + "prefixType": null, + "prefixFormat": null + }, + "aggregationConfig": { + "aggregationType": "None" + } + } + } + } + } + ], + "triggerConfig": { + "triggerType": "OnDemand", + "triggerProperties": {} + }, + "tasks": [ + { + "sourceFields": [ + "uuid_id", + "timestamp" + ], + "connectorOperator": { + "Amplitude": null, + "Datadog": null, + "Dynatrace": null, + "GoogleAnalytics": null, + "InforNexus": null, + "Marketo": null, + "S3": "PROJECTION", + "Salesforce": null, + "ServiceNow": null, + "Singular": null, + "Slack": null, + "Trendmicro": null, + "Veeva": null, + "Zendesk": null, + "SAPOData": null + }, + "destinationField": null, + "taskType": "Filter", + "taskProperties": {} + }, + { + "sourceFields": [ + "uuid_id" + ], + "connectorOperator": { + "Amplitude": null, + "Datadog": null, + "Dynatrace": null, + "GoogleAnalytics": null, + "InforNexus": null, + "Marketo": null, + "S3": "NO_OP", + "Salesforce": null, + "ServiceNow": null, + "Singular": null, + "Slack": null, + "Trendmicro": null, + "Veeva": null, + "Zendesk": null, + "SAPOData": null + }, + "destinationField": "uuid_id", + "taskType": "Map", + "taskProperties": {} + }, + { + "sourceFields": [ + "timestamp" + ], + "connectorOperator": { + "Amplitude": null, + "Datadog": null, + "Dynatrace": null, + "GoogleAnalytics": null, + "InforNexus": null, + "Marketo": null, + "S3": "NO_OP", + "Salesforce": null, + "ServiceNow": null, + "Singular": null, + "Slack": null, + "Trendmicro": null, + "Veeva": null, + "Zendesk": null, + "SAPOData": null + }, + "destinationField": "timestamp", + "taskType": "Map", + "taskProperties": {} + } + ], + "createdAt": "2021-11-08T14:13:50.894Z", + "lastUpdatedAt": "2021-11-08T14:25:30.775Z", + "createdBy": "arn:aws:iam::000011112222:user/khulnasoft.cloudexploit", + "lastUpdatedBy": "arn:aws:iam::000011112222:user/khulnasoft.cloudexploit", + "tags": {} + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (flows, keys, describeFlow, describeKey, flowsErr, keysErr, describeKeyErr, describeFlowErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + var flowName = (flows && flows.length) ? flows[0].flowName: null; + return { + appflow: { + listFlows: { + 'us-east-1': { + err: flowsErr, + data: flows + }, + }, + describeFlow: { + 'us-east-1': { + [flowName]: { + data: describeFlow, + err: describeFlowErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('flowEncrypted', function () { + describe('run', function () { + it('should PASS if AppFlow flow is encrypted with desired encryption level', function (done) { + const cache = createCache(listFlows, listKeys, describeFlow[0], describeKey[0]); + flowEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if AppFlow flow is not encrypted with desired encryption level', function (done) { + const cache = createCache(listFlows, listKeys, describeFlow[0], describeKey[1]); + flowEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no AppFlow flows found', function (done) { + const cache = createCache([]); + flowEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list AppFlow flows', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list AppFlow flows" }); + flowEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listFlows, null, null, null, { message: "Unable to list KMS keys" }); + flowEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/appmesh/appmeshTLSRequired.js b/plugins/aws/appmesh/appmeshTLSRequired.js new file mode 100644 index 000000000..07fe9e22b --- /dev/null +++ b/plugins/aws/appmesh/appmeshTLSRequired.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App Mesh TLS Required', + category: 'App Mesh', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS App Mesh virtual gateway listeners only accepts TLS enabled connections.', + more_info: 'In App Mesh, Transport Layer Security (TLS) encrypts communication between the envoy proxies deployed on compute resources that are represented in App Mesh by mesh endpoints, such as Virtual nodes and Virtual gateways.', + link: 'https://docs.aws.amazon.com/app-mesh/latest/APIReference/API_ListenerTls.html', + recommended_action: 'Restrict AWS App Mesh virtual gateway listeners to accept only TLS enabled connections.', + apis: ['AppMesh:listMeshes', 'AppMesh:listVirtualGateways', 'AppMesh:describeVirtualGateway'], + realtime_triggers: ['appmesh:CreateMesh','appmesh:DeleteMesh','appmesh:CreateVirtualGateway','appmesh:UpdateVirtualGateway','appmesh:DeleteVirtualGateway'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + async.each(region.appmesh, function(region, rcb){ + var listMeshes = helpers.addSource(cache, source, + ['appmesh', 'listMeshes', region]); + + if (!listMeshes) return rcb(); + + if (listMeshes.err || !listMeshes.data) { + helpers.addResult(results, 3, + 'Unable to list App Mesh meshes: ' + helpers.addError(listMeshes),region); + return rcb(); + } + + if (!listMeshes.data.length) { + helpers.addResult(results, 0, 'No App Mesh meshes found', region); + return rcb(); + } + + for (let mesh of listMeshes.data){ + if (!mesh.meshName || !mesh.arn) continue; + + let resource = mesh.arn; + + var listVirtualGateways = helpers.addSource(cache, source, + ['appmesh', 'listVirtualGateways', region, mesh.meshName]); + + if (!listVirtualGateways || listVirtualGateways.err || !listVirtualGateways.data) { + helpers.addResult(results, 3, + 'Unable to list App Mesh virtual gateways: ' + mesh.meshName + ': ' + helpers.addError(listVirtualGateways), + region, resource); + continue; + } + + if (!listVirtualGateways.data.virtualGateways || !listVirtualGateways.data.virtualGateways.length) { + helpers.addResult(results, 0, + 'No App Mesh virtual gateways found', region, resource); + continue; + } + + for (let gateway of listVirtualGateways.data.virtualGateways) { + if (!gateway.virtualGatewayName || !gateway.arn) continue; + + var gatewayArn = gateway.arn; + + var describeVirtualGateway = helpers.addSource(cache, source, + ['appmesh', 'describeVirtualGateway', region, gateway.virtualGatewayName]); + + if (!describeVirtualGateway || + describeVirtualGateway.err || + !describeVirtualGateway.data) { + helpers.addResult(results, 3, + 'Unable to describe App Mesh virtual gateway: ' + helpers.addError(describeVirtualGateway), region, gatewayArn); + continue; + } + + if (!describeVirtualGateway.data.virtualGateway.spec || + !describeVirtualGateway.data.virtualGateway.spec.listeners || + !describeVirtualGateway.data.virtualGateway.spec.listeners.length) { + helpers.addResult(results, 0, + 'App Mesh virtual gateway does not have listeners', region, gatewayArn); + } else { + const tlsEnabled = describeVirtualGateway.data.virtualGateway.spec.listeners.every(listener => listener.tls && listener.tls.mode && listener.tls.mode.toUpperCase() === 'STRICT'); + const status = tlsEnabled ? 0 : 2; + helpers.addResult(results, status, + `App Mesh virtual gateway listeners ${tlsEnabled ? 'restrict' : 'does not restrict'} TLS enabled connections`, + region, gatewayArn); + } + } + + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/appmesh/appmeshTLSRequired.spec.js b/plugins/aws/appmesh/appmeshTLSRequired.spec.js new file mode 100644 index 000000000..9226c001e --- /dev/null +++ b/plugins/aws/appmesh/appmeshTLSRequired.spec.js @@ -0,0 +1,267 @@ +const expect = require('chai').expect; +var appmeshTLSRequired = require('./appmeshTLSRequired'); + + +const listMeshes = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh1", + "createdAt": "2022-02-24T16:52:04.455000+05:00", + "lastUpdatedAt": "2022-02-24T16:52:04.455000+05:00", + "meshName": 'sadeed-mesh1', + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "d6e1ce9d-049d-42ec-9be5-3b8451b6c384", + "version": 1 + }, + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh2", + "createdAt": "2022-02-24T16:52:04.455000+05:00", + "lastUpdatedAt": "2022-02-24T16:52:04.455000+05:00", + "meshName": 'sadeed-mesh2', + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "d6e1ce9d-049d-42ec-9be5-3b8451b6c384", + "version": 1 + }, +]; + +const listVirtualGateways = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh1/virtualGateway/mine-vg-1", + "createdAt": "2022-02-24T16:59:37.097000+05:00", + "lastUpdatedAt": "2022-02-24T16:59:37.097000+05:00", + "meshName": "sadeed-mesh1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "mine-vg-1" + + }, + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh2/virtualGateway/mine-vg-2", + "createdAt": "2022-02-24T16:59:37.097000+05:00", + "lastUpdatedAt": "2022-02-24T16:59:37.097000+05:00", + "meshName": "sadeed-mesh2", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "mine-vg-2" + } +]; + +const describeVirtualGateway = [ + { + "virtualGateway": { + "meshName": "sadeed-mesh1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh1/virtualGateway/mine-vg-1", + "createdAt": "2022-02-24T16:59:37.097000+05:00", + "lastUpdatedAt": "2022-02-24T16:59:37.097000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "16759e86-7eff-428e-87bb-782598002446", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": { + "tls": { + "enforce": false, + "ports": [], + "validation": { + "trust": { + "sds": { + "secretName": "hhjhj" + } + } + } + } + } + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + }, + "tls": { + "certificate": { + "sds": { + "secretName": "mine1" + } + }, + "mode": "STRICT" + } + } + ], + "logging": {} + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "mine-vg-1" + } + }, + { + "virtualGateway": { + "meshName": "sadeed-mesh2", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/sadeed-mesh2/virtualGateway/mine-vg-2", + "createdAt": "2022-02-24T16:59:37.097000+05:00", + "lastUpdatedAt": "2022-02-24T16:59:37.097000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "16759e86-7eff-428e-87bb-782598002446", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": { + "tls": { + "enforce": false, + "ports": [], + "validation": { + "trust": { + "sds": { + "secretName": "hhjhj" + } + } + } + } + } + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + }, + "tls": { + "certificate": { + "sds": { + "secretName": "mine1" + } + }, + "mode": "DISABLED" + } + } + ], + "logging": {} + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "mine-vg-2" + } + }, +]; + + +const createCache = (listMeshes, listVirtualGateways, describeVirtualGateway, listMeshesErr, listVirtualGatewaysErr, describeVirtualGatewayErr) => { + var name = (listMeshes && listMeshes.length) ? listMeshes[0].meshName : null; + var gatewayName = (listVirtualGateways && listVirtualGateways.length) ? listVirtualGateways[0].virtualGatewayName : null; + return { + appmesh: { + listMeshes: { + 'us-east-1': { + err: listMeshesErr, + data: listMeshes + } + }, + listVirtualGateways: { + 'us-east-1': { + [name]: { + err: listVirtualGatewaysErr, + data: { + "virtualGateways": listVirtualGateways + + } + + } + } + }, + describeVirtualGateway: { + 'us-east-1': { + [gatewayName]: { + err: describeVirtualGatewayErr, + data: describeVirtualGateway + } + } + }, + }, + }; +}; + +const createNullCache = () => { + return { + appmesh: { + listMeshes: { + 'us-east-1': null + } + } + }; +}; + +describe('appmeshTLSRequired', function () { + describe('run', function () { + it('should PASS if App Mesh virtual gateway listeners restrict TLS enabled connections', function (done) { + const cache = createCache([listMeshes[0]], [listVirtualGateways[0]], describeVirtualGateway[0]); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('App Mesh virtual gateway listeners restrict TLS enabled connections') + done(); + }); + }); + + it('should FAIL if App Mesh vitual gateway listeners does not restrict TLS enabled connections', function (done) { + const cache = createCache([listMeshes[1]], [listVirtualGateways[1]], describeVirtualGateway[1]); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('App Mesh virtual gateway listeners does not restrict TLS enabled connections') + done(); + }); + }); + + it('should PASS if no App Mesh meshes found', function (done) { + const cache = createCache([]); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Mesh meshes found') + done(); + }); + }); + + it('should UNKNOWN if Unable to list App Mesh meshes', function (done) { + const cache = createCache(null, null, null, { message: 'Unable to list App Mesh meshes'}); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list App Mesh meshes') + done(); + }); + }); + + it('should UNKNOWN if unable to list App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], [], describeVirtualGateway[0], null, { message: 'Unable to list App Mesh virtual gateways'}); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list App Mesh virtual gateways') + done(); + }); + }); + + it('should not return anything if list App Mesh meshes response not found', function (done) { + const cache = createNullCache(); + appmeshTLSRequired.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/appmesh/appmeshVGAccessLogging.js b/plugins/aws/appmesh/appmeshVGAccessLogging.js new file mode 100644 index 000000000..ee598ea7e --- /dev/null +++ b/plugins/aws/appmesh/appmeshVGAccessLogging.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App Mesh VG Access Logging', + category: 'App Mesh', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that your Amazon App Mesh virtual gateways have access logging enabled.', + more_info: 'Enabling access logging feature for App Mesh virtual gateways lets you track application mesh user access, helps you meet compliance regulations, and gives insight into security audits and investigations. ', + link: 'https://docs.aws.amazon.com/app-mesh/latest/userguide/envoy-logs.html', + recommended_action: 'To enable access logging, modify virtual gateway configuration settings and configure the file path to write access logs to.', + apis: ['AppMesh:listMeshes', 'AppMesh:listVirtualGateways', 'AppMesh:describeVirtualGateway'], + realtime_triggers: ['appmesh:CreateMesh','appmesh:DeleteMesh','appmesh:CreateVirtualGateway','appmesh:UpdateVirtualGateway','appmesh:DeleteVirtualGateway'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + async.each(region.appmesh, function(region, rcb){ + var listMeshes = helpers.addSource(cache, source, + ['appmesh', 'listMeshes', region]); + + if (!listMeshes) return rcb(); + + if (listMeshes.err || !listMeshes.data) { + helpers.addResult(results, 3, + 'Unable to query for App Mesh meshes: ' + helpers.addError(listMeshes),region); + return rcb(); + } + + if (!listMeshes.data.length) { + helpers.addResult(results, 0, 'No App Mesh meshes found', region); + return rcb(); + } + + for (let mesh of listMeshes.data){ + if (!mesh.arn || !mesh.meshName) continue; + + let resource = mesh.arn; + + var listVirtualGateways = helpers.addSource(cache, source, + ['appmesh', 'listVirtualGateways', region, mesh.meshName]); + + if (!listVirtualGateways || listVirtualGateways.err || !listVirtualGateways.data) { + helpers.addResult(results, 3, + 'Unable to query for App Mesh virtual gateways: ' + helpers.addError(listVirtualGateways), + region, resource); + continue; + } + + if (!listVirtualGateways.data.virtualGateways) { + helpers.addResult(results, 0, + 'No App Mesh virtual gateways found', region, resource); + continue; + } + + for (let gateway of listVirtualGateways.data.virtualGateways) { + if (!gateway.arn || !gateway.virtualGatewayName) continue; + + let resource = gateway.arn; + + var describeVirtualGateway = helpers.addSource(cache, source, + ['appmesh', 'describeVirtualGateway', region, gateway.virtualGatewayName]); + + if (!describeVirtualGateway || + describeVirtualGateway.err || + !describeVirtualGateway.data) { + helpers.addResult(results, 3, + 'Unable to describe App Mesh virtual gateway: ' + helpers.addError(describeVirtualGateway), region, resource); + continue; + } + + if (describeVirtualGateway.data.virtualGateway && + describeVirtualGateway.data.virtualGateway.spec && + describeVirtualGateway.data.virtualGateway.spec.logging && + describeVirtualGateway.data.virtualGateway.spec.logging.accessLog && + describeVirtualGateway.data.virtualGateway.spec.logging.accessLog.file && + describeVirtualGateway.data.virtualGateway.spec.logging.accessLog.file.path && + describeVirtualGateway.data.virtualGateway.spec.logging.accessLog.file.path.length) { + helpers.addResult(results, 0, + 'App Mesh virtual gateway has access logging enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'App Mesh virtual gateway does not have access logging enabled', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/appmesh/appmeshVGAccessLogging.spec.js b/plugins/aws/appmesh/appmeshVGAccessLogging.spec.js new file mode 100644 index 000000000..bc65a23f0 --- /dev/null +++ b/plugins/aws/appmesh/appmeshVGAccessLogging.spec.js @@ -0,0 +1,231 @@ +const expect = require('chai').expect; +var appmeshVGAccessLogging = require('./appmeshVGAccessLogging'); + +const listMeshes = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1", + "createdAt": "2022-01-11T18:09:34.022000+05:00", + "lastUpdatedAt": "2022-01-11T18:09:34.022000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1 + } +]; + +const listVirtualGateways = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number2", + "createdAt": "2022-01-11T19:45:12.805000+05:00", + "lastUpdatedAt": "2022-01-11T19:45:12.805000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "number2", + }, + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number1", + "createdAt": "2022-01-11T18:11:31.773000+05:00", + "lastUpdatedAt": "2022-01-11T18:11:31.773000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "number1" + } +]; + +const describeVirtualGateway = [ + { + "virtualGateway": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number2", + "createdAt": "2022-01-11T19:45:12.805000+05:00", + "lastUpdatedAt": "2022-01-11T19:45:12.805000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "19f47311-04d5-4ef4-b81c-eb931f50509a", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": {} + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + } + } + ], + "logging": {} + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "number2" + } + }, + { + "virtualGateway": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number1", + "createdAt": "2022-01-11T18:11:31.773000+05:00", + "lastUpdatedAt": "2022-01-11T18:11:31.773000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "3965139e-cc80-44b3-86ca-353ae83b5330", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": { + "tls": { + "enforce": false, + "ports": [], + "validation": { + "trust": { + "sds": { + "secretName": "hhjhj" + } + } + } + } + } + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + } + } + ], + "logging": { + "accessLog": { + "file": { + "path": "hhjukh" + } + } + } + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "number1" + } + } +]; + + +const createCache = (listMeshes, listVirtualGateways, describeVirtualGateway, listMeshesErr, listVirtualGatewaysErr, describeVirtualGatewayErr) => { + let name = (listMeshes && listMeshes.length) ? listMeshes[0].meshName : null; + let gatewayName = (listVirtualGateways && listVirtualGateways.length) ? listVirtualGateways[0].virtualGatewayName : null; + return { + appmesh: { + listMeshes: { + 'us-east-1': { + data: listMeshes, + err: listMeshesErr + } + }, + listVirtualGateways: { + 'us-east-1': { + [name]: { + data: { + "virtualGateways":listVirtualGateways + }, + err: listVirtualGatewaysErr, + } + } + }, + describeVirtualGateway: { + 'us-east-1': { + [gatewayName]: { + data: describeVirtualGateway, + err: describeVirtualGatewayErr + } + } + }, + }, + }; +}; + +const createNullCache = () => { + return { + appmesh: { + listMeshes: { + 'us-east-1': null + } + } + }; +}; + +describe('appmeshVGAccessLogging', function () { + describe('run', function () { + + it('should PASS if access logging is enabled and configured for Amazon App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], [listVirtualGateways[1]], describeVirtualGateway[1]); + appmeshVGAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Mesh virtual gateway has access logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if access logging is not enabled for Amazon App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], [listVirtualGateways[0]], describeVirtualGateway[0]); + appmeshVGAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Mesh virtual gateway does not have access logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No App Meshes found', function (done) { + const cache = createCache([]); + appmeshVGAccessLogging.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No App Mesh meshes found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for App Mesh meshes', function (done) { + const cache = createCache(null, null, null, { message: 'Unable to query for App Meshes'}); + appmeshVGAccessLogging.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Mesh meshes'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], null, describeVirtualGateway[0], null, { message: 'Unable to query for AppMesh virtual gateways'}); + appmeshVGAccessLogging.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Mesh virtual gateways'); + done(); + }); + }); + + it('should not return anything if list App Meshes response not found', function (done) { + const cache = createNullCache(); + appmeshVGAccessLogging.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/appmesh/appmeshVGHealthChecks.js b/plugins/aws/appmesh/appmeshVGHealthChecks.js new file mode 100644 index 000000000..38fc1e577 --- /dev/null +++ b/plugins/aws/appmesh/appmeshVGHealthChecks.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App Mesh VG Health Check Policies', + category: 'App Mesh', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Amazon App Mesh virtual gateways use health check policies to monitor the availability of virtual nodes.', + more_info: 'Health check policies in App Mesh are essential to maintain application availability and reliability by monitoring the health of associated virtual nodes.', + link: 'https://docs.aws.amazon.com/app-mesh/latest/userguide/virtual_gateway_health_checks.html', + recommended_action: 'Configure health check policies for the virtual gateway listeners in your App Mesh, specifying values for healthy threshold, health check interval, health check protocol, timeout period, and unhealthy threshold.', + apis: ['AppMesh:listMeshes', 'AppMesh:listVirtualGateways', 'AppMesh:describeVirtualGateway'], + realtime_triggers: ['appmesh:CreateMesh','appmesh:DeleteMesh','appmesh:CreateVirtualGateway','appmesh:UpdateVirtualGateway','appmesh:DeleteVirtualGateway'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + async.each(region.appmesh, function(region, rcb) { + var listMeshes = helpers.addSource(cache, source, + ['appmesh', 'listMeshes', region]); + + if (!listMeshes) return rcb(); + + if (listMeshes.err || !listMeshes.data) { + helpers.addResult(results, 3, + 'Unable to query for App Mesh meshes: ' + helpers.addError(listMeshes), region); + return rcb(); + } + + if (!listMeshes.data.length) { + helpers.addResult(results, 0, 'No App Mesh meshes found', region); + return rcb(); + } + + for (let mesh of listMeshes.data){ + if (!mesh.arn || !mesh.meshName) continue; + + let meshResource = mesh.arn; + + var listVirtualGateways = helpers.addSource(cache, source, + ['appmesh', 'listVirtualGateways', region, mesh.meshName]); + + if (!listVirtualGateways || listVirtualGateways.err || !listVirtualGateways.data) { + helpers.addResult(results, 3, + 'Unable to query for App Mesh virtual gateways: ' + helpers.addError(listVirtualGateways), + region, meshResource); + continue; + } + + if (!listVirtualGateways.data.virtualGateways || !listVirtualGateways.data.virtualGateways.length) { + helpers.addResult(results, 0, + 'No App Mesh virtual gateways found', region, meshResource); + continue; + } + + for (let gateway of listVirtualGateways.data.virtualGateways) { + if (!gateway.arn || !gateway.virtualGatewayName) continue; + + let gatewayResource = gateway.arn; + + var describeVirtualGateway = helpers.addSource(cache, source, + ['appmesh', 'describeVirtualGateway', region, gateway.virtualGatewayName]); + + if (!describeVirtualGateway || + describeVirtualGateway.err || + !describeVirtualGateway.data) { + helpers.addResult(results, 3, + 'Unable to describe App Mesh virtual gateway: ' + helpers.addError(describeVirtualGateway), region, gatewayResource); + continue; + } + if (!describeVirtualGateway.data.virtualGateway.spec || + !describeVirtualGateway.data.virtualGateway.spec.listeners || + !describeVirtualGateway.data.virtualGateway.spec.listeners.length) { + helpers.addResult(results, 0, + 'App Mesh virtual gateway does not have listeners', region, gatewayResource); + } else { + const listeners = describeVirtualGateway.data.virtualGateway.spec.listeners; + const hasHealthCheckPolicies = listeners.every(listener => { + return listener.healthCheck && Object.keys(listener.healthCheck).length; + }); + const status = hasHealthCheckPolicies ? 0 : 2; + helpers.addResult(results, status, + `App Mesh virtual gateway ${hasHealthCheckPolicies ? 'has' : 'does not have'} health check policies`, + region, gatewayResource); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/appmesh/appmeshVGHealthChecks.spec.js b/plugins/aws/appmesh/appmeshVGHealthChecks.spec.js new file mode 100644 index 000000000..8d07389b7 --- /dev/null +++ b/plugins/aws/appmesh/appmeshVGHealthChecks.spec.js @@ -0,0 +1,237 @@ +const expect = require('chai').expect; +var appmeshVGHealthChecks = require('./appmeshVGHealthChecks'); // Replace with your plugin filename + +const listMeshes = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1", + "createdAt": "2022-01-11T18:09:34.022000+05:00", + "lastUpdatedAt": "2022-01-11T18:09:34.022000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1 + } +]; + +const listVirtualGateways = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number2", + "createdAt": "2022-01-11T19:45:12.805000+05:00", + "lastUpdatedAt": "2022-01-11T19:45:12.805000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "number2", + }, + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number1", + "createdAt": "2022-01-11T18:11:31.773000+05:00", + "lastUpdatedAt": "2022-01-11T18:11:31.773000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1, + "virtualGatewayName": "number1" + } +]; + +const describeVirtualGateway = [ + { + "virtualGateway": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number2", + "createdAt": "2022-01-11T19:45:12.805000+05:00", + "lastUpdatedAt": "2022-01-11T19:45:12.805000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "19f47311-04d5-4ef4-b81c-eb931f50509a", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": {} + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + }, + "healthCheck": { + "protocol": "http", + "healthyThreshold": 2, + "intervalMillis": 5000, + "timeoutMillis": 2000, + "unhealthyThreshold": 2 + } + } + ], + "logging": {} + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "number2" + } + }, + { + "virtualGateway": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1/virtualGateway/number1", + "createdAt": "2022-01-11T18:11:31.773000+05:00", + "lastUpdatedAt": "2022-01-11T18:11:31.773000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "3965139e-cc80-44b3-86ca-353ae83b5330", + "version": 1 + }, + "spec": { + "backendDefaults": { + "clientPolicy": { + "tls": { + "enforce": false, + "ports": [], + "validation": { + "trust": { + "sds": { + "secretName": "hhjhj" + } + } + } + } + } + }, + "listeners": [ + { + "portMapping": { + "port": 1, + "protocol": "http" + } + } + ], + "logging": { + "accessLog": { + "file": { + "path": "hhjukh" + } + } + } + }, + "status": { + "status": "ACTIVE" + }, + "virtualGatewayName": "number1" + } + } +]; + +const createCache = (listMeshes, listVirtualGateways, describeVirtualGateway, listMeshesErr, listVirtualGatewaysErr, describeVirtualGatewayErr) => { + let name = (listMeshes && listMeshes.length) ? listMeshes[0].meshName : null; + let gatewayName = (listVirtualGateways && listVirtualGateways.length) ? listVirtualGateways[0].virtualGatewayName : null; + return { + appmesh: { + listMeshes: { + 'us-east-1': { + data: listMeshes, + err: listMeshesErr + } + }, + listVirtualGateways: { + 'us-east-1': { + [name]: { + data: { + "virtualGateways":listVirtualGateways + }, + err: listVirtualGatewaysErr, + } + } + }, + describeVirtualGateway: { + 'us-east-1': { + [gatewayName]: { + data: describeVirtualGateway, + err: describeVirtualGatewayErr + } + } + }, + }, + }; +}; + +const createNullCache = () => { + return { + appmesh: { + listMeshes: { + 'us-east-1': null + } + } + }; +}; + +describe('appmeshVGHealthChecks', function () { + describe('run', function () { + + it('should PASS if health check policies are configured for Amazon App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], [listVirtualGateways[0]], describeVirtualGateway[0]); + appmeshVGHealthChecks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Mesh virtual gateway has health check policies'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if health check policies are not configured for Amazon App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], [listVirtualGateways[1]], describeVirtualGateway[1]); + appmeshVGHealthChecks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Mesh virtual gateway does not have health check policies'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No App Meshes found', function (done) { + const cache = createCache([]); + appmeshVGHealthChecks.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No App Mesh meshes found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for App Mesh meshes', function (done) { + const cache = createCache(null, null, null, { message: 'Unable to query for App Meshes'}); + appmeshVGHealthChecks.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Mesh meshes'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for App Mesh virtual gateways', function (done) { + const cache = createCache([listMeshes[0]], null, describeVirtualGateway[0], null, { message: 'Unable to query for AppMesh virtual gateways'}); + appmeshVGHealthChecks.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Mesh virtual gateways'); + done(); + }); + }); + + it('should not return anything if list App Meshes response not found', function (done) { + const cache = createNullCache(); + appmeshVGHealthChecks.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/appmesh/restrictExternalTraffic.js b/plugins/aws/appmesh/restrictExternalTraffic.js new file mode 100644 index 000000000..4a894b724 --- /dev/null +++ b/plugins/aws/appmesh/restrictExternalTraffic.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App Mesh Restrict External Traffic', + category: 'App Mesh', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Amazon App Mesh virtual nodes have egress only access to other defined resources available within the service mesh.', + more_info: 'Amazon App Mesh gives you controls to choose whether or not to allow App Mesh services to communicate with outside world. ' + + 'If you choose to deny external traffic, the proxies will not forward traffic to external services not defined in the mesh. ' + + 'The traffic to the external services should be denied to adhere to cloud security best practices and minimize the security risks.', + link: 'https://docs.aws.amazon.com/app-mesh/latest/userguide/security.html', + recommended_action: 'Deny all traffic to the external services', + apis: ['AppMesh:listMeshes', 'AppMesh:describeMesh'], + realtime_triggers: ['appmesh:CreateMesh','appmesh:DeleteMesh','appmesh:UpdateMesh'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.appmesh, function(region, rcb){ + var listMeshes = helpers.addSource(cache, source, + ['appmesh', 'listMeshes', region]); + + if (!listMeshes) return rcb(); + + if (listMeshes.err || !listMeshes.data) { + helpers.addResult(results, 3, + `Unable to query for App Mesh meshes: ${helpers.addError(listMeshes)}`,region); + return rcb(); + } + + if (!listMeshes.data.length) { + helpers.addResult(results, 0, 'No App Mesh meshes found', region); + return rcb(); + } + + for (let mesh of listMeshes.data) { + if (!mesh.arn) continue; + + let resource = mesh.arn; + + var describeMesh = helpers.addSource(cache, source, + ['appmesh', 'describeMesh', region, mesh.meshName]); + + if (!describeMesh || describeMesh.err || !describeMesh.data || + !describeMesh.data.mesh) { + helpers.addResult(results, 3, + `Unable to describe App Mesh mesh: ${helpers.addError(describeMesh)}`, + region, resource); + continue; + } + + if (describeMesh.data.mesh.spec && + describeMesh.data.mesh.spec.egressFilter && + describeMesh.data.mesh.spec.egressFilter.type.toUpperCase() === 'ALLOW_ALL') { + helpers.addResult(results, 2, + 'App Mesh mesh allows access to external services', + region, resource); + } else { + helpers.addResult(results, 0, + 'App Mesh mesh does not allow access to external services', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/appmesh/restrictExternalTraffic.spec.js b/plugins/aws/appmesh/restrictExternalTraffic.spec.js new file mode 100644 index 000000000..6b44dd2b0 --- /dev/null +++ b/plugins/aws/appmesh/restrictExternalTraffic.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var restrictExternalTraffic = require('./restrictExternalTraffic'); + +const listMeshes = [ + { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1", + "createdAt": "2022-01-12T15:33:12.228000+05:00", + "lastUpdatedAt": "2022-01-12T15:33:12.228000+05:00", + "meshName": "mine1", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "version": 1 + } +]; + +const describeMesh = [ + { + "mesh": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1", + "createdAt": "2022-01-12T15:33:12.228000+05:00", + "lastUpdatedAt": "2022-01-12T15:33:12.228000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "e9812fab-3e03-49a5-aad8-4a0b54d873da", + "version": 1 + }, + "spec": { + "egressFilter": { + "type": "ALLOW_ALL" + } + }, + "status": { + "status": "ACTIVE" + } + } + }, + { + "mesh": { + "meshName": "mine1", + "metadata": { + "arn": "arn:aws:appmesh:us-east-1:000011112222:mesh/mine1", + "createdAt": "2022-01-12T15:33:12.228000+05:00", + "lastUpdatedAt": "2022-01-12T15:33:12.228000+05:00", + "meshOwner": "000011112222", + "resourceOwner": "000011112222", + "uid": "e9812fab-3e03-49a5-aad8-4a0b54d873da", + "version": 1 + }, + "spec": { + "egressFilter": { + "type": "DROP_ALL" + } + }, + "status": { + "status": "ACTIVE" + } + } + } +]; + + +const createCache = (mesh, describeMesh, meshErr, describeMeshErr) => { + var name = (mesh && mesh.length) ? mesh[0].meshName: null; + return { + appmesh: { + listMeshes: { + 'us-east-1': { + err: meshErr, + data: mesh + }, + }, + describeMesh: { + 'us-east-1': { + [name]: { + data: describeMesh, + err: describeMeshErr + } + } + } + }, + }; +}; + +describe('restrictExternalTraffic', function () { + describe('run', function () { + it('should PASS if App Mesh mesh does not allow access to external services', function (done) { + const cache = createCache([listMeshes[0]], describeMesh[1]); + restrictExternalTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Mesh mesh does not allow access to external services'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if App Mesh mesh allows access to external services', function (done) { + const cache = createCache([listMeshes[0]], describeMesh[0]); + restrictExternalTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Mesh mesh allows access to external services'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no App Meshes found', function (done) { + const cache = createCache([]); + restrictExternalTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Mesh meshes found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for App Mesh meshes', function (done) { + const cache = createCache(null, null, { message: "Unable to query for App Mesh meshes" }); + restrictExternalTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for App Mesh meshes'); + done(); + }); + }); + + it('should UNKNOWN if Unable to describe App Mesh mesh', function (done) { + const cache = createCache([listMeshes[0]], null, null, { message: "Unable to describe App Mesh mesh" }); + restrictExternalTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to describe App Mesh mesh'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/apprunner/serviceEncrypted.js b/plugins/aws/apprunner/serviceEncrypted.js new file mode 100644 index 000000000..c90dfc0ad --- /dev/null +++ b/plugins/aws/apprunner/serviceEncrypted.js @@ -0,0 +1,119 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Service Encrypted', + category: 'App Runner', + domain: 'Compute', + severity: 'High', + description: 'Ensure that AWS App Runner service is encrypted using desired encryption level.', + more_info: 'To protect your application\'s data at rest, App Runner encrypts all stored copies of your application source image or source bundle using AWS-managed key by default.' + + 'Use customer-managed keys (CMKs) instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create App Runner Service with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/apprunner/latest/dg/security-data-protection-encryption.html', + apis: ['AppRunner:listServices','AppRunner:describeService', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + app_runner_service_desired_encryption_level: { + name: ' App Runner service desired Encryption level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['apprunner:CreateService','apprunner:DeleteService'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.app_runner_service_desired_encryption_level || this.settings.app_runner_service_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.apprunner, function(region, rcb){ + var listServices = helpers.addSource(cache, source, + ['apprunner', 'listServices', region]); + + if (!listServices) return rcb(); + + if (listServices.err || !listServices.data) { + helpers.addResult(results, 3, + 'Unable to query Service: ' + helpers.addError(listServices), region); + return rcb(); + } + + if (!listServices.data.length) { + helpers.addResult(results, 0, 'No Service found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let service of listServices.data) { + if (!service.ServiceArn) continue; + + let resource = service.ServiceArn; + + var describeService = helpers.addSource(cache, source, + ['apprunner', 'describeService', region, service.ServiceArn]); + + if (!describeService || describeService.err || !describeService.data ) { + helpers.addResult(results, 3, + `Unable to get Service description: ${helpers.addError(describeService)}`, + region, resource); + continue; + } + + if (describeService.data.Service && describeService.data.Service.EncryptionConfiguration && + describeService.data.Service.EncryptionConfiguration.KmsKey) { + + var kmsKey = describeService.data.Service.EncryptionConfiguration.KmsKey; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `App Runner service is using ${currentEncryptionLevelString} for encryption \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `App Runner service is using ${currentEncryptionLevelString} for encryption \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/apprunner/serviceEncrypted.spec.js b/plugins/aws/apprunner/serviceEncrypted.spec.js new file mode 100644 index 000000000..cfbc9d664 --- /dev/null +++ b/plugins/aws/apprunner/serviceEncrypted.spec.js @@ -0,0 +1,205 @@ +var expect = require('chai').expect; +var serviceEncrypted = require('./serviceEncrypted'); + +const listServices = [ + { + "ServiceName": "sadeed1", + "ServiceId": "9110332340cc4be5963e467f5deae770", + "ServiceArn": "arn:aws:apprunner:us-east-1:000111222333:service/sadeed1/9110332340cc4be5963e467f5deae770", + "ServiceUrl": "znuqjfu7cp.us-east-1.awsapprunner.com", + "CreatedAt": "2021-11-22T13:54:41+05:00", + "UpdatedAt": "2021-11-22T13:54:41+05:00", + "Status": "OPERATION_IN_PROGRESS" + } +]; + +const describeService = [ + { + "Service": { + "ServiceName": "sadeed1", + "ServiceId": "9110332340cc4be5963e467f5deae770", + "ServiceArn": "arn:aws:apprunner:us-east-1:000111222333:service/sadeed1/9110332340cc4be5963e467f5deae770", + "ServiceUrl": "znuqjfu7cp.us-east-1.awsapprunner.com", + "CreatedAt": "2021-11-22T13:54:41+05:00", + "UpdatedAt": "2021-11-22T13:54:41+05:00", + "Status": "OPERATION_IN_PROGRESS", + "SourceConfiguration": { + "CodeRepository": { + "RepositoryUrl": "https://github.com/AkhtarAmir/DAIZIC-Assessment", + "SourceCodeVersion": { + "Type": "BRANCH", + "Value": "main" + }, + "CodeConfiguration": { + "ConfigurationSource": "REPOSITORY" + } + }, + "AutoDeploymentsEnabled": false, + "AuthenticationConfiguration": { + "ConnectionArn": "arn:aws:apprunner:us-east-1:000011112222:connection/apprunner-connection/5497e544592748d29d92b57252d791b0" + } + }, + "InstanceConfiguration": { + "Cpu": "1024", + "Memory": "2048" + }, + "EncryptionConfiguration": { + "KmsKey": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + "HealthCheckConfiguration": { + "Protocol": "TCP", + "Path": "/", + "Interval": 10, + "Timeout": 5, + "HealthyThreshold": 1, + "UnhealthyThreshold": 5 + }, + "AutoScalingConfigurationSummary": { + "AutoScalingConfigurationArn": "arn:aws:apprunner:us-east-1:000011112222:autoscalingconfiguration/DefaultConfiguration/1/00000000000000000000000000000001", + "AutoScalingConfigurationName": "DefaultConfiguration", + "AutoScalingConfigurationRevision": 1 + } + } + } + +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (services, keys, describeService, describeKey, servicesErr, keysErr, describeKeyErr, describeServiceErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var serviceArn = (services && services.length) ? services[0].ServiceArn: null; + return { + apprunner: { + listServices: { + 'us-east-1': { + err: servicesErr, + data: services + }, + }, + describeService: { + 'us-east-1': { + [serviceArn]: { + data: describeService, + err: describeServiceErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('serviceEncrypted', function () { + describe('run', function () { + it('should PASS if App Runner service is encrypted with desired encryption level', function (done) { + const cache = createCache([listServices[0]], listKeys, describeService[0], describeKey[0]); + serviceEncrypted.run(cache, { app_runner_service_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if App Runner service not encrypted with desired encryption level', function (done) { + const cache = createCache([listServices[0]],listKeys, describeService[0], describeKey[1]); + serviceEncrypted.run(cache, { app_runner_service_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no App Runner service found', function (done) { + const cache = createCache([]); + serviceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Services', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Services" }); + serviceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listServices, null, null, null, { message: "Unable to list KMS keys" }); + serviceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/athena/workgroupEncrypted.js b/plugins/aws/athena/workgroupEncrypted.js index 2b59d31a0..b092ecaa9 100644 --- a/plugins/aws/athena/workgroupEncrypted.js +++ b/plugins/aws/athena/workgroupEncrypted.js @@ -4,17 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Workgroup Encrypted', category: 'Athena', + domain: 'Databases', + severity: 'High', description: 'Ensures Athena workgroups are configured to encrypt all data at rest.', more_info: 'Athena workgroups support full server-side encryption for all data at rest which should be enabled.', link: 'https://docs.aws.amazon.com/athena/latest/ug/encryption.html', recommended_action: 'Enable encryption at rest for all Athena workgroups.', apis: ['Athena:listWorkGroups', 'Athena:getWorkGroup', 'STS:getCallerIdentity'], - + remediation_description: 'Encryption for the affected workgroups will be enabled.', + remediation_min_version: '202011182332', + apis_remediate: ['Athena:listWorkGroups', 'Athena:getWorkGroup', 'STS:getCallerIdentity'], + actions: { + remediate: ['Athena:updateWorkGroup'], + rollback: ['Athena:updateWorkGroup'] + }, + permissions: { + remediate: ['athena:UpdateWorkGroup'], + rollback: ['athena:UpdateWorkGroup'] + }, + realtime_triggers: ['athena:CreateWorkGroup', 'athena:UpdateWorkGroup', 'athena:DeleteWorkGroup'], + remediation_inputs: { + encryptionOption: { + name: '(Mandatory) Encryption method', + description: 'SSE_S3 | SSE_KMS | CSE_KMS', + regex: '^(SSE_S3|SSE_KMS|CSE_KMS)$', + required: true + }, + kmsKeyIdforWg: { + name: '(Mandatory) KMS Key ID', + description: 'The KMS Key ID used for encryption if encryption option is SSE_KMS or CSE_KMS', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: true + } + }, run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); - + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); @@ -41,7 +68,7 @@ module.exports = { ['athena', 'getWorkGroup', region, wg.Name]); // arn:aws:athena:region:account-id:workgroup/workgroup-name - var arn = 'arn:aws:athena:' + region + ':' + accountId + ':workgroup/' + wg.Name; + var arn = `arn:${awsOrGov}:athena:` + region + ':' + accountId + ':workgroup/' + wg.Name; if (!getWorkGroup || getWorkGroup.err || !getWorkGroup.data) { helpers.addResult(results, 3, @@ -70,5 +97,89 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'workgroupEncrypted'; + var wgNameArr = resource.split(':'); + var wgName = wgNameArr[wgNameArr.length - 1].split('/'); + wgName = wgName[wgName.length - 1]; + // find the location of the Kinesis wg needing to be remediated + var wgLocation = wgNameArr[3]; + if (!wgLocation) { + return callback('Unable to get wg location'); + } + // add the location of the Kinesis wg to the config + config.region = wgLocation; + var params = {}; + // create the params necessary for the remediation + if (settings.input && settings.input.encryptionOption){ + if (settings.input.encryptionOption === 'SSE_KMS' || + settings.input.encryptionOption === 'CSE_KMS') { + if (settings.input.kmsKeyIdforWg){ + params = { + WorkGroup: wgName, + ConfigurationUpdates: { + ResultConfigurationUpdates: { + EncryptionConfiguration: { + EncryptionOption: settings.input.encryptionOption, + KmsKey: settings.input.kmsKeyIdforWg + } + } + } + }; + } else { + return callback(`Key is mandatory for workgroup update with ${settings.input.encryptionOption}`); + } + + } else if (settings.input.encryptionOption === 'SSE_S3'){ + params = { + WorkGroup: wgName, + ConfigurationUpdates: { + ResultConfigurationUpdates: { + EncryptionConfiguration: { + EncryptionOption: settings.input.encryptionOption, + } + } + } + }; + } + + } else { + return callback('EncryptionOption is mandatory for workgroup update'); + } + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'Workgroup Name': wgName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'Workgroup Name': wgName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + + rollback: function(config, cache, settings, resource, callback) { + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); } }; diff --git a/plugins/aws/athena/workgroupEncrypted.spec.js b/plugins/aws/athena/workgroupEncrypted.spec.js new file mode 100644 index 000000000..8e9916f5f --- /dev/null +++ b/plugins/aws/athena/workgroupEncrypted.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +const workgroupEncrypted = require('./workgroupEncrypted'); + +const listWorkGroups = [ + { + "Name": "primary", + "State": "ENABLED", + "Description": "", + "CreationTime": 1597044190.575 + }, + { + "Name": "wg-1", + "State": "ENABLED", + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + }, + { + "Name": "wg-2", + "State": "ENABLED", + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + } +]; + +const getWorkGroup = [ + { + "WorkGroup": { + "Name": "wg-1", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": { + "OutputLocation": "s3://aws-logs-000011112222-us-east-1/" + }, + "EnforceWorkGroupConfiguration": false, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + } + }, + { + "WorkGroup": { + "Name": "wg-2", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": { + "OutputLocation": "s3://aws-logs-000011112222-us-east-1/", + "EncryptionConfiguration": { + "EncryptionOption": "SSE_KMS", + "KmsKey": "arn:aws:kms:us-east-1:000011112222:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec" + } + }, + "EnforceWorkGroupConfiguration": false, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "Description": "encrypted workgroup", + "CreationTime": 1610126849.952 + } + }, + { + "WorkGroup": { + "Name": "primary", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": {}, + "EnforceWorkGroupConfiguration": false, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "CreationTime": 1597044190.575 + } + } +]; + + +const createCache = (workGroups, getWorkGroup, listErr, getErr) => { + var wgName = (workGroups && workGroups.length) ? workGroups[0].Name : null; + return { + athena: { + listWorkGroups: { + 'us-east-1': { + err: listErr, + data: workGroups + }, + }, + getWorkGroup: { + 'us-east-1': { + [wgName]: { + err: getErr, + data: getWorkGroup + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + athena: { + listWorkGroups: { + 'us-east-1': null, + } + } + }; +}; + +describe('workgroupEncrypted', function () { + describe('run', function () { + it('should PASS if Athena workgroup is using encryption', function (done) { + const cache = createCache([listWorkGroups[2]], getWorkGroup[1]); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Athena primary workgroup does not have encryption enabled but is not in use.', function (done) { + const cache = createCache([listWorkGroups[0]], getWorkGroup[2]); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Athena workgroup is not using encryption', function (done) { + const cache = createCache([listWorkGroups[1]], getWorkGroup[0]); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Athena workgroups found', function (done) { + const cache = createCache([]); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Athena workgroups', function (done) { + const cache = createCache(listWorkGroups, null, { message: 'Unable to list workgroups'}); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Athena workgroup', function (done) { + const cache = createCache([listWorkGroups[0]], getWorkGroup, null, { message: 'Unable to describe Athena workgroup'}); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list workgroups response not found', function (done) { + const cache = createNullCache(); + workgroupEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/athena/workgroupEnforceConfiguration.js b/plugins/aws/athena/workgroupEnforceConfiguration.js index ace99678a..452809913 100644 --- a/plugins/aws/athena/workgroupEnforceConfiguration.js +++ b/plugins/aws/athena/workgroupEnforceConfiguration.js @@ -4,16 +4,21 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Workgroup Enforce Configuration', category: 'Athena', + domain: 'Databases', + severity: 'Medium', description: 'Ensures Athena workgroups do not allow clients to override configuration options.', more_info: 'Athena workgroups support the ability for clients to override configuration options, including encryption requirements. This setting should be disabled to enforce encryption mandates.', link: 'https://docs.aws.amazon.com/athena/latest/ug/workgroups-settings.html', recommended_action: 'Disable the ability for clients to override Athena workgroup configuration options.', apis: ['Athena:listWorkGroups', 'Athena:getWorkGroup', 'STS:getCallerIdentity'], + realtime_triggers: ['athena:CreateWorkGroup', 'athena:UpdateWorkGroup', 'athena:DeleteWorkGroup'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); @@ -41,7 +46,7 @@ module.exports = { ['athena', 'getWorkGroup', region, wg.Name]); // arn:aws:athena:region:account-id:workgroup/workgroup-name - var arn = 'arn:aws:athena:' + region + ':' + accountId + ':workgroup/' + wg.Name; + var arn = `arn:${awsOrGov}:athena:` + region + ':' + accountId + ':workgroup/' + wg.Name; if (!getWorkGroup || getWorkGroup.err || !getWorkGroup.data) { helpers.addResult(results, 3, diff --git a/plugins/aws/athena/workgroupEnforceConfiguration.spec.js b/plugins/aws/athena/workgroupEnforceConfiguration.spec.js new file mode 100644 index 000000000..61caef139 --- /dev/null +++ b/plugins/aws/athena/workgroupEnforceConfiguration.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +const workgroupEnforceConfiguration = require('./workgroupEnforceConfiguration'); + +const listWorkGroups = [ + { + "Name": "primary", + "State": "ENABLED", + "Description": "", + "CreationTime": 1597044190.575 + }, + { + "Name": "wg-1", + "State": "ENABLED", + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + }, + { + "Name": "wg-2", + "State": "ENABLED", + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + } +]; + +const getWorkGroup = [ + { + "WorkGroup": { + "Name": "wg-1", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": { + "OutputLocation": "s3://aws-logs-000011112222-us-east-1/" + }, + "EnforceWorkGroupConfiguration": false, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "Description": "Workgroup for test cases", + "CreationTime": 1610126414.836 + } + }, + { + "WorkGroup": { + "Name": "wg-2", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": { + "OutputLocation": "s3://aws-logs-000011112222-us-east-1/", + "EncryptionConfiguration": { + "EncryptionOption": "SSE_KMS", + "KmsKey": "arn:aws:kms:us-east-1:000011112222:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec" + } + }, + "EnforceWorkGroupConfiguration": true, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "Description": "encrypted workgroup", + "CreationTime": 1610126849.952 + } + }, + { + "WorkGroup": { + "Name": "primary", + "State": "ENABLED", + "Configuration": { + "ResultConfiguration": {}, + "EnforceWorkGroupConfiguration": false, + "PublishCloudWatchMetricsEnabled": false, + "RequesterPaysEnabled": false + }, + "CreationTime": 1597044190.575 + } + } +]; + + +const createCache = (workGroups, getWorkGroup, listErr, getErr) => { + var wgName = (workGroups && workGroups.length) ? workGroups[0].Name : null; + return { + athena: { + listWorkGroups: { + 'us-east-1': { + err: listErr, + data: workGroups + }, + }, + getWorkGroup: { + 'us-east-1': { + [wgName]: { + err: getErr, + data: getWorkGroup + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + athena: { + listWorkGroups: { + 'us-east-1': null, + } + } + }; +}; + +describe('workgroupEnforceConfiguration', function () { + describe('run', function () { + it('should PASS if Athena workgroup is enforcing configuration options', function (done) { + const cache = createCache([listWorkGroups[2]], getWorkGroup[1]); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Athena primary workgroup is not enforcing configuration options but is not in use', function (done) { + const cache = createCache([listWorkGroups[0]], getWorkGroup[2]); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Athena workgroup is not enforcing configuration options', function (done) { + const cache = createCache([listWorkGroups[1]], getWorkGroup[0]); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Athena workgroups found', function (done) { + const cache = createCache([]); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Athena workgroups', function (done) { + const cache = createCache(listWorkGroups, null, { message: 'Unable to list workgroups'}); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Athena workgroup', function (done) { + const cache = createCache([listWorkGroups[0]], getWorkGroup, null, { message: 'Unable to describe Athena workgroup'}); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list workgroups response not found', function (done) { + const cache = createNullCache(); + workgroupEnforceConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/auditmanager/auditmanagerDataEncrypted.js b/plugins/aws/auditmanager/auditmanagerDataEncrypted.js new file mode 100644 index 000000000..ef9652f57 --- /dev/null +++ b/plugins/aws/auditmanager/auditmanagerDataEncrypted.js @@ -0,0 +1,102 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Audit Manager Data Encrypted', + category: 'Audit Manager', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that all data in Audit Manager is encrypted with desired encryption level.', + more_info: 'All resource in AWS Audit Manager such as assessments, controls, frameworks, evidence are encrypted under a customer managed key or an AWS owned key, depending on your selected settings. ' + + 'If you don’t provide a customer managed key, AWS Audit Manager uses an AWS owned key to encrypt your content. ' + + 'Encrypt these resources using customer-managed keys in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Modify Audit Manager data encryption settings and choose desired encryption key for data encryption', + link: 'https://docs.aws.amazon.com/audit-manager/latest/userguide/data-protection.html', + apis: ['AuditManager:getSettings', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + auditmanager_data_encryption_level: { + name: 'Audit Manager Data Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['auditmanager:registerAccount','auditmanager:UpdateSettings','auditmanager:DeregisterAccount'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.auditmanager_data_encryption_level || this.settings.auditmanager_data_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + for (let region of regions.auditmanager) { + var getSettings = helpers.addSource(cache, source, + ['auditmanager', 'getSettings', region]); + + if (!getSettings) continue; + + if (getSettings.err && getSettings.err.message && getSettings.err.message.includes('Please complete AWS Audit Manager setup')) { + helpers.addResult(results, 0, + 'Audit Manager is not setp up for this region', region); + continue; + } else if (getSettings.err || !getSettings.data) { + helpers.addResult(results, 3, + `Unable to query Audit Manager settings: ${helpers.addError(getSettings)}`, region); + continue; + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + continue; + } + + if (getSettings.data.kmsKey && getSettings.data.kmsKey.length) { + if (getSettings.data.kmsKey.toUpperCase() == 'DEFAULT') { + currentEncryptionLevel = 2; //awskms + } else { + var kmsKeyId = getSettings.data.kmsKey.split('/')[1] ? getSettings.data.kmsKey.split('/')[1] : getSettings.data.kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, getSettings.data.kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Audit Manager data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } else { + helpers.addResult(results, 2, + `Audit Manager data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } + } else { + helpers.addResult(results, 3, + 'Unable to retrieve encryption settings for Audit Manager data', region); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/aws/auditmanager/auditmanagerDataEncrypted.spec.js b/plugins/aws/auditmanager/auditmanagerDataEncrypted.spec.js new file mode 100644 index 000000000..f25b3b70f --- /dev/null +++ b/plugins/aws/auditmanager/auditmanagerDataEncrypted.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var auditmanagerDataEncrypted = require('./auditmanagerDataEncrypted'); + +const getSettings = { + "isAwsOrgEnabled": false, + "snsTopic": null, + "defaultProcessOwners": [], + "kmsKey": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" +}; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (settings, keys, describeKey, settingsErr, keysErr, describeKeyErr) => { + let keyId = (settings && settings.kmsKey) ? settings.kmsKey.split('/')[1] : null; + return { + auditmanager: { + getSettings: { + 'us-east-1': { + err: settingsErr, + data: settings + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('auditmanagerDataEncrypted', function () { + describe('run', function () { + it('should PASS if Audit Manager data is encrypted with desired encryption level', function (done) { + const cache = createCache(getSettings, listKeys, describeKey[0]); + auditmanagerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Audit Manager data is not encrypted with desired encryption level', function (done) { + const cache = createCache(getSettings, listKeys, describeKey[1]); + auditmanagerDataEncrypted.run(cache, {auditmanager_data_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Audit Manager is not setup for the region', function (done) { + const cache = createCache(null, listKeys, describeKey[0], { message: 'Please complete AWS Audit Manager setup from home page to enable this action in this account.' }); + auditmanagerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get Audit Manager settings', function (done) { + const cache = createCache(null, null, null, { message: "Unable to obtain data" }); + auditmanagerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(getSettings, null, null, null, { message: "Unable to list KMS keys" }); + auditmanagerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/appTierAsgApprovedAmi.js b/plugins/aws/autoscaling/appTierAsgApprovedAmi.js new file mode 100644 index 000000000..caec0c447 --- /dev/null +++ b/plugins/aws/autoscaling/appTierAsgApprovedAmi.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier ASG Launch Configurations Approved AMIs', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that App-Tier Auto Scaling Group Launch Configurations are using approved AMIs.', + more_info: 'App-Tier Auto Scaling Group Launch Configurations should use approved AMIs only to launch EC2 instances within the ASG', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/LaunchConfiguration.html', + recommended_action: 'Update App-Tier ASG Launch Configurations to use approved AMIs only', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations'], + settings: { + app_tier_tag_key: { + name: 'Auto Scaling App-Tier Tag Key', + description: 'App-Tier tag key used by Auto Scaling groups to indicate App-Tier groups', + regex: '^.*$', + default: '' + }, + approved_amis: { + name: 'Approved AMIs for ASG Launch Configuration', + description: 'List of approved AMIs for ASG Launch Configuration', + regex: '[a-zA-Z0-9-,]', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:createLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + app_tier_tag_key: settings.app_tier_tag_key || this.settings.app_tier_tag_key.default, + approved_amis: settings.approved_amis || this.settings.approved_amis.default + }; + + if (!config.app_tier_tag_key.length) return callback(null, results, source); + + config.approved_amis = config.approved_amis.split(','); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + var appTierAsgFound = false; + async.each(describeAutoScalingGroups.data, function(asg, cb){ + var appTierTag = false; + if (asg.Tags && asg.Tags.length){ + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + if (tag.Key && tag.Key === config.app_tier_tag_key) { + appTierTag = true; + appTierAsgFound = true; + break; + } + } + } + + if (appTierTag) { + var resource = asg.AutoScalingGroupARN; + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region, asg.AutoScalingGroupARN]); + + var imageFound = false; + var unapprovedAmis = []; + + if (!describeLaunchConfigurations || + describeLaunchConfigurations.err || + !describeLaunchConfigurations.data || + !describeLaunchConfigurations.data.LaunchConfigurations || + !describeLaunchConfigurations.data.LaunchConfigurations.length) { + helpers.addResult(results, 3, + `Unable to query launch configurations for Auto Scaling group "${asg.AutoScalingGroupName}": ${helpers.addError(describeLaunchConfigurations)}`, + region, resource); + return cb(); + } + + describeLaunchConfigurations.data.LaunchConfigurations.forEach(function(launchConfig){ + if (launchConfig.ImageId) { + imageFound = true; + if (config.approved_amis.indexOf(launchConfig.ImageId) === -1){ + unapprovedAmis.push(launchConfig.ImageId); + } + } + }); + + if (imageFound) { + if (!unapprovedAmis.length) { + helpers.addResult(results, 0, + `Launch Configuration for App-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is using approved AMI`, + region, resource); + } else { + helpers.addResult(results, 2, + `Launch Configuration for App-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is using this unapproved AMI: ${unapprovedAmis.join(', ')}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `Launch Configuration for App-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is not using any AMI`, + region, resource); + } + } + + if (!appTierAsgFound) { + helpers.addResult(results, 0, + 'No App-Tier Auto Scaling groups found', region); + } + + cb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + + } +}; \ No newline at end of file diff --git a/plugins/aws/autoscaling/appTierAsgApprovedAmi.spec.js b/plugins/aws/autoscaling/appTierAsgApprovedAmi.spec.js new file mode 100644 index 000000000..04760a0ad --- /dev/null +++ b/plugins/aws/autoscaling/appTierAsgApprovedAmi.spec.js @@ -0,0 +1,405 @@ +var expect = require('chai').expect; +const appTierAsgApprovedAmi = require('./appTierAsgApprovedAmi'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "app_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe8544444c", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe85445000", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, +]; + +const createCache = (asgs, config) => { + var asgArn = (asgs && asgs.length) ? asgs[0].AutoScalingGroupARN : null; + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + [asgArn]: { + data: config + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + message: 'error describing Auto Scaling group launch configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null + }, + }, + }; +}; + + +describe('appTierAsgApprovedAmi', function () { + describe('run', function () { + it('should PASS if Launch Configuration for App-Tier Auto Scaling group is using approved AMIs', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[0]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier', approved_amis: "ami-0001903fe8544444c" }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Launch Configuration for App-Tier Auto Scaling group is not using active AMIs', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[1]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Launch Configuration for App-Tier Auto Scaling group is not using any AMI', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[2]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe launch configuration for App-Tier Auto Scaling group', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no App-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[1]], describeLaunchConfigurations[2]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Auto Scaling groups response not found', function (done) { + const cache = createNullCache(); + appTierAsgApprovedAmi.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if App-Tier tag key is not provided in settings', function (done) { + const cache = createNullCache(); + appTierAsgApprovedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.js b/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.js new file mode 100644 index 000000000..d464b2bf8 --- /dev/null +++ b/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.js @@ -0,0 +1,138 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier Auto Scaling Group CloudWatch Logs Enabled', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that App-Tier Auto Scaling Groups are using CloudWatch logs agent.', + more_info: 'EC2 instance available within app-tier Auto Scaling Group (ASG) should use an AWS CloudWatch Logs agent to monitor, store and access log files.', + link: 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Install-CloudWatch-Agent.html', + recommended_action: 'Update app-tier Auto Scaling Group to use CloudWatch Logs agent', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations', 'STS:getCallerIdentity'], + settings: { + app_tier_tag_key: { + name: 'Auto Scaling App-Tier Tag Key', + description: 'App-Tier tag key used by Auto Scaling groups to indicate App-Tier groups', + regex: '^.*$', + default: '' + }, + cw_log_agent_install_command: { + name: 'Cloudwatch Agent Install Command', + description: 'Commands to install CloudWatch agent', + regex: '^.*$', + default: '#!/bin/bash curl https://s3.amazonaws.com//aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O ' + + 'chmod +x ./awslogs-agent-setup.py ' + + './awslogs-agent-setup.py -n -r -c ' + }, + s3_cw_agent_config_file: { + name: 'S3 Cloudwatch Agent Config File Location', + description: 'S3 path of cloudwatch agent configuration file', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:createLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + app_tier_tag_key: settings.app_tier_tag_key || this.settings.app_tier_tag_key.default, + cw_log_agent_install_command: settings.cw_log_agent_install_command || this.settings.cw_log_agent_install_command.default, + s3_cw_agent_config_file: settings.s3_cw_agent_config_file || this.settings.s3_cw_agent_config_file.default + }; + + if (!config.app_tier_tag_key.length) return callback(); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + var appTierAsgFound = false; + async.each(describeAutoScalingGroups.data, function(asg, cb){ + + var appTierTag = false; + if (asg.Tags && asg.Tags.length){ + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + if (tag && tag.Key && tag.Key === config.app_tier_tag_key) { + appTierTag = true; + appTierAsgFound = true; + break; + } + } + } + + if (appTierTag) { + var resource = asg.AutoScalingGroupARN; + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region, asg.AutoScalingGroupARN]); + + if (!describeLaunchConfigurations || + describeLaunchConfigurations.err || + !describeLaunchConfigurations.data || + !describeLaunchConfigurations.data.LaunchConfigurations || + !describeLaunchConfigurations.data.LaunchConfigurations.length) { + helpers.addResult(results, 3, + `Unable to query launch configurations for auto scaling group "${asg.AutoScalingGroupName}": ${helpers.addError(describeLaunchConfigurations)}`, + region, resource); + return cb(); + } + + var logsEnabled = false; + describeLaunchConfigurations.data.LaunchConfigurations.forEach(function(launchConfig){ + + config.cw_log_agent_install_command = config.cw_log_agent_install_command.replace('', region); + config.cw_log_agent_install_command = config.cw_log_agent_install_command.replace('', config.s3_cw_agent_config_file); + if (launchConfig.UserData && + launchConfig.UserData.indexOf(config.cw_log_agent_install_command) > -1) { + logsEnabled = true; + } + }); + + if (logsEnabled) { + helpers.addResult(results, 0, + `Auto Scaling group "${asg.AutoScalingGroupName}" is using CloudWatch Logs agent`, + region, resource); + } else { + helpers.addResult(results, 2, + `Auto Scaling group "${asg.AutoScalingGroupName}" is not using Clouwatch Logs agent`, + region, resource); + } + } + + if (!appTierAsgFound) { + helpers.addResult(results, 0, + 'No App-Tier Auto Scaling groups found', region); + } + cb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + + } +}; \ No newline at end of file diff --git a/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.spec.js b/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.spec.js new file mode 100644 index 000000000..6e1529f36 --- /dev/null +++ b/plugins/aws/autoscaling/appTierAsgCloudwatchLogs.spec.js @@ -0,0 +1,337 @@ +var expect = require('chai').expect; +const appTierAsgCloudWatchLogs = require('./appTierAsgCloudwatchLogs'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "app_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "key_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe8544444c", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "#!/bin/bash curl https://s3.amazonaws.com//aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O chmod +x ./awslogs-agent-setup.py ./awslogs-agent-setup.py -n -r us-east-1 -c s3://bucket-test-13/configFile.config", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe85445000", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + } +]; + +const createCache = (asgs, config) => { + var asgArn = (asgs && asgs.length) ? asgs[0].AutoScalingGroupARN : null; + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + [asgArn]: { + data: config + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing autos caling groups' + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + message: 'error describing auto scaling group notification configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null + }, + }, + }; +}; + + +describe('appTierAsgCloudWatchLogs', function () { + describe('run', function () { + it('should PASS if App-Tier Auto Scaling launch configuration is using CloudWatch Logs agent', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[0]); + const settings = { s3_cw_agent_config_file: 's3://bucket-test-13/configFile.config', app_tier_tag_key: 'app_tier' }; + appTierAsgCloudWatchLogs.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if App-Tier Auto Scaling launch configuration is not using CloudWatch Logs agent', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[1]); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe launch configuration for App-Tier Auto Scaling group', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no App-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[1]], describeLaunchConfigurations[2]); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no Auto Scaling groups found', function (done) { + const cache = createNullCache(); + appTierAsgCloudWatchLogs.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/appTierIamRole.js b/plugins/aws/autoscaling/appTierIamRole.js new file mode 100644 index 000000000..9baa29a56 --- /dev/null +++ b/plugins/aws/autoscaling/appTierIamRole.js @@ -0,0 +1,129 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier Launch Configurations IAM Roles', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that App-Tier Auto Scaling launch configuration is configured to use a customer created IAM role.', + more_info: 'App-Tier Auto Scaling launch configuration should have a customer created App-Tier IAM role to provide necessary credentials to access AWS services.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/us-iam-role.html', + recommended_action: 'Update App-Tier Auto Scaling launch configuration and attach a customer created App-Tier IAM role', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations'], + settings: { + app_tier_tag_key: { + name: 'Auto Scaling App-Tier Tag Key', + description: 'App-Tier tag key used by Auto Scaling groups to indicate App-Tier groups', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:CreateLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var app_tier_tag_key = settings.app_tier_tag_key || this.settings.app_tier_tag_key.default; + + if (!app_tier_tag_key.length) return callback(); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + if (!describeLaunchConfigurations || describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling group launch configurations: ${helpers.addError(describeLaunchConfigurations)}`, + region); + return rcb(); + } + + if (!describeLaunchConfigurations.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling launch configurations found', region); + return rcb(); + } + + var launchConfigurations = {}; + describeLaunchConfigurations.data.forEach(config => { + if (!config.IamInstanceProfile) return; + + launchConfigurations[config.LaunchConfigurationName] = config.IamInstanceProfile; + }); + + var launchConfigurationAsgFound = false; + var appTierAsgFound = false; + + for (var g in describeAutoScalingGroups.data) { + var asg = describeAutoScalingGroups.data[g]; + + if (!asg.AutoScalingGroupARN) continue; + + var resource = asg.AutoScalingGroupARN; + + if (asg.LaunchConfigurationName && asg.LaunchConfigurationName.length){ + launchConfigurationAsgFound = true; + + if (asg.Tags && asg.Tags.length) { + var appTierTag = false; + + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + + if (tag.Key === app_tier_tag_key) { + appTierTag = true; + appTierAsgFound = true; + break; + } + } + + if (appTierTag) { + if (launchConfigurations[asg.LaunchConfigurationName]) { + helpers.addResult(results, 0, + `Launch configuration for App-Tier group "${asg.AutoScalingGroupName}" has customer created IAM role configured`, + region, resource); + } else { + helpers.addResult(results, 2, + `Launch configuration for App-Tier group "${asg.AutoScalingGroupName}" does not have customer created IAM role configured`, + region, resource); + } + } + } + } + } + + if (!launchConfigurationAsgFound) { + helpers.addResult(results, 0, + 'No Auto Scaling groups utilizing launch configurations found', region); + return rcb(); + } + + if (!appTierAsgFound) { + helpers.addResult(results, 0, + 'No App-Tier Auto Scaling groups with found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/appTierIamRole.spec.js b/plugins/aws/autoscaling/appTierIamRole.spec.js new file mode 100644 index 000000000..fc69f61bb --- /dev/null +++ b/plugins/aws/autoscaling/appTierIamRole.spec.js @@ -0,0 +1,417 @@ +var expect = require('chai').expect; +const appTierIamRole = require('./appTierIamRole'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "app_tier", + "Value": "app_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "web_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test3-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:d4c0bbe4-e7d6-4d9b-89f1-06021f06a117:autoScalingGroupName/test3-36", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1d", + "us-east-1e" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0e8e9dd1aae06d4ae", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1e", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-27T00:01:54.103Z", + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + }, + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + } +]; + +const createCache = (asg, configuration) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asg + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + data: configuration + } + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + } + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling Launch Configurations' + } + } + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('appTierIamRole', function () { + describe('run', function () { + it('should PASS if launch configuration for App-Tier group has customer IAM role configured', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[0]]); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if launch configuration for App-Tier group does not have customer IAM role configured', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[1]]); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups utilizing launch configurations found', function (done) { + const cache = createCache([describeAutoScalingGroups[2]], [describeLaunchConfigurations[0]]); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no App-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[3]], [describeLaunchConfigurations[0]]); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling launch configurations found', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], []); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling launch configurations', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], null); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no response for describe Auto Scaling groups', function (done) { + const cache = createNullCache(); + appTierIamRole.run(cache, { app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgActiveNotifications.js b/plugins/aws/autoscaling/asgActiveNotifications.js new file mode 100644 index 000000000..9d3b201c2 --- /dev/null +++ b/plugins/aws/autoscaling/asgActiveNotifications.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Auto Scaling Notifications Active', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures auto scaling groups have notifications active.', + more_info: 'Notifications can be sent to an SNS endpoint when scaling actions occur, which should be set to ensure all scaling activity is recorded.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/ASGettingNotifications.html', + recommended_action: 'Add a notification endpoint to the auto scaling group.', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeNotificationConfigurations'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:PutNotificationConfiguration'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for auto scaling groups: ' + + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No auto scaling groups found', region); + return rcb(); + } + + async.each(describeAutoScalingGroups.data, function(asg, cb){ + var resource = asg.AutoScalingGroupARN; + var notificationConfiguration = helpers.addSource(cache, source, + ['autoscaling', 'describeNotificationConfigurations', region, asg.AutoScalingGroupARN]); + + if (!notificationConfiguration || notificationConfiguration.err || !notificationConfiguration.data) { + helpers.addResult(results, 3, + 'Unable to query for auto scaling group notification configurations: ' + + helpers.addError(notificationConfiguration), region, resource); + return cb(); + } + + if (!notificationConfiguration.data.NotificationConfigurations || + !notificationConfiguration.data.NotificationConfigurations.length) { + helpers.addResult(results, 2, + 'Auto scaling group: ' + asg.AutoScalingGroupName + ' does not have notifications active', + region, resource); + return cb(); + } else { + helpers.addResult(results, 0, + 'Auto scaling group: ' + asg.AutoScalingGroupName + ' has notifications active', + region, resource); + } + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgActiveNotifications.spec.js b/plugins/aws/autoscaling/asgActiveNotifications.spec.js new file mode 100644 index 000000000..389a340bc --- /dev/null +++ b/plugins/aws/autoscaling/asgActiveNotifications.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +const asgActiveNotifications = require('./asgActiveNotifications'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const notificationConfigurations = [ + { + AutoScalingGroupName: "auto-scaling-test-group", + NotificationType: "autoscaling:TEST_NOTIFICATION", + TopicARN: "arn:aws:sns:us-west-2:123456789012:my-sns-topic-2" + }, + { + AutoScalingGroupName: "auto-scaling-test-group", + NotificationType: "autoscaling:TEST_NOTIFICATION", + TopicARN: "arn:aws:sns:us-west-2:123456789012:my-sns-topic" + } +]; + +const createCache = (asgs, notifications) => { + if (asgs.length) var asgArn = asgs[0].AutoScalingGroupARN; + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + describeNotificationConfigurations: { + 'us-east-1': { + [asgArn]: { + data: { + NotificationConfigurations: notifications + } + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing autos caling groups' + }, + }, + describeNotificationConfigurations: { + 'us-east-1': { + message: 'error describing auto scaling group notification configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeNotificationConfigurations: { + 'us-east-1': null + }, + }, + }; +}; + +describe('asgActiveNotifications', function () { + describe('run', function () { + it('should PASS if notification are active for auto scaling group', function (done) { + const cache = createCache([autoScalingGroups[0]], [notificationConfigurations[0]]); + asgActiveNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if notification are not active for auto scaling group', function (done) { + const cache = createCache([autoScalingGroups[0]], []); + asgActiveNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe auto scaling group found', function (done) { + const cache = createErrorCache(); + asgActiveNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no auto scaling group found', function (done) { + const cache = createNullCache(); + asgActiveNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should FAIL if No auto scaling group notification configurations found', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgActiveNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgCooldownPeriod.js b/plugins/aws/autoscaling/asgCooldownPeriod.js new file mode 100644 index 000000000..60ff7f5f2 --- /dev/null +++ b/plugins/aws/autoscaling/asgCooldownPeriod.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Auto Scaling Group Cooldown Period', + category: 'AutoScaling', + domain: 'Availability', + severity: 'High', + description: 'Ensure that your AWS Auto Scaling Groups are configured to use a cool down period.', + more_info: 'A scaling cool down helps you prevent your Auto Scaling group from launching or terminating additional instances before the effects of previous activities are visible.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/Cooldown.html', + recommended_action: 'Implement proper cool down period for Auto Scaling groups to temporarily suspend any scaling actions.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for Auto Scaling groups: ' + + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + for (let group of describeAutoScalingGroups.data){ + if (!group.AutoScalingGroupARN) continue; + + let resource = group.AutoScalingGroupARN; + + if (group.DefaultCooldown) { + helpers.addResult(results, 0, + 'Auto Scaling group has cool down period configured', + region, resource); + } else { + helpers.addResult(results, 2, + 'Auto Scaling group does not have cool down period configured', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgCooldownPeriod.spec.js b/plugins/aws/autoscaling/asgCooldownPeriod.spec.js new file mode 100644 index 000000000..8bd89059f --- /dev/null +++ b/plugins/aws/autoscaling/asgCooldownPeriod.spec.js @@ -0,0 +1,180 @@ +var expect = require('chai').expect; +const asgCooldownPeriod = require('./asgCooldownPeriod'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 0, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [ + { + "ProcessName": "Launch", + "SuspensionReason": "User suspended at 2020-09-19T17:30:30Z" + }, + { + "ProcessName": "Terminate", + "SuspensionReason": "User suspended at 2020-09-19T17:56:14Z" + } + ], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: null, + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing auto scaling groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('asgCooldownPeriod', function () { + describe('run', function () { + it('should PASS if Amazon Auto Scaling Groups are utilizing cool down period', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgCooldownPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Auto Scaling group has cool down period configured'); + done(); + }); + }); + + it('should FAIL if the cool down period setting is not properly configured for the selected Amazon ASG', function (done) { + const cache = createCache([autoScalingGroups[1]]); + asgCooldownPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Auto Scaling group does not have cool down period configured'); + done(); + }); + }); + + it('should PASS if no AutoScaling groups found', function (done) { + const cache = createCache([]); + asgCooldownPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Auto Scaling groups found'); + done(); + }); + }); + + it('should UNKNOWN if an error occurs while describing AutoScaling groups', function (done) { + const cache = createErrorCache(); + asgCooldownPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Auto Scaling groups'); + done(); + }); + }); + + it('should not return anything if unable to query for AutoScaling groups', function (done) { + const cache = createNullCache(); + asgCooldownPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgMissingELB.js b/plugins/aws/autoscaling/asgMissingELB.js new file mode 100644 index 000000000..79b0ac9f4 --- /dev/null +++ b/plugins/aws/autoscaling/asgMissingELB.js @@ -0,0 +1,106 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Auto Scaling Group Missing ELB', + category: 'AutoScaling', + domain: 'Availability', + severity: 'High', + description: 'Ensures all Auto Scaling groups are referencing active load balancers.', + more_info: 'Each Auto Scaling group with a load balancer configured should reference an active ELB.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/attach-load-balancer-asg.html', + recommended_action: 'Ensure that the Auto Scaling group load balancer has not been deleted. If so, remove it from the ASG.', + apis: ['AutoScaling:describeAutoScalingGroups', 'ELB:describeLoadBalancers', 'ELBv2:describeLoadBalancers'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:AttachLoadBalancers','autoscaling:DetachLoadBalancers','elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:DeleteLoadBalancer','elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var elbNames = []; + + async.each(regions.autoscaling, function(region, rcb){ + var autoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + var elasticLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + var elasticLoadBalancersV2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!autoScalingGroups || !elasticLoadBalancers || !elasticLoadBalancersV2) return rcb(); + + if (autoScalingGroups.err || !autoScalingGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Auto Scaling groups: ' + helpers.addError(autoScalingGroups), region); + return rcb(); + } + + if (elasticLoadBalancers.err || !elasticLoadBalancers.data) { + helpers.addResult(results, 3, 'Unable to query for Classic load balancers: ' + helpers.addError(elasticLoadBalancers), region); + return rcb(); + } + + if (elasticLoadBalancersV2.err || !elasticLoadBalancersV2.data) { + helpers.addResult(results, 3, 'Unable to query for Application/Network load balancers: ' + helpers.addError(elasticLoadBalancersV2), region); + return rcb(); + } + + if (!autoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling group found', region); + return rcb(); + } + + if (elasticLoadBalancers.data.length) { + elasticLoadBalancers.data.forEach(function(elb) { + if (elb.LoadBalancerName) { + elbNames.push(elb.LoadBalancerName); + } + }); + } + + if (elasticLoadBalancersV2.data.length) { + elasticLoadBalancersV2.data.forEach(function(elbv2) { + if (elbv2.LoadBalancerName) { + elbNames.push(elbv2.LoadBalancerName); + } + }); + } + + autoScalingGroups.data.forEach(function(asg){ + var resource = asg.AutoScalingGroupARN; + var inactiveElbs = []; + if (asg.HealthCheckType && asg.HealthCheckType === 'ELB') { + if (asg.LoadBalancerNames && asg.LoadBalancerNames.length) { + asg.LoadBalancerNames.forEach(function(elbName){ + if (!elbNames.length || !elbNames.includes(elbName)) { + inactiveElbs.push(elbName); + } + }); + + if (inactiveElbs.length){ + helpers.addResult(results, 2, + 'Auto Scaling group utilizes these inactive load balancers: '+ inactiveElbs.join(', '), + region, resource); + } else { + helpers.addResult(results, 0, + 'Auto Scaling group: ' + asg.AutoScalingGroupName + ' utilizes active load balancers', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Auto Scaling group does not have any Load Balancer associated', region, resource); + } + } else { + helpers.addResult(results, 0, + 'Auto Scaling group does not utilize a load balancer', region, resource); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgMissingELB.spec.js b/plugins/aws/autoscaling/asgMissingELB.spec.js new file mode 100644 index 000000000..8b5602617 --- /dev/null +++ b/plugins/aws/autoscaling/asgMissingELB.spec.js @@ -0,0 +1,374 @@ +var expect = require('chai').expect; +const asgMissingELB = require('./asgMissingELB'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": ["asgEmptyElb"], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": ["my-load-balancer2"], + "TargetGroupARNs": [], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + +const loadBalancers = [ + { + "LoadBalancerName": "asgEmptyElb", + "DNSName": "asgEmptyElb-1112223333.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "asgEmptyElb-1112223333.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-00178f718e021c46b" + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-08-26T02:49:20.250Z", + "Scheme": "internet-facing" + } +] + +const loadBalancersV2 = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/networkELB/2ea2eb9b8d381866", + "DNSName": "networkELB-2ea2eb9b8d381866.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-08-26T02:50:34.119Z", + "LoadBalancerName": "networkELB", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "network", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-673a9a46", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + } + +] + +const createCache = (asgs, elb, elbv2) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + }, + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing AutoScaling groups' + }, + }, + }, + }, + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + }, + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('asgMissingELB', function () { + describe('run', function () { + it('should PASS if AutoScaling group utilizes active load balancer', function (done) { + const cache = createCache([autoScalingGroups[0]], [loadBalancers[0]], [loadBalancersV2[0]]); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if AutoScaling group utilizes inactive load balancer', function (done) { + const cache = createCache([autoScalingGroups[1]], [loadBalancers[0]], [loadBalancersV2[0]]); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if AutoScaling group does not have any ELB associated', function (done) { + const cache = createCache([autoScalingGroups[2]], [loadBalancers[0]], [loadBalancersV2[0]]); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if AutoScaling group does not utilize a load balancer', function (done) { + const cache = createCache([autoScalingGroups[3]], [loadBalancers[0]], [loadBalancersV2[0]]); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe AutoScaling group found', function (done) { + const cache = createErrorCache(); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no AutoScaling group found', function (done) { + const cache = createNullCache(); + asgMissingELB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgMissingSecurityGroups.js b/plugins/aws/autoscaling/asgMissingSecurityGroups.js new file mode 100644 index 000000000..90c44331d --- /dev/null +++ b/plugins/aws/autoscaling/asgMissingSecurityGroups.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Launch Configuration Referencing Missing Security Groups', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Auto Scaling launch configurations are not utilizing missing security groups.', + more_info: 'Auto Scaling launch configuration should utilize an active security group to ensure safety of managed instances.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/GettingStartedTutorial.html', + recommended_action: 'Ensure that the launch configuration security group has not been deleted. If so, remove it from launch configurations', + apis: ['AutoScaling:describeLaunchConfigurations', 'EC2:describeSecurityGroups'], + realtime_triggers: ['autoscaling:CreateLaunchConfiguration','autoscaling:DeleteLaunchConfiguration','ec2:CreateSecurityGroup','ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeLaunchConfigurations) return rcb(); + + if (describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling launch configurations: ${helpers.addError(describeLaunchConfigurations)}`, + region); + return rcb(); + } + + if (!describeLaunchConfigurations.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling launch configurations found', region); + return rcb(); + } + + if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to query for EC2 security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + var securityGroups = []; + + if (describeSecurityGroups.data.length) { + describeSecurityGroups.data.forEach(function(sg){ + securityGroups.push(sg.GroupId); + }); + } + + describeLaunchConfigurations.data.forEach(function(config){ + var resource = config.LaunchConfigurationARN; + + if (!config.SecurityGroups || !config.SecurityGroups.length) { + helpers.addResult(results, 0, + `Auto Scaling launch configuration "${config.LaunchConfigurationName}" does not have any security groups associated`, + region, resource); + } else { + var missingSecurityGroups = []; + + config.SecurityGroups.forEach(function(group){ + if (!securityGroups.includes(group)) { + missingSecurityGroups.push(group); + } + }); + + if (missingSecurityGroups.length) { + helpers.addResult(results, 2, + `Auto Scaling launch configuration "${config.LaunchConfigurationName}" references these missing EC2 security groups: ${missingSecurityGroups.join(', ')}`, + region, resource); + } else { + helpers.addResult(results, 0, + `Auto Scaling launch configuration "${config.LaunchConfigurationName}" does not reference any missing EC2 security groups`, + region, resource); + } + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgMissingSecurityGroups.spec.js b/plugins/aws/autoscaling/asgMissingSecurityGroups.spec.js new file mode 100644 index 000000000..0d4988239 --- /dev/null +++ b/plugins/aws/autoscaling/asgMissingSecurityGroups.spec.js @@ -0,0 +1,264 @@ +var expect = require('chai').expect; +const asgMissingSecurityGroups = require('./asgMissingSecurityGroups'); + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + }, + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9", + "sg-06cccccccccceaaaa", + "sg-06ccccccbbbbbbbbb" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + }, + { + "LaunchConfigurationName": "test-lc-44", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-44", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + } +] + +const describeSecurityGroups = [ + { + "Description": "launch-wizard-4 created 2020-08-25T07:21:35.823+05:00", + "GroupName": "launch-wizard-4", + "IpPermissions": [ + { + "FromPort": 22, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 22, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-06cccc47e5b3e1ee9", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +] + + +const createCache = (configs, groups) => { + return { + autoscaling: { + describeLaunchConfigurations: { + 'us-east-1': { + err: null, + data: configs + }, + }, + }, + ec2: { + describeSecurityGroups: { + 'us-east-1': { + err: null, + data: groups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeLaunchConfigurations: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling launch configurations' + }, + }, + }, + }, + ec2: { + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing EC2 security groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeLaunchConfigurations: { + 'us-east-1': null, + }, + }, + ec2: { + describeSecurityGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('asgMissingSecurityGroups', function () { + describe('run', function () { + it('should PASS if Auto Scaling launch configuration does not reference any missing EC2 security group', function (done) { + const cache = createCache([describeLaunchConfigurations[0]], [describeSecurityGroups[0]]); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Auto Scaling launch configuration references missing EC2 security group(s)', function (done) { + const cache = createCache([describeLaunchConfigurations[1]], [describeSecurityGroups[0]]); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling launch configurations found', function (done) { + const cache = createCache([]); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Auto Scaling launch configuration does not have any security groups associated', function (done) { + const cache = createCache([describeLaunchConfigurations[2]], [describeSecurityGroups[0]]); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no EC2 security groups found', function (done) { + const cache = createCache([describeLaunchConfigurations[1]],[]); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling launch configurations', function (done) { + const cache = createErrorCache(); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Auto Scaling launch configurations response not found', function (done) { + const cache = createNullCache(); + asgMissingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/autoscaling/asgMultiAz.js b/plugins/aws/autoscaling/asgMultiAz.js index ff6804cbb..1bb8bc6bc 100644 --- a/plugins/aws/autoscaling/asgMultiAz.js +++ b/plugins/aws/autoscaling/asgMultiAz.js @@ -4,11 +4,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ASG Multiple AZ', category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', description: 'Ensures that ASGs are created to be cross-AZ for high availability.', more_info: 'ASGs can easily be configured to allow instances to launch in multiple availability zones. This ensures that the ASG can continue to scale, even when AWS is experiencing downtime in one or more zones.', link: 'http://docs.aws.amazon.com/autoscaling/latest/userguide/AutoScalingGroup.html', recommended_action: 'Modify the autoscaling instance to enable scaling across multiple availability zones.', apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup'], + run: function(cache, settings, callback) { var results = []; @@ -35,16 +39,17 @@ module.exports = { // loop through autoscaling Instances describeAutoScalingGroups.data.forEach(function(Asg){ + var resource = Asg.AutoScalingGroupARN; if (Asg.AvailabilityZones.length <=1) { helpers.addResult(results, 2, 'Auto scaling group is only using ' + Asg.AvailabilityZones.length + ' availability zones', - region, Asg.AutoScalingGroupName); + region, resource); } else { helpers.addResult(results, 0, 'Auto scaling group using ' + Asg.AvailabilityZones.length + ' availability zones', - region, Asg.AutoScalingGroupName); + region, resource); } }); rcb(); diff --git a/plugins/aws/autoscaling/asgMultiAz.spec.js b/plugins/aws/autoscaling/asgMultiAz.spec.js new file mode 100644 index 000000000..0321d02bf --- /dev/null +++ b/plugins/aws/autoscaling/asgMultiAz.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +const asgMultiAz = require('./asgMultiAz'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1b", + "us-east-1c" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('asgMultiAz', function () { + describe('run', function () { + it('should PASS if Auto Scaling group utilizes multiple availability zones', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Auto Scaling group utilizes one availability zone', function (done) { + const cache = createCache([autoScalingGroups[1]]); + asgMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found ', function (done) { + const cache = createCache([]); + asgMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error describing Auto Scaling groups', function (done) { + const cache = createErrorCache(); + asgMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe Auto Scaling groups', function (done) { + const cache = createNullCache(); + asgMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgSuspendedProcesses.js b/plugins/aws/autoscaling/asgSuspendedProcesses.js new file mode 100644 index 000000000..354cd9dad --- /dev/null +++ b/plugins/aws/autoscaling/asgSuspendedProcesses.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Suspended AutoScaling Groups', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that there are no Amazon AutoScaling groups with suspended processes.', + more_info: 'AutoScaling groups should not have any suspended processes to avoid disrupting the AutoScaling workflow.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-suspend-resume-processes.html', + recommended_action: 'Update the AutoScaling group to resume the suspended processes.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:ResumeProcesses','autoscaling:DeleteAutoScalingGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for AutoScaling groups: ${helpers.addError(describeAutoScalingGroups)}`, region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No AutoScaling groups found', region); + return rcb(); + } + + describeAutoScalingGroups.data.forEach(function(asg){ + if (!asg.SuspendedProcesses || !asg.SuspendedProcesses.length) { + helpers.addResult(results, 0, + `AutoScaling group "${asg.AutoScalingGroupName}" does not have any suspended processes`, + region, asg.AutoScalingGroupARN); + } else { + var suspendedProcesses = []; + asg.SuspendedProcesses.forEach(function(process) { + suspendedProcesses.push(process.ProcessName); + }); + + helpers.addResult(results, 2, + `AutoScaling group "${asg.AutoScalingGroupName}" has these suspended processes: ${suspendedProcesses.join(', ')}`, + region, asg.AutoScalingGroupARN); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgSuspendedProcesses.spec.js b/plugins/aws/autoscaling/asgSuspendedProcesses.spec.js new file mode 100644 index 000000000..c4de36663 --- /dev/null +++ b/plugins/aws/autoscaling/asgSuspendedProcesses.spec.js @@ -0,0 +1,178 @@ +var expect = require('chai').expect; +const asgSuspendedProcesses = require('./asgSuspendedProcesses'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [ + { + "ProcessName": "Launch", + "SuspensionReason": "User suspended at 2020-09-19T17:30:30Z" + }, + { + "ProcessName": "Terminate", + "SuspensionReason": "User suspended at 2020-09-19T17:56:14Z" + } + ], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + + + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: null, + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing auto scaling groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('asgSuspendedProcesses', function () { + describe('run', function () { + it('should PASS if AutoScaling group does not have any suspended process', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgSuspendedProcesses.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if AutoScaling group has suspended processes', function (done) { + const cache = createCache([autoScalingGroups[1]]); + asgSuspendedProcesses.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no AutoScaling groups found ', function (done) { + const cache = createCache([]); + asgSuspendedProcesses.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if an error occurs while describing AutoScaling groups', function (done) { + const cache = createErrorCache(); + asgSuspendedProcesses.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to query for AutoScaling groups', function (done) { + const cache = createNullCache(); + asgSuspendedProcesses.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/asgTagPropagation.js b/plugins/aws/autoscaling/asgTagPropagation.js new file mode 100644 index 000000000..7515e70ef --- /dev/null +++ b/plugins/aws/autoscaling/asgTagPropagation.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ASG Tag Propagation', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that EC2 Auto Scaling Groups propagate tags to EC2 instances that it launches.', + more_info: 'Tags can help with managing, identifying, organizing, searching for, and filtering resources. Additionally, tags can help with security and compliance. Tags should be propagated from an Auto Scaling group to the EC2 instances that it launches.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-tagging.html', + recommended_action: 'Enable tag propagation for all tags on Auto Scaling Groups by setting PropagateAtLaunch to true for each tag.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup', 'autoscaling:UpdateAutoScalingGroup', 'autoscaling:CreateOrUpdateTags', 'autoscaling:DeleteTags'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for auto scaling groups: ' + + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No auto scaling groups found', region); + return rcb(); + } + + describeAutoScalingGroups.data.forEach(function(asg){ + var resource = asg.AutoScalingGroupARN; + + if (!resource) return; + + if (!asg.Tags || !asg.Tags.length) { + helpers.addResult(results, 0, + 'Auto scaling group has no tags configured', + region, resource); + return; + } + + var tagsNotPropagating = []; + asg.Tags.forEach(function(tag) { + if (!tag.PropagateAtLaunch) { + tagsNotPropagating.push(tag.Key || 'unnamed'); + } + }); + + if (!tagsNotPropagating.length ) { + helpers.addResult(results, 0, + 'Auto scaling group has all tags configured to propagate to EC2 instances', + region, resource); + } else { + helpers.addResult(results, 2, + 'Auto scaling group has ' + tagsNotPropagating.length + + ' tag(s) not configured to propagate to EC2 instances', + region, resource); + } + }); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/autoscaling/asgTagPropagation.spec.js b/plugins/aws/autoscaling/asgTagPropagation.spec.js new file mode 100644 index 000000000..53aa7c641 --- /dev/null +++ b/plugins/aws/autoscaling/asgTagPropagation.spec.js @@ -0,0 +1,236 @@ +var expect = require('chai').expect; +const asgTagPropagation = require('./asgTagPropagation'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "asg-all-tags-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-all-tags-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1b" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-all-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": true + }, + { + "ResourceId": "asg-all-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-some-tags-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-some-tags-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-some-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": true + }, + { + "ResourceId": "asg-some-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": false + } + ], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-no-tags", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-no-tags", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-no-tags-property", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-no-tags-property", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-all-tags-not-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-all-tags-not-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-all-tags-not-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": false + }, + { + "ResourceId": "asg-all-tags-not-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": false + } + ], + "TerminationPolicies": ["Default"] + } +]; + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + }, + }, + }; +}; + +describe('asgTagPropagation', function () { + describe('run', function () { + it('should PASS if all tags have PropagateAtLaunch set to true', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('all tags configured to propagate'); + done(); + }); + }); + + it('should FAIL if some tags do not have PropagateAtLaunch set to true', function (done) { + const cache = createCache([autoScalingGroups[1]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('1 tag(s) not configured to propagate'); + done(); + }); + }); + + it('should PASS if Auto Scaling group has no tags', function (done) { + const cache = createCache([autoScalingGroups[2]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has no tags configured'); + done(); + }); + }); + + it('should PASS if Auto Scaling group has no Tags property', function (done) { + const cache = createCache([autoScalingGroups[3]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has no tags configured'); + done(); + }); + }); + + it('should FAIL if all tags have PropagateAtLaunch set to false', function (done) { + const cache = createCache([autoScalingGroups[4]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('2 tag(s) not configured to propagate'); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No auto scaling groups found'); + done(); + }); + }); + + it('should UNKNOWN if error describing Auto Scaling groups', function (done) { + const cache = createErrorCache(); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.js b/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.js new file mode 100644 index 000000000..23a82ff24 --- /dev/null +++ b/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Auto Scaling Unused Launch Configuration', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Low', + description: 'Ensure that any unused Auto Scaling Launch Configuration templates are identified and removed from your account in order to adhere to AWS best practices.', + more_info: 'A launch configuration is an instance configuration template that an Auto Scaling group uses to launch EC2 instances. When you create a launch configuration, you specify information for the instances. '+ + 'Every unused Launch Configuration template should be removed for a better management of your AWS Auto Scaling components.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/LaunchConfiguration.html', + recommended_action: 'Identify and remove any Auto Scaling Launch Configuration templates that are not associated anymore with ASGs available in the selected AWS region.', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:CreateLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + if (!describeLaunchConfigurations) return rcb(); + + if (describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + 'Unable to query for Auto Scaling launch configurations: ' + helpers.addError(describeLaunchConfigurations), region); + return rcb(); + } + + if (!describeLaunchConfigurations.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling launch configurations found', region); + return rcb(); + } + + if (!describeAutoScalingGroups || describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for Auto Scaling groups: ' + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + let usedLaunchConfig = []; + describeAutoScalingGroups.data.forEach(group => { + if (!group.LaunchConfigurationName) return; + + if (!usedLaunchConfig.includes(group.LaunchConfigurationName)) { + usedLaunchConfig.push(group.LaunchConfigurationName); + } + }); + + describeLaunchConfigurations.data.forEach(config => { + if (!config.LaunchConfigurationARN) return; + + if (config.LaunchConfigurationName && usedLaunchConfig.includes(config.LaunchConfigurationName)) { + helpers.addResult(results, 0, + `Auto Scaling launch configuration "${config.LaunchConfigurationName}" is being used`, + region, config.LaunchConfigurationARN); + } else { + helpers.addResult(results, 2, + `Auto Scaling launch configuration "${config.LaunchConfigurationName}" is not being used`, + region, config.LaunchConfigurationARN); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.spec.js b/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.spec.js new file mode 100644 index 000000000..2aa92ecfe --- /dev/null +++ b/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.spec.js @@ -0,0 +1,179 @@ +var expect = require('chai').expect; +const asgUnusedLaunchConfiguration = require('./asgUnusedLaunchConfiguration'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "sadeed-grp1", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:000111222333:autoScalingGroup:1efc7b66-fbd4-4a5b-8086-8b95b0b74603:autoScalingGroupName/sadeed-grp1", + "LaunchConfigurationName": "mine2", + "MinSize": 1, + "MaxSize": 5, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1b" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2022-01-18T09:39:04.243000+00:00", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-02ed4181800d4658b,subnet-06629b4200870c740", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::000011112222:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "mine2", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:000011112222:launchConfiguration:8fceeaaa-c984-4cad-a20d-3e09eaea2440:launchConfigurationName/mine2", + "ImageId": "ami-085b20a79fc1af8f5", + "KeyName": "test", + "SecurityGroups": [ + "sg-008a9126e4f284b6c" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "a1.medium", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sdj", + "Ebs": { + "VolumeSize": 5, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": true + } + }, + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2022-01-18T09:38:17.445000+00:00", + "EbsOptimized": false + }, + { + "LaunchConfigurationName": "mine3", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:000011112222:launchConfiguration:917c072a-383b-48b6-9675-ed0e1f4e6f51:launchConfigurationName/mine3", + "ImageId": "ami-085b20a79fc1af8f5", + "KeyName": "test", + "SecurityGroups": [ + "sg-0d55142f499300efb" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "a1.medium", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sdj", + "Ebs": { + "VolumeSize": 5, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": true + } + }, + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2022-01-18T10:27:56.426000+00:00", + "EbsOptimized": false + } +]; + +const createCache = (group, config) => { + return { + autoscaling:{ + describeAutoScalingGroups: { + 'us-east-1': { + data: group + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + data: config + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling:{ + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error while describing ElastiCache clusters' + }, + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + err: { + message: 'error while describing ElastiCache reserved cache nodes' + }, + }, + }, + }, + }; +}; + + +describe('asgUnusedLaunchConfiguration', function () { + describe('run', function () { + it('should PASS if Auto Scaling launch configuration is being used', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[0]]); + asgUnusedLaunchConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is being used'); + done(); + }); + }); + + it('should FAIL if Auto Scaling launch configuration is not being used', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[1]]); + asgUnusedLaunchConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not being used'); + done(); + }); + }); + + it('should PASS if no Auto Scaling launch configurations found', function (done) { + const cache = createCache([], []); + asgUnusedLaunchConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Auto Scaling launch configurations found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Auto Scaling launch configurations', function (done) { + const cache = createErrorCache([],null); + asgUnusedLaunchConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Auto Scaling launch configurations'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/elbHealthCheckActive.js b/plugins/aws/autoscaling/elbHealthCheckActive.js new file mode 100644 index 000000000..c6bc46108 --- /dev/null +++ b/plugins/aws/autoscaling/elbHealthCheckActive.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB Health Check Active', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures all Auto Scaling groups have ELB health check active.', + more_info: 'Auto Scaling groups should have ELB health checks active to replace unhealthy instances in time.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-elb-healthcheck.html', + recommended_action: 'Enable ELB health check for the Auto Scaling groups.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + describeAutoScalingGroups.data.forEach(function(asg){ + if (!asg.AutoScalingGroupARN) return; + + var resource = asg.AutoScalingGroupARN; + + if (asg.HealthCheckType && asg.HealthCheckType === 'ELB') { + helpers.addResult(results, 0, + `Auto Scaling group "${asg.AutoScalingGroupName}" has ELB health check active`, + region, resource); + } else if (!asg.LoadBalancerNames || !asg.LoadBalancerNames.length) { + helpers.addResult(results, 0, + `Auto Scaling group "${asg.AutoScalingGroupName}" does not use ELBs`, + region, resource); + } else { + helpers.addResult(results, 2, + `Auto Scaling group "${asg.AutoScalingGroupName}" does not have ELB health check active`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/elbHealthCheckActive.spec.js b/plugins/aws/autoscaling/elbHealthCheckActive.spec.js new file mode 100644 index 000000000..b549f9d5a --- /dev/null +++ b/plugins/aws/autoscaling/elbHealthCheckActive.spec.js @@ -0,0 +1,220 @@ +var expect = require('chai').expect; +const elbHealthCheckActive = require('./elbHealthCheckActive'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "test-38", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:724d79e1-0e79-43f2-a65e-52a60d4868f9:autoScalingGroupName/test-38", + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-024af59c474e116ec", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ] + }, + { + "AutoScalingGroupName": "test-38", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:724d79e1-0e79-43f2-a65e-52a60d4868f9:autoScalingGroupName/test-38", + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [ + "test-38-classic" + ], + "TargetGroupARNs": [ + "arn:aws:elasticloadbalancing:us-east-1:112233445566:targetgroup/temp-tg/fee5b45af37af625" + ], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-024af59c474e116ec", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ] + }, + { + "AutoScalingGroupName": "test-38", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:724d79e1-0e79-43f2-a65e-52a60d4868f9:autoScalingGroupName/test-38", + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [ + "arn:aws:elasticloadbalancing:us-east-1:112233445566:targetgroup/temp-tg/fee5b45af37af625" + ], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-024af59c474e116ec", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ] + }, + { + "AutoScalingGroupName": "test-38-2", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:724d79e1-0e79-43f2-a65e-52a60d4868f9:autoScalingGroupName/test-38-2", + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [ + "test-38-classic", + ], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-024af59c474e116ec", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ] + } +]; + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbHealthCheckActive', function () { + describe('run', function () { + it('should PASS if Auto Scaling group does not use ELBs', function (done) { + const cache = createCache([describeAutoScalingGroups[2]]); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Auto Scaling group has ELB health check active', function (done) { + const cache = createCache([describeAutoScalingGroups[1]]); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Auto Scaling group does not use ELBs', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Auto Scaling group does not have ELB health check active', function (done) { + const cache = createCache([describeAutoScalingGroups[3]]); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no response found for describe Auto Scaling groups', function (done) { + const cache = createNullCache(); + elbHealthCheckActive.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/emptyASG.js b/plugins/aws/autoscaling/emptyASG.js new file mode 100644 index 000000000..a07b688be --- /dev/null +++ b/plugins/aws/autoscaling/emptyASG.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Empty AutoScaling Group', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Low', + description: 'Ensures all autoscaling groups contain at least 1 instance.', + more_info: 'AutoScaling groups that are no longer in use should be deleted to prevent accidental use.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/AutoScalingGroup.html', + recommended_action: 'Delete the unused AutoScaling group.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for auto scaling groups: ' + + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No auto scaling groups found', region); + return rcb(); + } + + describeAutoScalingGroups.data.forEach(function(asg){ + var resource = asg.AutoScalingGroupARN; + if (!asg.Instances || !asg.Instances.length) { + helpers.addResult(results, 2, + 'Auto scaling group: ' + asg.AutoScalingGroupName + ' does not contain any instance', + region, resource); + } else { + helpers.addResult(results, 0, + 'Auto scaling group: ' + asg.AutoScalingGroupName + ' contains ' + asg.Instances.length + ' instance(s)', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/emptyASG.spec.js b/plugins/aws/autoscaling/emptyASG.spec.js new file mode 100644 index 000000000..73bc84e1b --- /dev/null +++ b/plugins/aws/autoscaling/emptyASG.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +const emptyASG = require('./emptyASG'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + + + +const createCache = (asgs, notifications) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing autoscaling groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emptyASG', function () { + describe('run', function () { + it('should PASS if autoscaling group contains instance(s)', function (done) { + const cache = createCache([autoScalingGroups[0]]); + emptyASG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if autoscaling group does not contain instance(s)', function (done) { + const cache = createCache([autoScalingGroups[1]]); + emptyASG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no autoscaling group data found ', function (done) { + const cache = createCache([]); + emptyASG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe autoscaling group found', function (done) { + const cache = createErrorCache(); + emptyASG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no autoscaling group found', function (done) { + const cache = createNullCache(); + emptyASG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/sameAzElb.js b/plugins/aws/autoscaling/sameAzElb.js new file mode 100644 index 000000000..403458481 --- /dev/null +++ b/plugins/aws/autoscaling/sameAzElb.js @@ -0,0 +1,121 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AutoScaling ELB Same Availability Zone', + category: 'AutoScaling', + domain: 'Availability', + severity: 'High', + description: 'Ensures all autoscaling groups with attached ELBs are operating in the same availability zone.', + more_info: 'To work properly and prevent orphaned instances, ELBs must be created in the same availability zones as the backend instances in the autoscaling group.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-availability-zone.html', + recommended_action: 'Update the ELB to use the same availability zones as the autoscaling group.', + apis: ['AutoScaling:describeAutoScalingGroups', 'ELB:describeLoadBalancers', 'ELBv2:describeLoadBalancers'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:DeleteLoadBalancer','elasticloadbalancing:DeleteLoadBalancer'], + + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var loadBalancers = {}; + + async.each(regions.autoscaling, function(region, rcb){ + var autoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + var elasticLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + var elasticLoadBalancersV2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!autoScalingGroups || !elasticLoadBalancers || !elasticLoadBalancersV2) return rcb(); + + if (autoScalingGroups.err || !autoScalingGroups.data) { + helpers.addResult(results, 3, 'Unable to query for AutoScaling groups: ' + helpers.addError(autoScalingGroups), region); + return rcb(); + } + + if (elasticLoadBalancers.err || !elasticLoadBalancers.data) { + helpers.addResult(results, 3, 'Unable to query for Classic load balancers: ' + helpers.addError(elasticLoadBalancers), region); + return rcb(); + } + + if (elasticLoadBalancersV2.err || !elasticLoadBalancersV2.data) { + helpers.addResult(results, 3, 'Unable to query for Application/Network load balancers: ' + helpers.addError(elasticLoadBalancersV2), region); + return rcb(); + } + + if (!autoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No AutoScaling group found', region); + return rcb(); + } + + if (elasticLoadBalancers.data.length) { + elasticLoadBalancers.data.forEach(function(elb) { + if (elb.LoadBalancerName) { + loadBalancers[elb.LoadBalancerName] = elb; + } + }); + } + + if (elasticLoadBalancersV2.data.length) { + elasticLoadBalancersV2.data.forEach(function(elbv2) { + if (elbv2.LoadBalancerName) { + loadBalancers[elbv2.LoadBalancerName] = elbv2; + } + }); + } + + autoScalingGroups.data.forEach(function(asg) { + var asgAvailabilityZones = asg.AvailabilityZones; + var distinctAzs = []; + var resource = asg.AutoScalingGroupARN; + + if (asg.HealthCheckType == 'ELB') { + if (asg.LoadBalancerNames && asg.LoadBalancerNames.length) { + + asg.LoadBalancerNames.forEach(function(elbName) { + if (loadBalancers[elbName]) { + var loadBalancer = loadBalancers[elbName]; + var elbAvailabilityZones = loadBalancer.AvailabilityZones; + + if (elbAvailabilityZones && elbAvailabilityZones.length) { + elbAvailabilityZones.forEach(function(elbAz) { + if (asgAvailabilityZones && asgAvailabilityZones.length && !asgAvailabilityZones.includes(elbAz)) { + distinctAzs.push(elbAz); + } + }); + } + + if (distinctAzs.length) { + helpers.addResult(results, 2, + 'Auto scaling group "' + asg.AutoScalingGroupName + '" has load balancers in these different availability zones: ' + distinctAzs.join(', '), + region, resource); + } else { + helpers.addResult(results, 0, + 'Auto scaling group "' + asg.AutoScalingGroupName + '" has all load balancers in same availability zones', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'AutoScaling group "' + asg.AutoScalingGroupName + '" utilizes inactive load balancers', + region, resource); + } + }); + } else { + helpers.addResult(results, 0, 'AutoScaling group does not have any Load Balancer associated', region, resource); + } + } else { + helpers.addResult(results, 0, 'AutoScaling group does not utilize a load balancer', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/sameAzElb.spec.js b/plugins/aws/autoscaling/sameAzElb.spec.js new file mode 100644 index 000000000..005fcc458 --- /dev/null +++ b/plugins/aws/autoscaling/sameAzElb.spec.js @@ -0,0 +1,518 @@ +var expect = require('chai').expect; +const sameAzElb = require('./sameAzElb'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "LoadBalancerNames": ["my-load-balancer"], + "TargetGroupARNs": [], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a", + "us-west-1a" + ], + "LoadBalancerNames": ["my-load-balancer3"], + "TargetGroupARNs": [], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, +]; + +const loadBalancers = [ + { + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "BackendServerDescriptions": [ + { + "InstancePort": 80, + "PolicyNames": [ + "my-ProxyProtocol-policy" + ] + } + ], + "CanonicalHostedZoneName": "my-load-balancer-1234567890.us-west-2.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z3DZXE0EXAMPLE", + "CreatedTime": "2020-08-18T23:12:00.954Z", + "DNSName": "my-load-balancer-1234567890.us-west-2.elb.amazonaws.com", + "HealthCheck": { + "HealthyThreshold": 2, + "Interval": 30, + "Target": "HTTP:80/png", + "Timeout": 3, + "UnhealthyThreshold": 2 + }, + "Instances": [ + { + "InstanceId": "i-207d9717" + }, + { + "InstanceId": "i-afefb49b" + } + ], + "ListenerDescriptions": [ + { + "Listener": { + "InstancePort": 80, + "InstanceProtocol": "HTTP", + "LoadBalancerPort": 80, + "Protocol": "HTTP" + }, + "PolicyNames": [ + ] + }, + { + "Listener": { + "InstancePort": 443, + "InstanceProtocol": "HTTPS", + "LoadBalancerPort": 443, + "Protocol": "HTTPS", + "SSLCertificateId": "arn:aws:iam::123456789012:server-certificate/my-server-cert" + }, + "PolicyNames": [ + "ELBSecurityPolicy-2015-03" + ] + } + ], + "LoadBalancerName": "my-load-balancer", + "Policies": { + "AppCookieStickinessPolicies": [ + ], + "LBCookieStickinessPolicies": [ + { + "CookieExpirationPeriod": 60, + "PolicyName": "my-duration-cookie-policy" + } + ], + "OtherPolicies": [ + "my-PublicKey-policy", + "my-authentication-policy", + "my-SSLNegotiation-policy", + "my-ProxyProtocol-policy", + "ELBSecurityPolicy-2015-03" + ] + }, + "Scheme": "internet-facing", + "SecurityGroups": [ + "sg-a61988c3" + ], + "SourceSecurityGroup": { + "GroupName": "my-elb-sg", + "OwnerAlias": "123456789012" + }, + "Subnets": [ + "subnet-15aaab61" + ], + "VPCId": "vpc-a01106c2" + }, + { + "AvailabilityZones": [ + "us-west-2a" + ], + "BackendServerDescriptions": [ + { + "InstancePort": 80, + "PolicyNames": [ + "my-ProxyProtocol-policy" + ] + } + ], + "CanonicalHostedZoneName": "my-load-balancer-1234567890.us-west-2.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z3DZXE0EXAMPLE", + "CreatedTime": "2020-08-18T23:12:00.954Z", + "DNSName": "my-load-balancer-1234567890.us-west-2.elb.amazonaws.com", + "HealthCheck": { + "HealthyThreshold": 2, + "Interval": 30, + "Target": "HTTP:80/png", + "Timeout": 3, + "UnhealthyThreshold": 2 + }, + "Instances": [ + { + "InstanceId": "i-207d9717" + }, + { + "InstanceId": "i-afefb49b" + } + ], + "ListenerDescriptions": [ + { + "Listener": { + "InstancePort": 80, + "InstanceProtocol": "HTTP", + "LoadBalancerPort": 80, + "Protocol": "HTTP" + }, + "PolicyNames": [ + ] + }, + { + "Listener": { + "InstancePort": 443, + "InstanceProtocol": "HTTPS", + "LoadBalancerPort": 443, + "Protocol": "HTTPS", + "SSLCertificateId": "arn:aws:iam::123456789012:server-certificate/my-server-cert" + }, + "PolicyNames": [ + "ELBSecurityPolicy-2015-03" + ] + } + ], + "LoadBalancerName": "my-load-balancer2", + "Policies": { + "AppCookieStickinessPolicies": [ + ], + "LBCookieStickinessPolicies": [ + { + "CookieExpirationPeriod": 60, + "PolicyName": "my-duration-cookie-policy" + } + ], + "OtherPolicies": [ + "my-PublicKey-policy", + "my-authentication-policy", + "my-SSLNegotiation-policy", + "my-ProxyProtocol-policy", + "ELBSecurityPolicy-2015-03" + ] + }, + "Scheme": "internet-facing", + "SecurityGroups": [ + "sg-a61988c3" + ], + "SourceSecurityGroup": { + "GroupName": "my-elb-sg", + "OwnerAlias": "123456789012" + }, + "Subnets": [ + "subnet-15aaab61" + ], + "VPCId": "vpc-a01106c2" + } +]; + +const createCache = (asgs, elb, elbv2) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + }, + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + } + } + } + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing AutoScaling groups' + }, + }, + }, + }, + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + }, + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('sameAzElb', function () { + describe('run', function () { + it('should PASS if load balancer is in the same Availability Zone as of AutoScaling group', function (done) { + const cache = createCache([autoScalingGroups[0]], [loadBalancers[0]], []); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if AutoScaling does not utilizes load balancer as HealthCheckType', function (done) { + const cache = createCache([autoScalingGroups[2]], [], []); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if load balancer is not in the same Availability Zone as of AutoScaling group', function (done) { + const cache = createCache([autoScalingGroups[1]], [loadBalancers[1]],[]); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if autoscaling group utilizes an inactive load balancer', function (done) { + const cache = createCache([autoScalingGroups[1]], [], []); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKOWN if unable to query for load balancers', function (done) { + const cache = createCache([autoScalingGroups[1]], null, null); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe autoscaling groups', function (done) { + const cache = createErrorCache(); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no autoscaling group found', function (done) { + const cache = createNullCache(); + sameAzElb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierAsgApprovedAmi.js b/plugins/aws/autoscaling/webTierAsgApprovedAmi.js new file mode 100644 index 000000000..41b69e95e --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgApprovedAmi.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web-Tier ASG Launch Configurations Approved AMIs', + category: 'AutoScaling', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Web-Tier Auto Scaling Group Launch Configurations are using approved AMIs.', + more_info: 'Web-Tier Auto Scaling Group Launch Configurations should use approved AMIs only to launch EC2 instances within the ASG', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/LaunchConfiguration.html', + recommended_action: 'Update Web-Tier ASG Launch Configuration to use approved AMIs only', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations', 'STS:getCallerIdentity'], + settings: { + web_tier_tag_key: { + name: 'Auto Scaling Web-Tier Tag Key', + description: 'Web-Tier tag key used by Auto Scaling groups to indicate Web-Tier groups', + regex: '^.*$', + default: '' + }, + approved_amis: { + name: 'Approved AMIs for ASG Launch Configuration', + description: 'List of approved AMIs for ASG Launch Configuration', + regex: '[a-zA-Z0-9-,]', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:createLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + web_tier_tag_key: settings.web_tier_tag_key || this.settings.web_tier_tag_key.default, + approved_amis: settings.approved_amis || this.settings.approved_amis.default + }; + + if (!config.web_tier_tag_key.length) return callback(null, results, source); + + config.approved_amis = config.approved_amis.split(','); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + var webTierAsgFound = false; + async.each(describeAutoScalingGroups.data, function(asg, cb){ + var webTierTagFound = false; + if (asg.Tags && asg.Tags.length){ + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + if (tag && tag.Key && tag.Key === config.web_tier_tag_key) { + webTierTagFound = true; + webTierAsgFound = true; + break; + } + } + } + + if (webTierTagFound) { + var resource = asg.AutoScalingGroupARN; + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region, asg.AutoScalingGroupARN]); + + var imageFound = false; + var unapprovedAmis = []; + + if (!describeLaunchConfigurations || + describeLaunchConfigurations.err || + !describeLaunchConfigurations.data || + !describeLaunchConfigurations.data.LaunchConfigurations || + !describeLaunchConfigurations.data.LaunchConfigurations.length) { + helpers.addResult(results, 3, + `Unable to query launch configurations for Auto Scaling group "${asg.AutoScalingGroupName}": ${helpers.addError(describeLaunchConfigurations)}`, + region, resource); + return cb(); + } + + describeLaunchConfigurations.data.LaunchConfigurations.forEach(function(launchConfig){ + if (launchConfig.ImageId) { + imageFound = true; + if (config.approved_amis.indexOf(launchConfig.ImageId) === -1){ + unapprovedAmis.push(launchConfig.ImageId); + } + } + }); + + if (imageFound) { + if (!unapprovedAmis.length) { + helpers.addResult(results, 0, + `Launch Configuration for Web-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is using approved AMIs`, + region, resource); + } else { + helpers.addResult(results, 2, + `Launch Configuration for Web-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is using these unapproved AMIs: ${unapprovedAmis.join(', ')}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `Launch Configuration for Web-Tier Auto Scaling group "${asg.AutoScalingGroupName}" is not using any AMI`, + region, resource); + } + } + + if (!webTierAsgFound) { + helpers.addResult(results, 0, + 'No Web-Tier Auto Scaling groups found', region); + } + + cb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + + } +}; \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierAsgApprovedAmi.spec.js b/plugins/aws/autoscaling/webTierAsgApprovedAmi.spec.js new file mode 100644 index 000000000..7b92f8922 --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgApprovedAmi.spec.js @@ -0,0 +1,405 @@ +var expect = require('chai').expect; +const webTierAsgApprovedAmi = require('./webTierAsgApprovedAmi'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "app_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe8544444c", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe85445000", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, +]; + +const createCache = (asgs, config) => { + var asgArn = (asgs && asgs.length) ? asgs[0].AutoScalingGroupARN : null; + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + [asgArn]: { + data: config + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + message: 'error describing Auto Scaling group launch configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null + }, + }, + }; +}; + + +describe('webTierAsgApprovedAmi', function () { + describe('run', function () { + it('should PASS if Launch Configuration for Web-Tier Auto Scaling group is using approved AMIs', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[0]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier', approved_amis: "ami-0001903fe8544444c" }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Launch Configuration for Web-Tier Auto Scaling group is not using active AMIs', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[1]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Launch Configuration for Web-Tier Auto Scaling group is not using any AMI', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[2]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe launch configuration for Web-Tier Auto Scaling group', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Web-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[1]], describeLaunchConfigurations[2]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Auto Scaling groups response not found', function (done) { + const cache = createNullCache(); + webTierAsgApprovedAmi.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if Web-Tier tag key is provided in settings', function (done) { + const cache = createNullCache(); + webTierAsgApprovedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierAsgAssociatedElb.js b/plugins/aws/autoscaling/webTierAsgAssociatedElb.js new file mode 100644 index 000000000..444245faf --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgAssociatedElb.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web-Tier Auto Scaling Group Associated ELB', + category: 'AutoScaling', + domain: 'Availability', + severity: 'High', + description: 'Ensures that Web-Tier Auto Scaling Group has an associated Elastic Load Balancer', + more_info: 'Web-Tier Auto Scaling groups should have an ELB associated to distribute incoming traffic across EC2 instances.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/attach-load-balancer-asg.html', + recommended_action: 'Update Web-Tier Auto Scaling group to associate ELB to distribute incoming traffic.', + apis: ['AutoScaling:describeAutoScalingGroups'], + settings: { + web_tier_tag_key: { + name: 'Auto Scaling Web-Tier Tag Key', + description: 'Web-Tier tag key used by Auto Scaling groups to indicate Web-Tier groups', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:AttachLoadBalancers','autoscaling:DetachLoadBalancers'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var web_tier_tag_key = settings.web_tier_tag_key || this.settings.web_tier_tag_key.default; + + if (!web_tier_tag_key.length) return callback(); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + var webTierAsgFound = false; + describeAutoScalingGroups.data.forEach(asg => { + if (!asg.AutoScalingGroupARN) return; + + var resource = asg.AutoScalingGroupARN; + + if (asg.Tags && asg.Tags.length) { + var webTierTagFound = false; + + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + + if (tag.Key === web_tier_tag_key) { + webTierTagFound = true; + break; + } + } + + if (webTierTagFound) { + webTierAsgFound = true; + + if (asg.LoadBalancerNames && asg.LoadBalancerNames.length) { + helpers.addResult(results, 0, + `Auto Scaling group "${asg.AutoScalingGroupName}" has load balancers associated`, + region, resource); + } else { + helpers.addResult(results, 2, + `Auto Scaling group "${asg.AutoScalingGroupName}" does have any load balancers associated`, + region, resource); + } + } + } + }); + + if (!webTierAsgFound) { + helpers.addResult(results, 0, + 'No Web-Tier Auto Scaling groups found', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/webTierAsgAssociatedElb.spec.js b/plugins/aws/autoscaling/webTierAsgAssociatedElb.spec.js new file mode 100644 index 000000000..483d8d9e9 --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgAssociatedElb.spec.js @@ -0,0 +1,234 @@ +var expect = require('chai').expect; +const webTierAssociatedElb = require('./webTierAsgAssociatedElb'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": ["test-lb"], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "web_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "web_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const createCache = (asg) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asg + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + } + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('webTierAssociatedElb', function () { + describe('run', function () { + it('should PASS if Web-Tier Auto Scaling group has ELB associated', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Web-Tier Auto Scaling group does not have ELB associated', function (done) { + const cache = createCache([describeAutoScalingGroups[1]]); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Web-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[2]]); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Auto Scaling groups response not found', function (done) { + const cache = createNullCache(); + webTierAssociatedElb.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.js b/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.js new file mode 100644 index 000000000..d3283d31d --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.js @@ -0,0 +1,137 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web-Tier Auto Scaling Group CloudWatch Logs Enabled', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Web-Tier Auto Scaling Groups are using CloudWatch Logs agent.', + more_info: 'EC2 instance available within web-tier Auto Scaling Group (ASG) should use an AWS CloudWatch Logs agent to monitor, store and access log files.', + link: 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Install-CloudWatch-Agent.html', + recommended_action: 'Update web-tier Auto Scaling Group to use CloudWatch Logs agent', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations', 'STS:getCallerIdentity'], + settings: { + web_tier_tag_key: { + name: 'Auto Scaling Web-Tier Tag Key', + description: 'Web-Tier tag key used by Auto Scaling groups to indicate Web-Tier groups', + regex: '^.*$', + default: '' + }, + cw_log_agent_install_command: { + name: 'Cloudwatch Agent Install Command', + description: 'Commands to install Cloudwatch Agent', + regex: '^.*$', + default: '#!/bin/bash curl https://s3.amazonaws.com//aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O ' + + 'chmod +x ./awslogs-agent-setup.py ' + + './awslogs-agent-setup.py -n -r -c ' + }, + s3_cw_agent_config_file: { + name: 'S3 Cloudwatch Agent Config File Location', + description: 'S3 path of cloudwatch agent configuration file', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:createLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + web_tier_tag_key: settings.web_tier_tag_key || this.settings.web_tier_tag_key.default, + cw_log_agent_install_command: settings.cw_log_agent_install_command || this.settings.cw_log_agent_install_command.default, + s3_cw_agent_config_file: settings.s3_cw_agent_config_file || this.settings.s3_cw_agent_config_file.default + }; + + if (!config.web_tier_tag_key.length) return callback(); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for auto scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No auto scaling groups found', region); + return rcb(); + } + + var webTierAsgFound = false; + async.each(describeAutoScalingGroups.data, function(asg, cb){ + + var webTierTag = false; + if (asg.Tags && asg.Tags.length){ + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + if (tag && tag.Key && tag.Key === config.web_tier_tag_key) { + webTierTag = true; + webTierAsgFound = true; + break; + } + } + } + + if (webTierTag) { + var resource = asg.AutoScalingGroupARN; + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region, asg.AutoScalingGroupARN]); + + if (!describeLaunchConfigurations || + describeLaunchConfigurations.err || + !describeLaunchConfigurations.data || + !describeLaunchConfigurations.data.LaunchConfigurations || + !describeLaunchConfigurations.data.LaunchConfigurations.length) { + helpers.addResult(results, 3, + `Unable to query launch configurations for auto scaling group "${asg.AutoScalingGroupName}": ${helpers.addError(describeLaunchConfigurations)}`, + region, resource); + return cb(); + } + + var logsEnabled = false; + describeLaunchConfigurations.data.LaunchConfigurations.forEach(function(launchConfig){ + + config.cw_log_agent_install_command = config.cw_log_agent_install_command.replace('', region); + config.cw_log_agent_install_command = config.cw_log_agent_install_command.replace('', config.s3_cw_agent_config_file); + if (launchConfig.UserData && + launchConfig.UserData.indexOf(config.cw_log_agent_install_command) > -1) { + logsEnabled = true; + } + }); + + if (logsEnabled) { + helpers.addResult(results, 0, + `Auto scaling group "${asg.AutoScalingGroupName}" has CloudWatch logs enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `Auto scaling group "${asg.AutoScalingGroupName}" does not have CloudWatch logs enabled`, + region, resource); + } + } + + if (!webTierAsgFound) { + helpers.addResult(results, 0, + 'No Web-Tier auto scaling groups found', region); + } + cb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + + } +}; \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.spec.js b/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.spec.js new file mode 100644 index 000000000..cae250203 --- /dev/null +++ b/plugins/aws/autoscaling/webTierAsgCloudwatchLogs.spec.js @@ -0,0 +1,337 @@ +var expect = require('chai').expect; +const webTierAsgCloudWatchLogs = require('./webTierAsgCloudwatchLogs'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [{ + "ResourceId": "test-45", + "ResourceType": "auto-scaling-group", + "Key": "key_tier", + "Value": "", + "PropagateAtLaunch": true + }], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe8544444c", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "#!/bin/bash curl https://s3.amazonaws.com//aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O chmod +x ./awslogs-agent-setup.py ./awslogs-agent-setup.py -n -r us-east-1 -c s3://bucket-test-13/configFile.config", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + }, + { + "LaunchConfigurations": [ + { + "LaunchConfigurationName": "test-36", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:b7772b0b-5b9e-4fc2-8509-e298fed9fae2:launchConfigurationName/test-36", + "ImageId": "ami-0001903fe85445000", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-08f7cb8776a0176ef" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sda1", + "Ebs": { + "SnapshotId": "snap-0923e10c79d5bd837", + "VolumeSize": 50, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sde", + "Ebs": { + "SnapshotId": "snap-0ac876e65f9c1711c", + "VolumeSize": 250, + "VolumeType": "gp2", + "DeleteOnTermination": false, + "Encrypted": false + } + }, + { + "DeviceName": "/dev/sdc", + "NoDevice": true + }, + { + "DeviceName": "/dev/sdb", + "NoDevice": true + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "CreatedTime": "2020-09-24T00:00:29.642Z", + "EbsOptimized": false + } + ] + } +]; + +const createCache = (asgs, config) => { + var asgArn = (asgs && asgs.length) ? asgs[0].AutoScalingGroupARN : null; + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + [asgArn]: { + data: config + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing autos caling groups' + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + message: 'error describing auto scaling group notification configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null + }, + }, + }; +}; + + +describe('webTierAsgCloudWatchLogs', function () { + describe('run', function () { + it('should PASS if Web-Tier Auto Scaling launch configuration has CloudWatch logs enabled', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[0]); + const settings = { s3_cw_agent_config_file: 's3://bucket-test-13/configFile.config', web_tier_tag_key: 'web_tier' }; + webTierAsgCloudWatchLogs.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Web-Tier Auto Scaling launch configuration does not have CloudWatch logs enabled', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], describeLaunchConfigurations[1]); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe launch configuration for Web-Tier Auto Scaling group', function (done) { + const cache = createCache([describeAutoScalingGroups[0]]); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Web-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[1]], describeLaunchConfigurations[2]); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no Auto Scaling groups found', function (done) { + const cache = createNullCache(); + webTierAsgCloudWatchLogs.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/autoscaling/webTierIamRole.js b/plugins/aws/autoscaling/webTierIamRole.js new file mode 100644 index 000000000..e6348a78d --- /dev/null +++ b/plugins/aws/autoscaling/webTierIamRole.js @@ -0,0 +1,129 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web-Tier Launch Configurations IAM Roles', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensures that Web-Tier Auto Scaling launch configuration is configured to use a customer created IAM role.', + more_info: 'Web-Tier Auto Scaling launch configuration should have a customer created Web-Tier IAM role to provide necessary credentials to access AWS services.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/us-iam-role.html', + recommended_action: 'Update Web-Tier Auto Scaling launch configuration and attach a customer created Web-Tier IAM role', + apis: ['AutoScaling:describeAutoScalingGroups', 'AutoScaling:describeLaunchConfigurations'], + settings: { + web_tier_tag_key: { + name: 'Auto Scaling Web-Tier Tag Key', + description: 'Web-Tier tag key used by Auto Scaling groups to indicate Web-Tier groups', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['autoscaling:CreateAutoScalingGroup','autoscaling:DeleteAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:createLaunchConfiguration','autoscaling:DeleteLaunchConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var web_tier_tag_key = settings.web_tier_tag_key || this.settings.web_tier_tag_key.default; + + if (!web_tier_tag_key.length) return callback(); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling groups: ${helpers.addError(describeAutoScalingGroups)}`, + region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling groups found', region); + return rcb(); + } + + if (!describeLaunchConfigurations || describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for Auto Scaling group launch configurations: ${helpers.addError(describeLaunchConfigurations)}`, + region); + return rcb(); + } + + if (!describeLaunchConfigurations.data.length) { + helpers.addResult(results, 0, 'No Auto Scaling launch configurations found', region); + return rcb(); + } + + var launchConfigurations = {}; + describeLaunchConfigurations.data.forEach(config => { + if (!config.IamInstanceProfile) return; + + launchConfigurations[config.LaunchConfigurationName] = config.IamInstanceProfile; + }); + + var launchConfigurationAsgFound = false; + var webTierAsgFound = false; + + for (var g in describeAutoScalingGroups.data) { + var asg = describeAutoScalingGroups.data[g]; + + if (!asg.AutoScalingGroupARN) continue; + + var resource = asg.AutoScalingGroupARN; + + if (asg.LaunchConfigurationName && asg.LaunchConfigurationName.length){ + launchConfigurationAsgFound = true; + + if (asg.Tags && asg.Tags.length) { + var webTierTag = false; + + for (var t in asg.Tags) { + var tag = asg.Tags[t]; + + if (tag.Key === web_tier_tag_key) { + webTierTag = true; + webTierAsgFound = true; + break; + } + } + + if (webTierTag) { + if (launchConfigurations[asg.LaunchConfigurationName]) { + helpers.addResult(results, 0, + `Launch configuration for Web-Tier group "${asg.AutoScalingGroupName}" has customer created IAM role configured`, + region, resource); + } else { + helpers.addResult(results, 2, + `Launch configuration for Web-Tier group "${asg.AutoScalingGroupName}" does not have customer created IAM role configured`, + region, resource); + } + } + } + } + } + + if (!launchConfigurationAsgFound) { + helpers.addResult(results, 0, + 'No Auto Scaling groups utilizing launch configurations found', region); + return rcb(); + } + + if (!webTierAsgFound) { + helpers.addResult(results, 0, + 'No Web-Tier Auto Scaling groups with found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/autoscaling/webTierIamRole.spec.js b/plugins/aws/autoscaling/webTierIamRole.spec.js new file mode 100644 index 000000000..ec91977e9 --- /dev/null +++ b/plugins/aws/autoscaling/webTierIamRole.spec.js @@ -0,0 +1,417 @@ +var expect = require('chai').expect; +const webTierIamRole = require('./webTierIamRole'); + +const describeAutoScalingGroups = [ + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "web_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "auto-scaling-test-group", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/auto-scaling-test-group", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:d18695a1-726b-4011-9c1e-2f648fccbc26:autoScalingGroupName/test-36", + "LaunchConfigurationName": "test-lc-43", + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0342dfcfa0469d667", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchConfigurationName": "test-lc-43", + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-23T22:28:04.892Z", + "SuspendedProcesses": [], + "VPCZoneIdentifier": "subnet-06aa0f60", + "EnabledMetrics": [], + "Tags": [ + { + "ResourceId": "test-36", + "ResourceType": "auto-scaling-group", + "Key": "web_tier", + "Value": "web_tier", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": [ + "Default" + ], + "NewInstancesProtectedFromScaleIn": false, + "ServiceLinkedRoleARN": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" + }, + { + "AutoScalingGroupName": "test3-36", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:112233445566:autoScalingGroup:d4c0bbe4-e7d6-4d9b-89f1-06021f06a117:autoScalingGroupName/test3-36", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "$Default" + }, + "MinSize": 1, + "MaxSize": 1, + "DesiredCapacity": 1, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1d", + "us-east-1e" + ], + "LoadBalancerNames": [], + "TargetGroupARNs": [], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [ + { + "InstanceId": "i-0e8e9dd1aae06d4ae", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1e", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "CreatedTime": "2020-09-27T00:01:54.103Z", + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + }, + { + "LaunchConfigurationName": "test-lc-43", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:f20acca0-07b4-4cec-9174-75547b193446:launchConfigurationName/test-lc-43", + "ImageId": "ami-02354e95b39ca8dec", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "SnapshotId": "snap-06d919bfeced8496a", + "VolumeSize": 8, + "VolumeType": "gp2", + "DeleteOnTermination": true, + "Encrypted": false + } + } + ], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "", + "CreatedTime": "2020-08-30T22:49:13.182Z", + "EbsOptimized": false + } +]; + +const createCache = (asg, configuration) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asg + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + data: configuration + } + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + } + }, + }, + describeLaunchConfigurations: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling Launch Configurations' + } + } + }, + }, + }; +}; + +const createNullCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': null, + }, + describeLaunchConfigurations: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('webTierIamRole', function () { + describe('run', function () { + it('should PASS if launch configuration for Web-Tier group has customer IAM role configured', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[0]]); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if launch configuration for Web-Tier group does not have customer IAM role configured', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], [describeLaunchConfigurations[1]]); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups utilizing launch configurations found', function (done) { + const cache = createCache([describeAutoScalingGroups[2]], [describeLaunchConfigurations[0]]); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Web-Tier Auto Scaling groups found', function (done) { + const cache = createCache([describeAutoScalingGroups[3]], [describeLaunchConfigurations[0]]); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Auto Scaling launch configurations found', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], []); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling groups', function (done) { + const cache = createErrorCache(); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Auto Scaling launch configurations', function (done) { + const cache = createCache([describeAutoScalingGroups[0]], null); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if no response for describe Auto Scaling groups', function (done) { + const cache = createNullCache(); + webTierIamRole.run(cache, { web_tier_tag_key: 'web_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/backup/backupDeletionProtection.js b/plugins/aws/backup/backupDeletionProtection.js new file mode 100644 index 000000000..ecd28b72f --- /dev/null +++ b/plugins/aws/backup/backupDeletionProtection.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Deletion Protection Enabled', + category: 'Backup', + domain: 'Storage', + severity: 'High', + description: 'Ensure that an Amazon Backup vault access policy is configured to prevent the deletion of AWS backups in the backup vault.', + more_info: 'With AWS Backup, you can assign policies to backup vaults and the resources they contain. Assigning policies allows you to do things like grant access to users to create backup plans and on-demand backups, but limit their ability to delete recovery points after they are created.', + recommended_action: 'Add a statement in Backup vault access policy which denies global access to action: backup:DeleteRecoveryPoint', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/creating-a-vault-access-policy.html', + apis: ['Backup:listBackupVaults', 'Backup:getBackupVaultAccessPolicy'], + realtime_triggers: ['backup:CreateBackupVault','backup:DeleteBackupVault','backup:PutBackupVaultAccessPolicy','backup:DeleteBackupVaultAccessPolicy'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.backup, function(region, rcb){ + var listBackupVaults = helpers.addSource(cache, source, + ['backup', 'listBackupVaults', region]); + + if (!listBackupVaults) return rcb(); + + if (listBackupVaults.err || !listBackupVaults.data) { + helpers.addResult(results, 3, + `Unable to query for Backup vault list: ${helpers.addError(listBackupVaults)}`, region); + return rcb(); + } + + if (!listBackupVaults.data.length) { + helpers.addResult(results, 0, 'No Backup vaults found', region); + return rcb(); + } + + for (let vault of listBackupVaults.data){ + if (!vault.BackupVaultArn || !vault.BackupVaultName) continue; + + let resource = vault.BackupVaultArn; + + let getBackupVaultAccessPolicy = helpers.addSource(cache, source, + ['backup', 'getBackupVaultAccessPolicy', region, vault.BackupVaultName]); + + if (getBackupVaultAccessPolicy && getBackupVaultAccessPolicy.err && getBackupVaultAccessPolicy.err.code && + getBackupVaultAccessPolicy.err.code == 'ResourceNotFoundException') { + helpers.addResult(results, 2, + 'No access policy found for Backup vault', region, resource); + continue; + } + + if (!getBackupVaultAccessPolicy || getBackupVaultAccessPolicy.err || !getBackupVaultAccessPolicy.data) { + helpers.addResult(results, 3, `Unable to get Backup vault access policy: ${helpers.addError(getBackupVaultAccessPolicy)}`, region, resource); + continue; + } + + let statements = helpers.normalizePolicyDocument(getBackupVaultAccessPolicy.data.Policy); + let deleteProtected = false; + + for (let statement of statements){ + if (statement.Effect && statement.Effect.toUpperCase() === 'DENY' && + statement.Principal && helpers.globalPrincipal(statement.Principal, settings) && + statement.Action && statement.Action.find(action => action.toUpperCase().includes('BACKUP:DELETERECOVERYPOINT'))) { + deleteProtected = true; + } + } + + if (deleteProtected) { + helpers.addResult(results, 0, + 'Backup vault has deletion protection enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Backup vault does not have deletion protection enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/backup/backupDeletionProtection.spec.js b/plugins/aws/backup/backupDeletionProtection.spec.js new file mode 100644 index 000000000..29fc9212b --- /dev/null +++ b/plugins/aws/backup/backupDeletionProtection.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +const backupDeletionProtection = require('./backupDeletionProtection'); + +const listBackupVaults = [ + { + "BackupVaultName": "Default", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:Default", + "CreationDate": "2021-11-26T17:05:36.477000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/228d6374-d201-428d-b084-842fc7b2d148", + "CreatorRequestId": "Default", + "NumberOfRecoveryPoints": 0 + }, + { + "BackupVaultName": "sadeed1", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed1", + "CreationDate": "2022-01-21T23:05:24.095000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreatorRequestId": "967e0cd4-59c5-471c-8d4d-582a9ee27433", + "NumberOfRecoveryPoints": 0 + } +]; + + +const getBackupVaultAccessPolicy =[ + { + "BackupVaultName": "sadeed2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed2", + "Policy": { + "Version":"2012-10-17", + "Statement":[ + { + "Effect": 'Deny', + "Principal": '*', + "Action": [ 'backup:DeleteRecoveryPoint' ], + "Resource": [ '*' ] + }, + ], + } + } , + { + "BackupVaultName": "sadeed2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed2", + "Policy": { + "Version":"2012-10-17", + "Statement":[ + { + "Effect": 'Allow', + "Principal": '*', + "Action": [ 'backup:CopyIntoBackupVault' ], + "Resource": [ '*' ], + "Condition": { + 'ForAnyValue:StringLike': { 'aws:PrincipalOrgPaths': 'o-lcjto3x5wd/r-z1be/ou-[OU]/*' } + } + }, + ], + } + } +]; + +const createCache = (listBackupVaults, getBackupVaultAccessPolicy, listBackupVaultsErr, getBackupVaultAccessPolicyErr) => { + let name = (listBackupVaults && listBackupVaults.length) ? listBackupVaults[0].BackupVaultName : null; + return { + backup: { + listBackupVaults: { + 'us-east-1': { + data: listBackupVaults, + err: listBackupVaultsErr + } + }, + getBackupVaultAccessPolicy: { + 'us-east-1': { + [name]: { + data: getBackupVaultAccessPolicy, + err: getBackupVaultAccessPolicyErr + } + } + } + } + } +}; + + +describe('backupDeletionProtection', function () { + describe('run', function () { + it('should PASS if Backup vault has deletion protection enabled', function (done) { + const cache = createCache([listBackupVaults[1]], getBackupVaultAccessPolicy[0]); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup vault has deletion protection enabled') + done(); + }); + }); + + it('should FAIL if Backup vault does not have deletion protection enabled', function (done) { + const cache = createCache([listBackupVaults[0]], getBackupVaultAccessPolicy[1] ); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup vault does not have deletion protection enabled') + done(); + }); + }); + + it('should FAIL if no access policy found for Backup vault', function (done) { + const cache = createCache([listBackupVaults[0]], null , null, { message: 'An error occurred (ResourceNotFoundException) when calling the GetBackupVaultAccessPolicy operation', code : 'ResourceNotFoundException' } ); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No access policy found for Backup vault') + done(); + }); + }); + + + it('should PASS if no Backup vault list found', function (done) { + const cache = createCache([]); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Backup vaults found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Backup vault list', function (done) { + const cache = createCache(null, { message: 'Unable to query for Backup vault list' }); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Backup vault list') + done(); + }); + }); + + it('should UNKNOWN if Unable to get Backup vault policy', function (done) { + const cache = createCache([listBackupVaults[0]], null, null, { message: 'Unable to get Backup vault policy' }); + backupDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get Backup vault policy') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/backup/backupInUseForRDSSnapshots.js b/plugins/aws/backup/backupInUseForRDSSnapshots.js new file mode 100644 index 000000000..3189af163 --- /dev/null +++ b/plugins/aws/backup/backupInUseForRDSSnapshots.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup In Use For RDS Snapshots', + category: 'Backup', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that Amazon Backup is integrated with Amazon Relational Database Service in order to manage RDS database instance snapshots', + more_info: 'Amazon RDS creates and saves automated backups of your DB instance during the backup window of your DB instance. With Amazon Backup, you can centrally configure backup policies and rules, and monitor backup activity for AWS RDS database instances.', + recommended_action: 'Enable RDS database instance snapshots to improve the reliability of your backup strategy.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html', + apis: ['RDS:describeDBSnapshots'], + realtime_triggers: ['backup:CreateBackupSelection','backup:DeleteBackupSelection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb){ + var describeDBSnapshots = helpers.addSource(cache, source, + ['rds', 'describeDBSnapshots', region]); + + if (!describeDBSnapshots) return rcb(); + + if (describeDBSnapshots.err || !describeDBSnapshots.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS snapshots: ' + helpers.addError(describeDBSnapshots), region); + return rcb(); + } + + if (!describeDBSnapshots.data.length) { + helpers.addResult(results, 0, 'No RDS snapshots found', region); + return rcb(); + } + + let snapshots = describeDBSnapshots.data.find(snapshot => snapshot.SnapshotType && snapshot.SnapshotType.toLowerCase() === 'awsbackup'); + + if (snapshots) { + helpers.addResult(results, 0, 'Backup service is in use for RDS snapshots', region); + } else { + helpers.addResult(results, 2, 'Backup service is not in use for RDS snapshots', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/backup/backupInUseForRDSSnapshots.spec.js b/plugins/aws/backup/backupInUseForRDSSnapshots.spec.js new file mode 100644 index 000000000..4e3a7fa12 --- /dev/null +++ b/plugins/aws/backup/backupInUseForRDSSnapshots.spec.js @@ -0,0 +1,119 @@ +var expect = require('chai').expect; +var backupInUseForRDSSnapshots = require('./backupInUseForRDSSnapshots'); + +const describeDBSnapshots = [ + { + "DBSnapshotIdentifier": "database-1-final-snapshot", + "DBInstanceIdentifier": "database-1", + "SnapshotCreateTime": "2022-01-24T15:41:26.234000+00:00", + "Engine": "mariadb", + "AllocatedStorage": 20, + "Status": "available", + "Port": 3306, + "AvailabilityZone": "us-east-1a", + "VpcId": "vpc-0f4f4575a74fac014", + "InstanceCreateTime": "2022-01-24T15:27:38.423000+00:00", + "MasterUsername": "admin", + "EngineVersion": "10.5.13", + "LicenseModel": "general-public-license", + "SnapshotType": "awsbackup", + "OptionGroupName": "default:mariadb-10-5", + "PercentProgress": 100, + "StorageType": "gp2", + "Encrypted": false, + "DBSnapshotArn": "arn:aws:rds:us-east-1:000011112222:snapshot:database-1-final-snapshot", + "IAMDatabaseAuthenticationEnabled": false, + "ProcessorFeatures": [], + "DbiResourceId": "db-AVTEMNYVJCF3INR3EROOHGZXQQ", + "TagList": [], + "OriginalSnapshotCreateTime": "2022-01-24T15:41:26.234000+00:00" + }, + { + "DBSnapshotIdentifier": "database-1-final-snapshot", + "DBInstanceIdentifier": "database-1", + "SnapshotCreateTime": "2022-01-24T15:41:26.234000+00:00", + "Engine": "mariadb", + "AllocatedStorage": 20, + "Status": "available", + "Port": 3306, + "AvailabilityZone": "us-east-1a", + "VpcId": "vpc-0f4f4575a74fac014", + "InstanceCreateTime": "2022-01-24T15:27:38.423000+00:00", + "MasterUsername": "admin", + "EngineVersion": "10.5.13", + "LicenseModel": "general-public-license", + "SnapshotType": "manual", + "OptionGroupName": "default:mariadb-10-5", + "PercentProgress": 100, + "StorageType": "gp2", + "Encrypted": false, + "DBSnapshotArn": "arn:aws:rds:us-east-1:000011112222:snapshot:database-1-final-snapshot", + "IAMDatabaseAuthenticationEnabled": false, + "ProcessorFeatures": [], + "DbiResourceId": "db-AVTEMNYVJCF3INR3EROOHGZXQQ", + "TagList": [], + "OriginalSnapshotCreateTime": "2022-01-24T15:41:26.234000+00:00" + } +]; + + +const createCache = (snapshots, snapshotsErr) => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': { + err: snapshotsErr, + data: snapshots + }, + } + }, + }; +}; + +describe('backupInUseForRDSSnapshots', function () { + describe('run', function () { + it('should PASS if Backup service is in use for RDS snapshots', function (done) { + const cache = createCache([describeDBSnapshots[0]]); + backupInUseForRDSSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup service is in use for RDS snapshots'); + done(); + }); + }); + + it('should FAIL if Backup service is not in use for RDS snapshots', function (done) { + const cache = createCache([describeDBSnapshots[1]]); + backupInUseForRDSSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup service is not in use for RDS snapshots'); + done(); + }); + }); + + it('should PASS if no RDS snapshots found', function (done) { + const cache = createCache([]); + backupInUseForRDSSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No RDS snapshots found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for RDS snapshots', function (done) { + const cache = createCache(null, { message: "Unable to query for RDS snapshots" }); + backupInUseForRDSSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for RDS snapshots'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/backup/backupNotificationEnabled.js b/plugins/aws/backup/backupNotificationEnabled.js new file mode 100644 index 000000000..bc85b329e --- /dev/null +++ b/plugins/aws/backup/backupNotificationEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Failure Notification Enabled', + category: 'Backup', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that Amazon Backup vaults send notifications via Amazon SNS for each failed backup job event.', + more_info: 'AWS Backup can take advantage of the robust notifications delivered by Amazon Simple Notification Service (Amazon SNS). You can configure Amazon SNS to notify you of AWS Backup events from the Amazon SNS console.', + recommended_action: 'Configure Backup vaults to sent notifications alert for failed backup job events.', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/sns-notifications.html', + apis: ['Backup:listBackupVaults', 'Backup:getBackupVaultNotifications'], + realtime_triggers: ['backup:CreateBackupVault','backup:PutBackupVaultNotifications','backup:DeleteBackupVault'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.backup, function(region, rcb){ + var listBackupVaults = helpers.addSource(cache, source, + ['backup', 'listBackupVaults', region]); + + if (!listBackupVaults) return rcb(); + + if (listBackupVaults.err || !listBackupVaults.data) { + helpers.addResult(results, 3, + `Unable to query for Backup vault list: ${helpers.addError(listBackupVaults)}`, region); + return rcb(); + } + + if (!listBackupVaults.data.length) { + helpers.addResult(results, 0, 'No Backup vaults found', region); + return rcb(); + } + + for (let vault of listBackupVaults.data){ + if (!vault.BackupVaultArn) continue; + + let resource = vault.BackupVaultArn; + + let getBackupVaultNotifications = helpers.addSource(cache, source, + ['backup', 'getBackupVaultNotifications', region, vault.BackupVaultName]); + + if (getBackupVaultNotifications && getBackupVaultNotifications.err && getBackupVaultNotifications.err.message && + getBackupVaultNotifications.err.message.toUpperCase().includes('FAILED READING NOTIFICATIONS FROM DATABASE')) { + helpers.addResult(results, 2, + 'Backup vault does not have any notifications configured', region, resource); + continue; + } + + if (!getBackupVaultNotifications || getBackupVaultNotifications.err || !getBackupVaultNotifications.data || !getBackupVaultNotifications.data.BackupVaultEvents) { + helpers.addResult(results, 3, `Unable to get event notifications for Backup vault: ${helpers.addError(getBackupVaultNotifications)}`, region, resource); + continue; + } + + if (getBackupVaultNotifications.data.BackupVaultEvents.find(notification => notification && notification.toUpperCase() == 'BACKUP_JOB_FAILED')) { + helpers.addResult(results, 0, + 'Backup vault is configured to send alert notifications for failed Backup job events', + region, resource); + } else { + helpers.addResult(results, 2, + 'Backup vault is not configured to send alert notifications for failed Backup job events', + region, resource); + } + + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/backup/backupNotificationEnabled.spec.js b/plugins/aws/backup/backupNotificationEnabled.spec.js new file mode 100644 index 000000000..9766e3f45 --- /dev/null +++ b/plugins/aws/backup/backupNotificationEnabled.spec.js @@ -0,0 +1,126 @@ +var expect = require('chai').expect; +const backupNotificationEnabled = require('./backupNotificationEnabled'); + +const listBackupVaults = [ + { + "BackupVaultName": "sadeed1", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed1", + "CreationDate": "2022-01-21T23:05:24.095000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreatorRequestId": "967e0cd4-59c5-471c-8d4d-582a9ee27433", + "NumberOfRecoveryPoints": 0 + } +]; + + +const getBackupVaultNotifications =[ + { + "BackupVaultName": "sadeed2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed2", + "SNSTopicArn": "arn:aws:sns:us-east-1:000011112222:mine1.fifo", + "BackupVaultEvents": [ + "BACKUP_JOB_COMPLETED" + ] + }, + { + "BackupVaultName": "sadeed2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed2", + "SNSTopicArn": "arn:aws:sns:us-east-1:000011112222:mine1.fifo", + "BackupVaultEvents": [ + "BACKUP_JOB_FAILED" + ] + } + +]; + +const createCache = (listBackupVaults, getBackupVaultNotifications, listBackupVaultsErr, getBackupVaultNotificationsErr) => { + let name = (listBackupVaults && listBackupVaults.length) ? listBackupVaults[0].BackupVaultName : null; + return { + backup: { + listBackupVaults: { + 'us-east-1': { + data: listBackupVaults, + err: listBackupVaultsErr + } + }, + getBackupVaultNotifications: { + 'us-east-1': { + [name]: { + data: getBackupVaultNotifications, + err: getBackupVaultNotificationsErr + } + } + } + } + } +}; + + +describe('backupNotificationEnabled', function () { + describe('run', function () { + it('should PASS if Backup vault is configured to send alert notifications for failed Backup job events', function (done) { + const cache = createCache([listBackupVaults[0]], getBackupVaultNotifications[1]); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup vault is configured to send alert notifications for failed Backup job events') + done(); + }); + }); + + it('should FAIL if Backup vault is not configured to send alert notifications for failed Backup job events', function (done) { + const cache = createCache([listBackupVaults[0]], getBackupVaultNotifications[0] ); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup vault is not configured to send alert notifications for failed Backup job events') + done(); + }); + }); + + it('should FAIL if Backup vault does not have any notifications configured', function (done) { + const cache = createCache([listBackupVaults[0]], null , null, { message: 'Failed reading notifications from database'} ); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup vault does not have any notifications configured') + done(); + }); + }); + + + it('should PASS if no Backup vault list found', function (done) { + const cache = createCache([]); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Backup vaults found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Backup vault list', function (done) { + const cache = createCache(null, null, { message: 'Unable to query for Backup vault list' }); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Backup vault list') + done(); + }); + }); + + it('should UNKNOWN if Unable to get event notifications for selected Amazon Backup vault', function (done) { + const cache = createCache([listBackupVaults[0]], null, null, { message: 'Unable to get event notifications for selected Amazon Backup vault' }); + backupNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get event notifications for Backup vault') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/backup/backupResourceProtection.js b/plugins/aws/backup/backupResourceProtection.js new file mode 100644 index 000000000..6352e20db --- /dev/null +++ b/plugins/aws/backup/backupResourceProtection.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Resource Protection', + category: 'Backup', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that protected resource types feature is enabled and configured for Amazon Backup service within.', + more_info: 'Amazon Backup protected resource types feature allows you to choose which resource types are protected by backup plans on per-region basis.', + recommended_action: 'Enable protected resource type feature in order to meet compliance requirements.', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/whatisbackup.html', + apis: ['Backup:describeRegionSettings'], + settings: { + backup_resource_type: { + name: 'Protected Amazon Backup Resource Types', + description: 'Comma separated list of resource types that should be backup protected i.e. rds,efs', + regex: '^.*$', + default:'' + } + }, + realtime_triggers: ['backup:UpdateRegionSettings'], + + run: function(cache, settings, callback) { + var config = { + backup_resource_type:(settings.backup_resource_type || this.settings.backup_resource_type.default) + }; + + config.backup_resource_type = config.backup_resource_type.replace(/\s/g, ''); + + if (!config.backup_resource_type.length) return callback(null, results, source); + + config.backup_resource_type = config.backup_resource_type.toLowerCase().split(','); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.backup, function(region, rcb){ + var describeRegionSettings = helpers.addSource(cache, source, + ['backup', 'describeRegionSettings', region]); + + if (!describeRegionSettings || describeRegionSettings.err || + !describeRegionSettings.data) { + helpers.addResult(results, 3, `Unable to query for Backup resource type opt in preference: ${helpers.addError(describeRegionSettings)}`, region); + return rcb(); + } + + if (!describeRegionSettings.data) { + helpers.addResult(results, 0, 'No Backup region settings found', region); + return rcb(); + } + + let loweredResourceTypes = Object.keys(describeRegionSettings.data).reduce((acc, key) => { + acc[key.toLowerCase().replace(/\s/g, '')] = describeRegionSettings.data[key]; + return acc; + }, {}); + + + let missingResourceTypes = []; + config.backup_resource_type.forEach(element => { + if (!loweredResourceTypes[element]) { + missingResourceTypes.push(element); + } + }); + + if (!missingResourceTypes.length) { + helpers.addResult(results, 0, + 'All desired resource types are protected by Backup service', region); + } else { + helpers.addResult(results, 2, + 'These desired resource types are not protected by Backup service: ' + missingResourceTypes.join(', '), region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/backup/backupResourceProtection.spec.js b/plugins/aws/backup/backupResourceProtection.spec.js new file mode 100644 index 000000000..c9b0bcc3b --- /dev/null +++ b/plugins/aws/backup/backupResourceProtection.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var backupResourceProtection = require('./backupResourceProtection'); + +const describeRegionSettings = [ + { + "Aurora": true, + "DocumentDB": true, + "DynamoDB": true, + "EBS": true, + "EC2": true, + "EFS": true, + "FSx": true, + "Neptune": true, + "RDS": true, + "Storage Gateway": true, + "VirtualMachine": true + }, + { + "Aurora": true, + "DocumentDB": false, + "DynamoDB": false, + "EBS": true, + "EC2": false, + "EFS": true, + "FSx": true, + "Neptune": true, + "RDS": true, + "Storage Gateway": true, + "VirtualMachine": true + } +]; + +const createCache = (resource, resourceErr) => { + return { + backup: { + describeRegionSettings: { + 'us-east-1': { + err: resourceErr, + data: resource + }, + }, + }, + }; +}; + +describe('backupResourceProtection', function () { + describe('run', function () { + it('should PASS if All desired resource types are protected by Backup service', function (done) { + const cache = createCache(describeRegionSettings[0]); + backupResourceProtection.run(cache, { backup_resource_type:'rds, efs, aurora, dynamodb, storage gateway, ec2, ebs, virtual machine'}, (err, results) => { + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('All desired resource types are protected by Backup service'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if These desired resource types are not protected by Backup service', function (done) { + const cache = createCache(describeRegionSettings[1]); + backupResourceProtection.run(cache, { backup_resource_type:'rds, efs, aurora, dynamodb, storage gateway, ec2, ebs, virtual machine'}, (err, results) =>{ + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('These desired resource types are not protected by Backup service'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN Unable to query for Backup resource type opt in preference', function (done) { + const cache = createCache(null, { message: "Unable to query for Backup resource type opt in preference" }); + backupResourceProtection.run(cache, { backup_resource_type: 'rds, efs, aurora, dynamodb, storage gateway, ec2, ebs, virtual machine'}, (err, results) => { + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/backup/backupVaultEncrypted.js b/plugins/aws/backup/backupVaultEncrypted.js new file mode 100644 index 000000000..a8c69131a --- /dev/null +++ b/plugins/aws/backup/backupVaultEncrypted.js @@ -0,0 +1,106 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Vault Encrypted', + category: 'Backup', + domain: 'Storage', + severity: 'High', + description: 'Ensure that your Amazon Backup vaults are using AWS KMS Customer Master Keys instead of AWS managed-keys (i.e. default encryption keys).', + more_info: 'When you encrypt AWS Backup using your own AWS KMS Customer Master Keys (CMKs) for enhanced protection, you have full control over who can use the encryption keys to access your backups.', + recommended_action: 'Encrypt Backup Vault with desired encryption level', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/creating-a-vault.html', + apis: ['Backup:listBackupVaults', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + backup_vault_desired_encryption_level: { + name: 'CodeArtifact Domain Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['backup:CreateBackupVault','backup:DeleteBackupVault'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.backup_vault_desired_encryption_level || this.settings.backup_vault_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.backup, function(region, rcb){ + var listBackupVaults = helpers.addSource(cache, source, + ['backup', 'listBackupVaults', region]); + + if (!listBackupVaults) return rcb(); + + if (listBackupVaults.err || !listBackupVaults.data) { + helpers.addResult(results, 3, + `Unable to list Backup vaults: ${helpers.addError(listBackupVaults)}`, region); + return rcb(); + } + + if (!listBackupVaults.data.length) { + helpers.addResult(results, 0, + 'No Backup vaults found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let backupVault of listBackupVaults.data) { + if (!backupVault.BackupVaultArn) continue; + + let resource = backupVault.BackupVaultArn; + if (backupVault.EncryptionKeyArn) { + var kmsKeyId = backupVault.EncryptionKeyArn.split('/')[1] ? backupVault.EncryptionKeyArn.split('/')[1] : backupVault.EncryptionKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, backupVault.EncryptionKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Backup vault is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Backup vault is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Backup vaults does not have encryption enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/backup/backupVaultEncrypted.spec.js b/plugins/aws/backup/backupVaultEncrypted.spec.js new file mode 100644 index 000000000..d201125fd --- /dev/null +++ b/plugins/aws/backup/backupVaultEncrypted.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var backupVaultEncrypted = require('./backupVaultEncrypted'); + +const listBackupVaults = [ + { + BackupVaultName: 'sadeed-vault', + BackupVaultArn: 'arn:aws:backup:us-east-1:000011112222:backup-vault:sadeed-vault', + CreationDate: '2021-11-08T10:12:46.700Z', + EncryptionKeyArn: 'arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250' + }, + { + BackupVaultName: "aws/efs/automatic-backup-vault", + BackupVaultArn: "arn:aws:backup:us-east-1:000011112222:backup-vault:aws/efs/automatic-backup-vault", + CreationDate: "2020-10-18T10:53:45.887000-07:00", + EncryptionKeyArn: "arn:aws:kms:us-east-1:000011112222:key/f4942dd6-bce5-4213-bdd3-cc8ccd87dd89" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + }, + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/f4942dd6-bce5-4213-bdd3-cc8ccd87dd890" + } +] + +const createCache = (backupVault, keys, describeKey, backupVaultErr, keysErr, describeKeyErr) => { + var keyId = (backupVault && backupVault.length) ? backupVault[0].EncryptionKeyArn.split('/')[1] : null; + return { + backup: { + listBackupVaults: { + 'us-east-1': { + err: backupVaultErr, + data: backupVault + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('backupVaultEncrypted', function () { + describe('run', function () { + it('should PASS if Backup Vault is encrypted with desired encryption level', function (done) { + const cache = createCache([listBackupVaults[0]], listKeys, describeKey[0]); + backupVaultEncrypted.run(cache, { backup_vault_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Backup vault is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Backup Vault is not encrypted with desired encyption level', function (done) { + const cache = createCache([listBackupVaults[0]], listKeys, describeKey[1]); + backupVaultEncrypted.run(cache, { backup_vault_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Backup vault is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Backup vault found', function (done) { + const cache = createCache([]); + backupVaultEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Backup vaults found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Backup vault', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Backup vault encryption" }); + backupVaultEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + backupVaultEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/backup/backupVaultHasTags.js b/plugins/aws/backup/backupVaultHasTags.js new file mode 100644 index 000000000..83fd72191 --- /dev/null +++ b/plugins/aws/backup/backupVaultHasTags.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Vault Has Tags', + category: 'Backup', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that AWS Backup Vaults have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Backup Vault and add tags.', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/creating-a-vault.html', + apis: ['Backup:listBackupVaults', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['backup:CreateBackupVault','backup:DeleteBackupVault','backup:TagResource','backup:UntagResource'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.backup, function(region, rcb){ + var listBackupVaults = helpers.addSource(cache, source, + ['backup', 'listBackupVaults', region]); + + if (!listBackupVaults) return rcb(); + + if (listBackupVaults.err || !listBackupVaults.data) { + helpers.addResult(results, 3, + `Unable to list Backup vaults: ${helpers.addError(listBackupVaults)}`, region); + return rcb(); + } + + if (!listBackupVaults.data.length) { + helpers.addResult(results, 0,'No Backup vaults found', region); + return rcb(); + } + + const vaultARN = []; + for (let vault of listBackupVaults.data){ + if (!vault.BackupVaultArn) continue; + vaultARN.push(vault.BackupVaultArn); + } + helpers.checkTags(cache, 'Backup Vault', vaultARN, region, results, settings); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/backup/backupVaultHasTags.spec.js b/plugins/aws/backup/backupVaultHasTags.spec.js new file mode 100644 index 000000000..b047e9e29 --- /dev/null +++ b/plugins/aws/backup/backupVaultHasTags.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +const backupVaultHasTags = require('./backupVaultHasTags'); + +const listBackupVaults = [ + { + "BackupVaultName": "Default", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:Default", + "CreationDate": "2021-11-26T17:05:36.477000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/228d6374-d201-428d-b084-842fc7b2d148", + "CreatorRequestId": "Default", + "NumberOfRecoveryPoints": 0 + }, + { + "BackupVaultName": "sadeed1", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:Default", + "CreationDate": "2022-01-21T23:05:24.095000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreatorRequestId": "967e0cd4-59c5-471c-8d4d-582a9ee27433", + "NumberOfRecoveryPoints": 0 + } +]; + + +const resourcegroupstaggingapi =[ + { + "ResourceARN": "arn:aws:backup:us-east-1:000011112222:backup-vault:Default", + "Tags": [{key:"key1", value:"value"}], + }, + { + "ResourceARN": "arn:aws:backup:us-east-1:000011112222:backup-vault:Default", + "Tags": [], + } +]; + +const createCache = (listBackupVaults, rgData) => { + return { + backup: { + listBackupVaults: { + 'us-east-1': { + data: listBackupVaults, + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + } + } +}; + + +describe('backupVaultHasTags', function () { + describe('run', function () { + it('should PASS if Backup vault have tags', function (done) { + const cache = createCache([listBackupVaults[0]], [resourcegroupstaggingapi[0]]); + backupVaultHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup Vault has tags') + done(); + }); + }); + + it('should FAIL if Backup vault does not have tags', function (done) { + const cache = createCache([listBackupVaults[0]], [resourcegroupstaggingapi[1]] ); + backupVaultHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup Vault does not have any tags') + done(); + }); + }); + + + it('should PASS if no Backup vault list found', function (done) { + const cache = createCache([], null); + backupVaultHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Backup vaults found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Backup vault list', function (done) { + const cache = createCache(null, null); + backupVaultHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list Backup vaults') + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listBackupVaults[0]], null); + backupVaultHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources from group tagging api') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/backup/backupVaultPolicies.js b/plugins/aws/backup/backupVaultPolicies.js new file mode 100644 index 000000000..4ccba1bab --- /dev/null +++ b/plugins/aws/backup/backupVaultPolicies.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Backup Vault Policies', + category: 'Backup', + domain: 'Storage', + severity: 'High', + description: 'Ensures Backup Vault policies are properly scoped with specific permissions.', + more_info: 'Policies attached to Backup Vault should be scoped to least-privileged access and avoid the use of wildcards.', + recommended_action: 'Ensure that all Backup Vault policies are scoped to specific services and API calls.', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/creating-a-vault-access-policy.html', + apis: ['Backup:listBackupVaults', 'Backup:getBackupVaultAccessPolicy', 'STS:getCallerIdentity'], + realtime_triggers: ['backup:CreateBackupVault','backup:DeleteBackupVault','backup:PutBackupVaultAccessPolicy','backup:DeleteBackupVaultAccessPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.backup, function(region, rcb){ + var listBackupVaults = helpers.addSource(cache, source, + ['backup', 'listBackupVaults', region]); + + if (!listBackupVaults) return rcb(); + + if (listBackupVaults.err || !listBackupVaults.data) { + helpers.addResult(results, 3, + `Unable to query for Backup vault list: ${helpers.addError(listBackupVaults)}`, region); + return rcb(); + } + + if (!listBackupVaults.data.length) { + helpers.addResult(results, 0, 'No Backup vaults found', region); + return rcb(); + } + + for (let vault of listBackupVaults.data) { + if (!vault.BackupVaultArn || !vault.BackupVaultName) continue; + + let resource = vault.BackupVaultArn; + + let getBackupVaultAccessPolicy = helpers.addSource(cache, source, + ['backup', 'getBackupVaultAccessPolicy', region, vault.BackupVaultName]); + + if (getBackupVaultAccessPolicy.err && getBackupVaultAccessPolicy.err.message === `Backup Vault ${resource} has no associated POLICY`) { + helpers.addResult(results, 0, 'Backup Vault has no associated policy attached', region, resource); + } else if (!getBackupVaultAccessPolicy || getBackupVaultAccessPolicy.err || !getBackupVaultAccessPolicy.data || !getBackupVaultAccessPolicy.data.Policy) { + helpers.addResult(results, 3, `Unable to get Backup vault access policy: ${helpers.addError(getBackupVaultAccessPolicy)}`, region, resource); + } else { + var statements = helpers.normalizePolicyDocument(getBackupVaultAccessPolicy.data.Policy); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'Backup Vault policy does not have trust relationship statements', + region, resource); + continue; + } + + var actions = []; + + for (var statement of statements) { + // Evaluates whether the effect of the statement is to "allow" access to the SNS + var effectEval = (statement.Effect && statement.Effect == 'Allow' ? true : false); + + // Evaluates whether the principal is open to everyone/anonymous + var principalEval = helpers.globalPrincipal(statement.Principal, settings); + + // Evaluates whether condition is scoped or global + let scopedCondition; + if (statement.Condition) scopedCondition = helpers.isValidCondition(statement, [], helpers.IAM_CONDITION_OPERATORS, false, accountId, settings); + + if (!scopedCondition && principalEval && effectEval) { + if (statement.Action && typeof statement.Action === 'string') { + if (actions.indexOf(statement.Action) === -1) { + actions.push(statement.Action); + } + } else if (statement.Action && statement.Action.length) { + for (var a in statement.Action) { + if (actions.indexOf(statement.Action[a]) === -1) { + actions.push(statement.Action[a]); + } + } + } + } + } + + if (actions.length) { + helpers.addResult(results, 2, + 'Backup Vault policy allows global access to the action(s): ' + actions, + region, resource); + } else { + helpers.addResult(results, 0, + 'Backup Vault policy does not allow global access.', + region, resource); + } + } + } + rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/backup/backupVaultPolicies.spec.js b/plugins/aws/backup/backupVaultPolicies.spec.js new file mode 100644 index 000000000..c09b3745c --- /dev/null +++ b/plugins/aws/backup/backupVaultPolicies.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +const backupVaultPolicies = require('./backupVaultPolicies'); + +const listBackupVaults = [ + { + "BackupVaultName": "test", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:test", + "CreationDate": "2021-11-26T17:05:36.477000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/228d6374-d201-428d-b084-842fc7b2d148", + "CreatorRequestId": "test", + "NumberOfRecoveryPoints": 0 + }, + { + "BackupVaultName": "test2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:test2", + "CreationDate": "2022-01-21T23:05:24.095000+05:00", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreatorRequestId": "967e0cd4-59c5-471c-8d4d-582a9ee27433", + "NumberOfRecoveryPoints": 0 + } +]; + + +const getBackupVaultAccessPolicy =[ + { + "BackupVaultName": "test", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:test", + "Policy": { + "Version":"2012-10-17", + "Statement":[ + { + "Effect": 'Deny', + "Principal": '*', + "Action": [ 'backup:DeleteRecoveryPoint' ], + "Resource": [ '*' ] + }, + ], + } + } , + { + "BackupVaultName": "test2", + "BackupVaultArn": "arn:aws:backup:us-east-1:000011112222:backup-vault:test2", + "Policy": { + "Version":"2012-10-17", + "Statement":[ + { + "Effect": 'Allow', + "Principal": '*', + "Action": [ 'backup:CopyIntoBackupVault' ], + "Resource": [ '*' ], + "Condition": { + 'ForAnyValue:StringLike': { 'aws:PrincipalOrgPaths': 'o-lcjto3x5wd/r-z1be/ou-[OU]/*' } + } + }, + ], + } + } +]; + +const createCache = (listBackupVaults, getBackupVaultAccessPolicy, listBackupVaultsErr, getBackupVaultAccessPolicyErr) => { + let name = (listBackupVaults && listBackupVaults.length) ? listBackupVaults[0].BackupVaultName : null; + return { + backup: { + listBackupVaults: { + 'us-east-1': { + data: listBackupVaults, + err: listBackupVaultsErr + } + }, + getBackupVaultAccessPolicy: { + 'us-east-1': { + [name]: { + data: getBackupVaultAccessPolicy, + err: getBackupVaultAccessPolicyErr + } + } + } + } + } +}; + + +describe('backupVaultPolicies', function () { + describe('run', function () { + it('should PASS if Backup vault does not allow global access to the action', function (done) { + const cache = createCache([listBackupVaults[1]], getBackupVaultAccessPolicy[0]); + backupVaultPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup Vault policy does not allow global access.') + done(); + }); + }); + + it('should FAIL if Backup vault allow global access to the action', function (done) { + const cache = createCache([listBackupVaults[0]], getBackupVaultAccessPolicy[1] ); + backupVaultPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup Vault policy allows global access to the action') + done(); + }); + }); + + it('should PASS if no Backup vault list found', function (done) { + const cache = createCache([]); + backupVaultPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Backup vaults found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Backup vault list', function (done) { + const cache = createCache(null, { message: 'Unable to query for Backup vault list' }); + backupVaultPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Backup vault list') + done(); + }); + }); + + it('should UNKNOWN if Unable to get Backup vault policy', function (done) { + const cache = createCache([listBackupVaults[0]], null, null, { message: 'Unable to get Backup vault policy' }); + backupVaultPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get Backup vault policy') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/backup/compliantLifecycleConfigured.js b/plugins/aws/backup/compliantLifecycleConfigured.js new file mode 100644 index 000000000..feb48756e --- /dev/null +++ b/plugins/aws/backup/compliantLifecycleConfigured.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Backup Compliant Lifecycle Configured', + category: 'Backup', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that a compliant lifecycle configuration is enabled for your Amazon Backup plans in order to meet compliance requirements when it comes to security and cost optimization.', + more_info: 'The AWS Backup lifecycle configuration contains an array of transition objects specifying how long in days before a recovery point transitions to cold storage or is deleted.', + recommended_action: 'Enable compliant lifecycle configuration for your Amazon Backup plans', + link: 'https://docs.aws.amazon.com/aws-backup/latest/devguide/API_Lifecycle.html', + apis: ['Backup:listBackupPlans', 'Backup:getBackupPlan'], + realtime_triggers: ['backup:CreateBackupPlan','backup:UpdateBackupPlan','backup:DeleteBackupPlan'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.backup, function(region, rcb) { + var listBackupPlans = helpers.addSource(cache, source, + ['backup', 'listBackupPlans', region]); + + if (!listBackupPlans) return rcb(); + + if (listBackupPlans.err || !listBackupPlans.data) { + helpers.addResult(results, 3, + 'Unable to list Backup plans: ' + helpers.addError(listBackupPlans), region); + return rcb(); + } + + if (!listBackupPlans.data.length) { + helpers.addResult(results, 0, 'No Backup plans found', region); + return rcb(); + } + + for (let plan of listBackupPlans.data) { + if (!plan.BackupPlanArn) continue; + + var resource = plan.BackupPlanArn; + var getBackupPlan = helpers.addSource(cache, source, + ['backup', 'getBackupPlan', region, plan.BackupPlanId]); + + if (!getBackupPlan || getBackupPlan.err || !getBackupPlan.data) { + helpers.addResult(results, 3, + `Unable to get Backup plan description: ${helpers.addError(getBackupPlan)}`, + region, resource); + continue; + } + + if (!getBackupPlan.data.BackupPlan || + !getBackupPlan.data.BackupPlan.Rules) { + helpers.addResult(results, 2, + 'No lifecycle configuration rules found for Backup plan', region, resource); + continue; + } + + let found = getBackupPlan.data.BackupPlan.Rules.find(rule => rule.Lifecycle && rule.Lifecycle.DeleteAfterDays && rule.Lifecycle.MoveToColdStorageAfterDays); + if (found) { + helpers.addResult(results, 0, + 'Backup plan has lifecycle configuration enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Backup plan does not have lifecycle configuration enabled', region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/backup/compliantLifecycleConfigured.spec.js b/plugins/aws/backup/compliantLifecycleConfigured.spec.js new file mode 100644 index 000000000..ea8b0a69a --- /dev/null +++ b/plugins/aws/backup/compliantLifecycleConfigured.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +const compliantLifecycleConfigured = require('./compliantLifecycleConfigured'); + +const listBackupPlans = [ + { + "BackupPlanArn": "arn:aws:backup:us-east-1:000011112222:backup-plan:07ade659-ed39-4a80-a62c-267828ca315a", + "BackupPlanId": "07ade659-ed39-4a80-a62c-267828ca315a", + "CreationDate": "2022-01-21T16:19:55.937000+05:00", + "VersionId": "YTY2NGEzZjMtODQxZC00OTlhLTg0MTYtODQ3NWNhNjg3NWUz", + "BackupPlanName": "mine1" + }, + { + "BackupPlanArn": "arn:aws:backup:us-east-1:000011112222:backup-plan:07ade659-ed39-4a80-a62c-267828ca325a", + "BackupPlanId": "07ade659-ed39-4a80-a62c-267828ca315a", + "CreationDate": "2022-01-21T16:19:55.937000+05:00", + "VersionId": "YTY2NGEzZjMtODQxZC00OTlhLTg0MTYtODQ3NWNhNjg3NWUx", + "BackupPlanName": "mine2" + } +]; + +const getBackupPlan = [ + { + "BackupPlan": { + "BackupPlanName": "mine1", + "Rules": [ + { + "RuleName": "DailyBackups", + "TargetBackupVaultName": "Default", + "ScheduleExpression": "cron(0 5 ? * * *)", + "StartWindowMinutes": 480, + "CompletionWindowMinutes": 10080, + "Lifecycle": { + "DeleteAfterDays": 35, + "MoveToColdStorageAfterDays": 120 + }, + "RuleId": "5e0a4936-0da8-4455-a63c-c63ec62e1474" + } + ] + }, + "BackupPlanId": "07ade659-ed39-4a80-a62c-267828ca315a", + "BackupPlanArn": "arn:aws:backup:us-east-1:000011112222:backup-plan:07ade659-ed39-4a80-a62c-267828ca315a", + "VersionId": "YTY2NGEzZjMtODQxZC00OTlhLTg0MTYtODQ3NWNhNjg3NWUz", + "CreationDate": "2022-01-21T16:19:55.937000+05:00" + }, + { + "BackupPlan": { + "BackupPlanName": "mine1", + "Rules": [ + { + "RuleName": "DailyBackups", + "TargetBackupVaultName": "Default", + "ScheduleExpression": "cron(0 5 ? * * *)", + "StartWindowMinutes": 480, + "CompletionWindowMinutes": 10080, + "Lifecycle": { + "DeleteAfterDays": null, + "MoveToColdStorageAfterDays": null + }, + "RuleId": "5e0a4936-0da8-4455-a63c-c63ec62e1474" + } + ] + }, + "BackupPlanId": "07ade659-ed39-4a80-a62c-267828ca325a", + "BackupPlanArn": "arn:aws:backup:us-east-1:000011112222:backup-plan:07ade659-ed39-4a80-a62c-267828ca325a", + "VersionId": "YTY2NGEzZjMtODQxZC00OTlhLTg0MTYtODQ3NWNhNjg3NWUx", + "CreationDate": "2022-01-21T16:19:55.937000+05:00" + } +]; + + +const createCache = (plans, getBackupPlan, plansErr, getBackupPlanErr) => { + var id = (plans && plans.length) ? plans[0].BackupPlanId : null; + return { + backup: { + listBackupPlans: { + 'us-east-1': { + data: plans, + err: plansErr + }, + }, + getBackupPlan: { + 'us-east-1': { + [id]: { + data: getBackupPlan, + err: getBackupPlanErr + } + } + } + }, + } +} + +describe('compliantLifecycleConfigured', function () { + describe('run', function () { + it('should PASS if Backup plan has lifecycle configuration enabled', function (done) { + const cache = createCache([listBackupPlans[0]], getBackupPlan[0]); + compliantLifecycleConfigured.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup plan has lifecycle configuration enabled') + done(); + }); + }); + + it('should FAIL if Backup plan does not have lifecycle configuration enabled', function (done) { + const cache = createCache([listBackupPlans[1]], getBackupPlan[1]); + compliantLifecycleConfigured.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Backup plan does not have lifecycle configuration enabled') + done(); + }); + }); + + it('should PASS if no Backup plans found', function (done) { + const cache = createCache([]); + compliantLifecycleConfigured.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Backup plans found') + done(); + }); + }); + + it('should UNKNOWN if Unable to list Backup plans', function (done) { + const cache = createCache(null, null, { message: "Unable to list Backup plans" }); + compliantLifecycleConfigured.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list Backup plans') + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/bedrock/bedrockInUse.js b/plugins/aws/bedrock/bedrockInUse.js new file mode 100644 index 000000000..328a12126 --- /dev/null +++ b/plugins/aws/bedrock/bedrockInUse.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Bedrock In Use', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that AWS Bedrock service is in use within your AWS account.', + more_info: 'AWS Bedrock provides access to high-performing foundation models from leading AI startups and Amazon through a unified API, enabling easy experimentation, customization, and deployment of generative AI applications with robust security and privacy features.', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html', + recommended_action: 'Use Bedrock service to utilize top foundation models with strong security and customization.', + apis: ['Bedrock:listCustomModels'], + realtime_triggers: ['bedrock:DeleteCustomModel'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.bedrock, function(region, rcb){ + var listCustomModels = helpers.addSource(cache, source, + ['bedrock', 'listCustomModels', region]); + + if (!listCustomModels) return rcb(); + + if (listCustomModels.err && listCustomModels.err.message.includes('Unknown operation')) { + helpers.addResult(results, 0, + 'Custom model service is not available in this region', region); + return rcb(); + } + + if (listCustomModels.err || !listCustomModels.data) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(listCustomModels)}`, region); + return rcb(); + } + + if (!listCustomModels.data.length) { + helpers.addResult(results, 2, 'Bedrock service is not in use', region); + return rcb(); + } else { + helpers.addResult(results, 0, 'Bedrock service is in use', region); + return rcb(); + + } + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/bedrock/bedrockInUse.spec.js b/plugins/aws/bedrock/bedrockInUse.spec.js new file mode 100644 index 000000000..d47f6e41a --- /dev/null +++ b/plugins/aws/bedrock/bedrockInUse.spec.js @@ -0,0 +1,73 @@ +var expect = require('chai').expect; +const bedrockInUse = require('./bedrockInUse'); + +const listCustomModels = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "creationTime": "2023-11-28T11:29:18.655000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + } +]; + + +const createCache = (listModels) => { + return { + bedrock: { + listCustomModels: { + 'us-east-1': { + err: null, + data: listModels + } + }, + } + }; +}; + + +describe('bedrockInUse', function () { + describe('run', function () { + it('should PASS if Bedrock service is in use', function (done) { + const cache = createCache([listCustomModels[0]]); + bedrockInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Bedrock service is in use') + done(); + }); + }); + + it('should FAIL if Bedrock service is not in use', function (done) { + const cache = createCache([]); + bedrockInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Bedrock service is not in use') + done(); + }); + }); + + + it('should UNKNOWN if unable to query Bedrock custom model', function (done) { + const cache = createCache(null, null); + bedrockInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Bedrock custom model list') + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/bedrock/customModelEncryptionEnabled.js b/plugins/aws/bedrock/customModelEncryptionEnabled.js new file mode 100644 index 000000000..d00e8eb91 --- /dev/null +++ b/plugins/aws/bedrock/customModelEncryptionEnabled.js @@ -0,0 +1,114 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Custom Model Encryption Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensure that an Amazon Bedrock custom models are encrypted with desired encryption level.', + more_info: 'When you encrypt AWS Bedrock custom model using your own AWS Customer Managed Keys (CMKs) for enhanced protection, you have full control over who can use the encryption keys to access your custom model.', + recommended_action: 'Encrypt Bedrock custom model with desired encryption level.', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html', + apis: ['Bedrock:listCustomModels', 'Bedrock:getCustomModel', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + bedrock_model_desired_encryption_level: { + name: 'Bedrock Custom Model Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var config = { + desiredEncryptionLevelString: settings.bedrock_model_desired_encryption_level || this.settings.bedrock_model_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + + async.each(regions.bedrock, function(region, rcb){ + var listCustomModels = helpers.addSource(cache, source, + ['bedrock', 'listCustomModels', region]); + + if (!listCustomModels) return rcb(); + + if (listCustomModels.err && listCustomModels.err.message.includes('Unknown operation')) { + helpers.addResult(results, 0, + 'Custom model service is not available in this region', region); + return rcb(); + } + + if (listCustomModels.err || !listCustomModels.data) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(listCustomModels)}`, region); + return rcb(); + } + + if (!listCustomModels.data.length) { + helpers.addResult(results, 0, 'No Bedrock custom model found', region); + return rcb(); + } + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let model of listCustomModels.data) { + if (!model.modelArn) continue; + + let resource = model.modelArn; + + let getCustomModel = helpers.addSource(cache, source, + ['bedrock', 'getCustomModel', region, model.modelName]); + + + if (!getCustomModel || getCustomModel.err || !getCustomModel.data) { + helpers.addResult(results, 3, `Unable to describe Bedrock custom model : ${helpers.addError(getCustomModel)}`, region, resource); + continue; + } + + let currentEncryptionLevel = 2; + + if (getCustomModel.data.modelKmsKeyArn) { + var kmsKeyId = getCustomModel.data.modelKmsKeyArn.split('/')[1] ? getCustomModel.data.modelKmsKeyArn.split('/')[1] : getCustomModel.data.modelKmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, getCustomModel.data.modelKmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Bedrock Custom model is encrypted with ${currentEncryptionLevelString} + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Bedrock Custom model is encrypted with ${currentEncryptionLevelString} + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/bedrock/customModelEncryptionEnabled.spec.js b/plugins/aws/bedrock/customModelEncryptionEnabled.spec.js new file mode 100644 index 000000000..94ab75fb4 --- /dev/null +++ b/plugins/aws/bedrock/customModelEncryptionEnabled.spec.js @@ -0,0 +1,212 @@ +var expect = require('chai').expect; +var customModelEncryptionEnabled = require('./customModelEncryptionEnabled'); + +const listCustomModels = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "test-model", + "creationTime": "2023-11-28T11:29:18.655000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + } +]; + +const getCustomModel = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "modelKmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-29T10:45:43.056000+00:00" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "test-model", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/iuvltioettou", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-28T11:29:18.655000+00:00" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0252", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0252", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + }, + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/f4942dd6-bce5-4213-bdd3-cc8ccd87dd890" + } +] +const createCache = (customModel, getCustomModel, keys, describeKey, customModelErr, getCustomModelErr, keysErr, describeKeyErr) => { + var modelName = (customModel && customModel.length) ? customModel[0].modelName: null; + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + return { + bedrock: { + listCustomModels: { + 'us-east-1': { + err: customModelErr, + data: customModel + }, + }, + getCustomModel: { + 'us-east-1': { + [modelName]: { + data: getCustomModel, + err: getCustomModelErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('customModelEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if Bedrock Custom Model is Encrypted using CMK', function (done) { + const cache = createCache([listCustomModels[0]], getCustomModel[0], listKeys, describeKey[0]); + customModelEncryptionEnabled.run(cache, {bedrock_model_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Bedrock Custom Model is encrypted with AWS owned key', function (done) { + const cache = createCache([listCustomModels[1]], getCustomModel[1], listKeys, describeKey[1]); + customModelEncryptionEnabled.run(cache, {bedrock_model_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if the desired encryption level for bedrock custom model is awskms', function (done) { + const cache = createCache([listCustomModels[1]], getCustomModel[1], listKeys, describeKey[1]); + customModelEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Bedrock custom model found', function (done) { + const cache = createCache([]); + customModelEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Bedrock custom model', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Bedrock Custom Model" }); + customModelEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) + diff --git a/plugins/aws/bedrock/customModelHasTags.js b/plugins/aws/bedrock/customModelHasTags.js new file mode 100644 index 000000000..fc2003479 --- /dev/null +++ b/plugins/aws/bedrock/customModelHasTags.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Custom Model Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Bedrock Custom model has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/tagging.html', + recommended_action: 'Modify Bedrock Custom model and add tags.', + apis: ['Bedrock:listCustomModels','ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['bedrock:TagResource','bedrock:UntagResource', 'bedrock:DeleteCustomModel'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.bedrock, function(region, rcb){ + var listCustomModels = helpers.addSource(cache, source, + ['bedrock', 'listCustomModels', region]); + + if (!listCustomModels) return rcb(); + + if (listCustomModels.err && listCustomModels.err.message.includes('Unknown operation')) { + helpers.addResult(results, 0, + 'Custom model service is not available in this region', region); + return rcb(); + } + + if (listCustomModels.err || !listCustomModels.data) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(listCustomModels)}`, region); + return rcb(); + } + + if (!listCustomModels.data.length) { + helpers.addResult(results, 0, 'No Bedrock custom model found', region); + return rcb(); + } + + const arnList = []; + for (let model of listCustomModels.data){ + if (!model.modelArn) continue; + + arnList.push(model.modelArn); + } + + helpers.checkTags(cache, 'Bedrock custom model', arnList, region, results, settings); + return rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/bedrock/customModelHasTags.spec.js b/plugins/aws/bedrock/customModelHasTags.spec.js new file mode 100644 index 000000000..17668f3e6 --- /dev/null +++ b/plugins/aws/bedrock/customModelHasTags.spec.js @@ -0,0 +1,112 @@ +var expect = require('chai').expect; +const customModelHasTags = require('./customModelHasTags'); + +const listCustomModels = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "creationTime": "2023-11-28T11:29:18.655000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + } +]; + +const getResources = [ + { + "ResourceARN": "arn:aws:codestar:us-east-1:111222333444:project/khulnasoft-project", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "Tags": [{key: 'value'}], + } +] + + +const createCache = (listModels, rgData) => { + return { + bedrock: { + listCustomModels: { + 'us-east-1': { + err: null, + data: listModels + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + + +describe('customModelHasTags', function () { + describe('run', function () { + it('should PASS if Bedrock custom model has tags', function (done) { + const cache = createCache([listCustomModels[0]], [getResources[1]]); + customModelHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Bedrock custom model has tags') + done(); + }); + }); + + it('should FAIL if Bedrock custom model doesnot have tags', function (done) { + const cache = createCache([listCustomModels[0]], [getResources[0]]); + customModelHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Bedrock custom model does not have any tags') + done(); + }); + }); + + it('should PASS if no Bedrock custom model found', function (done) { + const cache = createCache([]); + customModelHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Bedrock custom model found') + done(); + }); + }); + + it('should UNKNOWN if unable to query Bedrock custom model', function (done) { + const cache = createCache(null, null); + customModelHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Bedrock custom model list') + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listCustomModels[0]],null); + customModelHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/bedrock/customModelInVpc.js b/plugins/aws/bedrock/customModelInVpc.js new file mode 100644 index 000000000..8b6bfeece --- /dev/null +++ b/plugins/aws/bedrock/customModelInVpc.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Custom Model In VPC', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensure that an Amazon Bedrock custom model is configured with a VPC.', + more_info: 'When the custom model is configured within a VPC, it establishes a secure environment that prevents unauthorized internet access to your training data, enhancing the overall security and confidentiality of your model.', + recommended_action: 'Create the custom model with VPC configuration', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/usingVPC.html', + apis: ['Bedrock:listCustomModels', 'Bedrock:getCustomModel','Bedrock:listModelCustomizationJobs', 'Bedrock:getModelCustomizationJob'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.bedrock, function(region, rcb){ + var listCustomModels = helpers.addSource(cache, source, + ['bedrock', 'listCustomModels', region]); + + if (!listCustomModels) return rcb(); + + if (listCustomModels.err && listCustomModels.err.message.includes('Unknown operation')) { + helpers.addResult(results, 0, + 'Custom model service is not available in this region', region); + return rcb(); + } + + if (listCustomModels.err || !listCustomModels.data) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(listCustomModels)}`, region); + return rcb(); + } + + if (!listCustomModels.data.length) { + helpers.addResult(results, 0, 'No Bedrock custom model found', region); + return rcb(); + } + + for (let model of listCustomModels.data){ + if (!model.modelArn|| !model.modelName) continue; + + let resource = model.modelArn; + + let getCustomModel = helpers.addSource(cache, source, + ['bedrock', 'getCustomModel', region, model.modelName]); + + + if (!getCustomModel || getCustomModel.err || !getCustomModel.data) { + helpers.addResult(results, 3, `Unable to describe Bedrock custom model : ${helpers.addError(getCustomModel)}`, region, resource); + continue; + } + + let getModelJob = helpers.addSource(cache, source, + ['bedrock', 'getModelCustomizationJob', region, getCustomModel.data.jobArn]); + + if (!getModelJob || getModelJob.err || !getModelJob.data) { + helpers.addResult(results, 3, `Unable to describe Bedrock model customzation job : ${helpers.addError(getModelJob)}`, region, resource); + continue; + } + + if (getModelJob.data.vpcConfig ) { + helpers.addResult(results, 0, + 'Bedrock custom model is configured within a VPC', region, resource); + } else { + helpers.addResult(results, 2, + 'Bedrock custom model is not configured within a VPC', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/bedrock/customModelInVpc.spec.js b/plugins/aws/bedrock/customModelInVpc.spec.js new file mode 100644 index 000000000..27d120973 --- /dev/null +++ b/plugins/aws/bedrock/customModelInVpc.spec.js @@ -0,0 +1,251 @@ +var expect = require('chai').expect; +var customModelInVpc = require('./customModelInVpc') + +const listCustomModels = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "creationTime": "2023-11-28T11:29:18.655000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + } +]; + +const getCustomModel = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "modelKmsKeyArn": "arn:aws:kms:us-east-1:11223344:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-29T10:45:43.056000+00:00" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/iuvltioettou", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-28T11:29:18.655000+00:00" + } +]; + +const listModelCustomizationJobs = [ + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "jobName": "second", + "status": "Completed", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "customModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "customModelName": "model2" + }, + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/nn23m2vejr54", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "jobName": "testjobformodel2", + "status": "Failed", + "lastModifiedTime": "2023-11-29T09:08:17.414000+00:00", + "creationTime": "2023-11-29T08:47:00.690000+00:00", + "endTime": "2023-11-29T09:08:17.335000+00:00", + "customModelName": "testmodel2" + }, +] + +const getModelCustomizationJob = [ + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/nn23m2vejr54", + "jobName": "second", + "outputModelName": "model2", + "outputModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "clientRequestToken": "0da79509-df0a-4bec-9dab-c13a33584247", + "roleArn": "arn:aws:iam::11223344:role/service-role/test-role-bedrock", + "status": "Completed", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "validationDataConfig": { + "validators": [] + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "outputModelKmsKeyArn": "arn:aws:kms:us-east-1:672202477801:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "validationMetrics": [], + "vpcConfig": { + "subnetIds": [ + "subnet-090543c3cc7bee455", + "subnet-0c24e4b662cd8d653", + "subnet-0d9749278f1e1363d", + "subnet-047096f0145576587", + "subnet-042ccd1bd4f8fcc89", + "subnet-02b16d7c95cf5de7f", + "subnet-0a47a7d5f3f852877" + ], + "securityGroupIds": [ + "sg-0931c3a02deed68f5" + ] + } + }, + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "jobName": "first", + "outputModelName": "testmodel2", + "outputModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "clientRequestToken": "0da79509-df0a-4bec-9dab-c13a33584247", + "roleArn": "arn:aws:iam::11223344:role/service-role/test-role-bedrock", + "status": "Completed", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "validationDataConfig": { + "validators": [] + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "outputModelKmsKeyArn": "arn:aws:kms:us-east-1:672202477801:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "validationMetrics": [], + } +] + +const createCache = (customModel, listJobs, getCustomModel, getJobs, customModelErr, listJobsErr, getCustomModelErr, getJobErr) => { + var modelName = (customModel && customModel.length) ? customModel[0].modelName: null; + var jobName = (listJobs && listJobs.length) ? listJobs[0].jobArn: null; + return { + bedrock: { + listCustomModels: { + 'us-east-1': { + err: customModelErr, + data: customModel + }, + }, + listModelCustomizationJobs: { + 'us-east-1': { + err: listJobsErr, + data: listJobs + }, + }, + getCustomModel: { + 'us-east-1': { + [modelName]: { + data: getCustomModel, + err: getCustomModelErr + } + } + }, + getModelCustomizationJob: { + 'us-east-1': { + [jobName]: { + data: getJobs, + err: getJobErr + } + } + } + } + }; +}; + +describe('customModelInVpc', function () { + describe('run', function () { + it('should PASS if Bedrock Custom Model has Vpc configured', function (done) { + const cache = createCache([listCustomModels[1]], [listModelCustomizationJobs[0]],getCustomModel[0],getModelCustomizationJob[0]); + customModelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Bedrock Custom Model have not Vpc configured', function (done) { + const cache = createCache([listCustomModels[0]],[listModelCustomizationJobs[0]] ,getCustomModel[0],getModelCustomizationJob[1]); + customModelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Bedrock custom model found', function (done) { + const cache = createCache([]); + customModelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Bedrock custom model', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Bedrock Custom Model" }); + customModelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/bedrock/modelInvocationLoggingEnabled.js b/plugins/aws/bedrock/modelInvocationLoggingEnabled.js new file mode 100644 index 000000000..8b1ff8870 --- /dev/null +++ b/plugins/aws/bedrock/modelInvocationLoggingEnabled.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Bedrock Model Invocation Logging Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensure that Amazon Bedrock model invocation logging is enabled.', + more_info: 'With invocation logging enabled, you can collect the full request data, response data, and metadata associated with all calls performed in account. This detailed logging provides valuable insights into model usage patterns, helps in troubleshooting, and enhances security by allowing for thorough analysis of model interactions. It also facilitates compliance with auditing requirements, offering a comprehensive record of model invocations.', + recommended_action: 'Enable invocation logging for Amazon Bedrock models.', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/settings.html#model-invocation-logging', + apis: ['Bedrock:getModelInvocationLoggingConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.bedrock, function(region, rcb){ + var invocationLoggingConfiguration = helpers.addSource(cache, source, + ['bedrock', 'getModelInvocationLoggingConfiguration', region]); + + if (!invocationLoggingConfiguration) return rcb(); + + if (invocationLoggingConfiguration.err) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(invocationLoggingConfiguration)}`, region); + return rcb(); + } + + + if (!invocationLoggingConfiguration.data) { + helpers.addResult(results, 2, 'Invocation logging is not enabled for bedrock models', region); + } else { + helpers.addResult(results, 0, 'Invocation logging is enabled for bedrock models', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/bedrock/modelInvocationLoggingEnabled.spec.js b/plugins/aws/bedrock/modelInvocationLoggingEnabled.spec.js new file mode 100644 index 000000000..646849853 --- /dev/null +++ b/plugins/aws/bedrock/modelInvocationLoggingEnabled.spec.js @@ -0,0 +1,61 @@ +var expect = require('chai').expect; +var modelInvocationLoggingEnabled = require('./modelInvocationLoggingEnabled'); + +const invocationLoggingConfiguration = { + "loggingConfig": { + "s3Config": { + "bucketName": "bedrockbuckettest", + "keyPrefix": "" + }, + "textDataDeliveryEnabled": true, + "imageDataDeliveryEnabled": true, + "embeddingDataDeliveryEnabled": true + } +} + +const createCache = (invocationLoggingConfiguration, invocationLoggingConfigurationErr) => { + return { + bedrock: { + getModelInvocationLoggingConfiguration: { + 'us-east-1': { + err: invocationLoggingConfigurationErr, + data: invocationLoggingConfiguration + }, + }, + } + }; +}; + +describe('modelInvocationLoggingEnabled', function () { + describe('run', function () { + it('should PASS if model invocation logging is enabled for bedrock models', function (done) { + const cache = createCache(invocationLoggingConfiguration); + modelInvocationLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if model invocation logging is disabled for bedrock models', function (done) { + const cache = createCache(); + modelInvocationLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for model invocation logging', function (done) { + const cache = createCache(null, { message: "Unable to list model invocation logging config"}); + modelInvocationLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/bedrock/privateCustomModel.js b/plugins/aws/bedrock/privateCustomModel.js new file mode 100644 index 000000000..f9bd2468c --- /dev/null +++ b/plugins/aws/bedrock/privateCustomModel.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Private Custom Model', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensure that an Amazon Bedrock custom model is configured within a private VPC.', + more_info: 'When the custom model is configured within a private VPC or with a private VPC endpoint, it enhances security by restricting access to authorized networks only, preventing exposure to the public internet.', + recommended_action: 'Configure the custom model with VPC and private VPC endpoint.', + link: 'https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html', + apis: ['Bedrock:listCustomModels', 'Bedrock:getCustomModel','Bedrock:listModelCustomizationJobs', 'Bedrock:getModelCustomizationJob','EC2:describeSubnets', 'EC2:describeRouteTables'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.bedrock, function(region, rcb){ + var listCustomModels = helpers.addSource(cache, source, + ['bedrock', 'listCustomModels', region]); + + if (!listCustomModels) return rcb(); + + if (listCustomModels.err && listCustomModels.err.message.includes('Unknown operation')) { + helpers.addResult(results, 0, + 'Custom model service is not available in this region', region); + return rcb(); + } + + if (listCustomModels.err || !listCustomModels.data) { + helpers.addResult(results, 3, + `Unable to query for Bedrock custom model list: ${helpers.addError(listCustomModels)}`, region); + return rcb(); + } + + if (!listCustomModels.data.length) { + helpers.addResult(results, 0, 'No Bedrock custom model found', region); + return rcb(); + } + var subnetRouteTableMap; + var privateSubnets = []; + + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data ) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + return rcb(); + } + + if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + return rcb(); + } else { + subnetRouteTableMap = helpers.getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = helpers.getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + } + + for (let model of listCustomModels.data){ + if (!model.modelArn|| !model.modelName) continue; + + let resource = model.modelArn; + + let getCustomModel = helpers.addSource(cache, source, + ['bedrock', 'getCustomModel', region, model.modelName]); + + + if (!getCustomModel || getCustomModel.err || !getCustomModel.data) { + helpers.addResult(results, 3, `Unable to describe Bedrock custom model : ${helpers.addError(getCustomModel)}`, region, resource); + continue; + } + + let getModelJob = helpers.addSource(cache, source, + ['bedrock', 'getModelCustomizationJob', region, getCustomModel.data.jobArn]); + + if (!getModelJob || getModelJob.err || !getModelJob.data) { + helpers.addResult(results, 3, `Unable to describe Bedrock model customzation job : ${helpers.addError(getModelJob)}`, region, resource); + continue; + } + + if (getModelJob.data.vpcConfig && getModelJob.data.vpcConfig.subnetIds) { + var allPrivate = getModelJob.data.vpcConfig.subnetIds.every(subnetId => privateSubnets.includes(subnetId)); + + if (allPrivate) { + helpers.addResult(results, 0, + 'Bedrock custom model is configured within a private VPC', + region, resource); + } else { + helpers.addResult(results, 2, + 'Bedrock custom model is not configured within a private VPC', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Bedrock custom model does not have VPC configured', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/bedrock/privateCustomModel.spec.js b/plugins/aws/bedrock/privateCustomModel.spec.js new file mode 100644 index 000000000..509e223b1 --- /dev/null +++ b/plugins/aws/bedrock/privateCustomModel.spec.js @@ -0,0 +1,370 @@ +var expect = require('chai').expect; +var privateCustomModel = require('./privateCustomModel'); + +const listCustomModels = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "creationTime": "2023-11-28T11:29:18.655000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "baseModelName": "" + } +]; + +const getCustomModel = [ + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "modelName": "model2", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "modelKmsKeyArn": "arn:aws:kms:us-east-1:11223344:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-29T10:45:43.056000+00:00" + }, + { + "modelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/vjqsydtdhkpz", + "modelName": "testmodel2", + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/iuvltioettou", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "trainingMetrics": { + "trainingLoss": 1.7109375 + }, + "validationMetrics": [], + "creationTime": "2023-11-28T11:29:18.655000+00:00" + } +]; + +const listModelCustomizationJobs = [ + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "jobName": "second", + "status": "Completed", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "customModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "customModelName": "model2" + }, + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/nn23m2vejr54", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "jobName": "testjobformodel2", + "status": "Failed", + "lastModifiedTime": "2023-11-29T09:08:17.414000+00:00", + "creationTime": "2023-11-29T08:47:00.690000+00:00", + "endTime": "2023-11-29T09:08:17.335000+00:00", + "customModelName": "testmodel2" + }, +] + +const getModelCustomizationJob = [ + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/nn23m2vejr54", + "jobName": "second", + "outputModelName": "model2", + "outputModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "clientRequestToken": "0da79509-df0a-4bec-9dab-c13a33584247", + "roleArn": "arn:aws:iam::11223344:role/service-role/test-role-bedrock", + "status": "Completed", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "validationDataConfig": { + "validators": [] + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "outputModelKmsKeyArn": "arn:aws:kms:us-east-1:672202477801:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "validationMetrics": [], + "vpcConfig": { + "subnetIds": [ + "subnet-123", + ], + "securityGroupIds": [ + "sg-0931c3a02deed68f5" + ] + } + }, + { + "jobArn": "arn:aws:bedrock:us-east-1:11223344:model-customization-job/amazon.titan-text-lite-v1:0:4k/lo7152tvvl3f", + "jobName": "first", + "outputModelName": "testmodel2", + "outputModelArn": "arn:aws:bedrock:us-east-1:11223344:custom-model/amazon.titan-text-lite-v1:0:4k/2ytyyx8nid0h", + "clientRequestToken": "0da79509-df0a-4bec-9dab-c13a33584247", + "roleArn": "arn:aws:iam::11223344:role/service-role/test-role-bedrock", + "status": "Completed", + "creationTime": "2023-11-29T10:45:43.056000+00:00", + "lastModifiedTime": "2023-11-29T11:36:48.302000+00:00", + "endTime": "2023-11-29T11:36:47.666000+00:00", + "baseModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-lite-v1:0:4k", + "hyperParameters": { + "batchSize": "2", + "epochCount": "2", + "learningRate": "0.00001", + "learningRateWarmupSteps": "0" + }, + "trainingDataConfig": { + "s3Uri": "s3://bedrockbuckettest/trainigdata.jsonl" + }, + "validationDataConfig": { + "validators": [] + }, + "outputDataConfig": { + "s3Uri": "s3://bedrockbuckettest" + }, + "outputModelKmsKeyArn": "arn:aws:kms:us-east-1:672202477801:key/29c2507e-ba0d-4b70-b20d-8b30b761685b", + "validationMetrics": [], + "vpcConfig": { + "subnetIds": [ + "subnet-234", + ], + "securityGroupIds": [ + "sg-0931c3a02deed68f5" + ] + } + } +]; + +const describeRouteTables = [ + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [], + "VpcId": "vpc-123", + "OwnerId": "000011112222" + }, + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + }, + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "igw-sedwednkq", + "Origin": "CreateRouteTable", + "State": "active" + } + + ], + "Tags": [], + "VpcId": "vpc-234", + "OwnerId": "000011112222" + } +]; + +const describeSubnets = [ + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-123", + "VpcId": "vpc-123", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-234", + "VpcId": "vpc-234", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + } +]; + +const createCache = (customModel, listJobs, getCustomModel, getJobs, subnets, routeTables, customModelErr, listJobsErr, getCustomModelErr, getJobErr) => { + var modelName = (customModel && customModel.length) ? customModel[0].modelName: null; + var jobName = (listJobs && listJobs.length) ? listJobs[0].jobArn: null; + return { + bedrock: { + listCustomModels: { + 'us-east-1': { + err: customModelErr, + data: customModel + }, + }, + listModelCustomizationJobs: { + 'us-east-1': { + err: listJobsErr, + data: listJobs + }, + }, + getCustomModel: { + 'us-east-1': { + [modelName]: { + data: getCustomModel, + err: getCustomModelErr + } + } + }, + getModelCustomizationJob: { + 'us-east-1': { + [jobName]: { + data: getJobs, + err: getJobErr + } + } + } + }, + ec2: { + describeSubnets: { + 'us-east-1': { + data: subnets + } + }, + describeRouteTables: { + 'us-east-1': { + data: routeTables + } + } + } + }; +}; + +describe('privateCustomModel', function () { + describe('run', function () { + it('should PASS if Bedrock Custom Model is a private model', function (done) { + const cache = createCache([listCustomModels[1]], [listModelCustomizationJobs[0]],getCustomModel[0],getModelCustomizationJob[0],[describeSubnets[0]], [describeRouteTables[0]]); + privateCustomModel.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Bedrock Custom Model have not Vpc configured', function (done) { + const cache = createCache([listCustomModels[0]],[listModelCustomizationJobs[0]] ,getCustomModel[0],getModelCustomizationJob[1],[describeSubnets[0]], [describeRouteTables[0]]); + privateCustomModel.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Bedrock Custom Model is not a private model', function (done) { + const cache = createCache([listCustomModels[0]],[listModelCustomizationJobs[0]] ,getCustomModel[0],getModelCustomizationJob[0],[describeSubnets[1]], [describeRouteTables[1]]); + privateCustomModel.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Bedrock custom model found', function (done) { + const cache = createCache([]); + privateCustomModel.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Bedrock custom model', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Bedrock Custom Model" }); + privateCustomModel.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/cloudformation/cloudformationAdminPriviliges.js b/plugins/aws/cloudformation/cloudformationAdminPriviliges.js new file mode 100644 index 000000000..b778b56e7 --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationAdminPriviliges.js @@ -0,0 +1,173 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +//var managedAdminPolicy = 'arn:aws:iam::aws:policy/AdministratorAccess'; + +module.exports = { + title: 'CloudFormation Admin Priviliges', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures no AWS CloudFormation stacks available in your AWS account has admin privileges.', + more_info: 'A service role is an AWS Identity and Access Management (IAM) role that allows AWS CloudFormation to make calls to resources in a stack on your behalf. You can specify an IAM role that allows AWS CloudFormation to create, update, or delete your stack resources', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-iam-servicerole.html', + recommended_action: 'Modify IAM role attached with AWS CloudFormation stack to provide the minimal amount of access required to perform its tasks', + apis: ['CloudFormation:listStacks', 'CloudFormation:describeStacks', 'IAM:listRoles', 'IAM:listAttachedRolePolicies', 'IAM:listRolePolicies', + 'IAM:listPolicies', 'IAM:getPolicy', 'IAM:getPolicyVersion', 'IAM:getRolePolicy'], + realtime_triggers: ['cloudformation:CreateStack','cloudformation:DeleteStack','cloudformation:UpdateStack','iam:DeleteRole','iam:AttachRolePolicy','iam:DetachRolePolicy','iam:DeleteRolePolicy','iam:PutRolePolicy'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, + `Unable to list CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + async.each(listStacks.data, function(stack, cb){ + if (!stack.StackId || !stack.StackName) return cb(); + var resource = stack.StackId; + + var describeStacks = helpers.addSource(cache, source, + ['cloudformation', 'describeStacks', region, stack.StackName]); + + if (!describeStacks || describeStacks.err || !describeStacks.data || !describeStacks.data.Stacks) { + helpers.addResult(results, 3, + `Unable to query for CloudFormation stack: ${helpers.addError(describeStacks)}`, region, resource); + return cb(); + } + + if (!describeStacks.data.Stacks[0].RoleARN) { + helpers.addResult(results, 0, + 'CloudFormation stack does not have a role attached', region, resource); + return cb(); + } + + var roleName = describeStacks.data.Stacks[0].RoleARN.split('/')[1] ? + describeStacks.data.Stacks[0].RoleARN.split('/')[1] : describeStacks.data.Stacks[0].RoleARN; + var adminPrivileged; + + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', defaultRegion, roleName]); + + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', defaultRegion, roleName]); + + var getRolePolicy = helpers.addSource(cache, source, + ['iam', 'getRolePolicy', defaultRegion, roleName]); + + if (!listAttachedRolePolicies || + listAttachedRolePolicies.err || + !listAttachedRolePolicies.data || + !listAttachedRolePolicies.data.AttachedPolicies) { + helpers.addResult(results, 3, + `Unable to query for IAM attached policy for role "${roleName}": ${helpers.addError(listAttachedRolePolicies)}`, + region, resource); + return cb(); + } + + if (!listRolePolicies || listRolePolicies.err || !listRolePolicies.data || !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}": ${helpers.addError(listRolePolicies)}`, + region, resource); + return cb(); + } + + for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { + if (!policy.PolicyArn) continue; + + if (policy.PolicyArn === managedAdminPolicy) { + helpers.addResult(results, 2, + 'CloudFormation stack has admin privileges', region, resource); + return cb(); + } + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', defaultRegion, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', defaultRegion, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (adminPrivileged) break; + } + + for (var policyName of listRolePolicies.data.PolicyNames) { + if (getRolePolicy && + getRolePolicy[policyName] && + getRolePolicy[policyName].data && + getRolePolicy[policyName].data.PolicyDocument) { + let statements = getRolePolicy[policyName].data.PolicyDocument; + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (!adminPrivileged) { + helpers.addResult(results, 0, + 'CloudFormation stack does not have admin privileges', region, resource); + } else { + helpers.addResult(results, 2, + 'CloudFormation stack has admin privileges', region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudformation/cloudformationAdminPriviliges.spec.js b/plugins/aws/cloudformation/cloudformationAdminPriviliges.spec.js new file mode 100644 index 000000000..d7d6ff645 --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationAdminPriviliges.spec.js @@ -0,0 +1,369 @@ +const expect = require('chai').expect; +var cloudformationAdminPriviliges = require('./cloudformationAdminPriviliges'); + +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/aws-sam-cli-managed-default/b6136b70-6efd-11ec-b78c-0e81d5d55d65", + "StackName": "aws-sam-cli-managed-default", + "TemplateDescription": "Managed Stack for AWS SAM CLI", + "CreationTime": "2022-01-06T14:34:10.262000+00:00", + "LastUpdatedTime": "2022-01-06T14:34:27.009000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }, + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/sam-app/e26f50d0-6efd-11ec-bb7d-0e5217d78663", + "StackName": "sam-app", + "TemplateDescription": "S3 Uploader - sample application", + "CreationTime": "2022-01-06T14:35:24.796000+00:00", + "LastUpdatedTime": "2022-01-06T14:35:52.880000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + } +]; + +const describeStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/aws-sam-cli-managed-default/b6136b70-6efd-11ec-b78c-0e81d5d55d65", + "StackName": "aws-sam-cli-managed-default", + "Description": "Managed Stack for AWS SAM CLI", + "CreationTime": "2022-01-06T14:34:10.262000+00:00", + "LastUpdatedTime": "2022-02-16T17:09:21.404000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "UPDATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "SourceBucket", + "OutputValue": "aws-sam-cli-managed-default-samclisourcebucket-10o2i0pvnvjml" + } + ], + "RoleARN": "arn:aws:iam::000011112222:role/only-cf-role", + "Tags": [ + { + "Key": "ManagedStackSource", + "Value": "AwsSamCli" + } + ], + "EnableTerminationProtection": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }, + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/sam-app/e26f50d0-6efd-11ec-bb7d-0e5217d78663", + "StackName": "sam-app", + "Description": "S3 Uploader - sample application", + "CreationTime": "2022-01-06T14:35:24.796000+00:00", + "LastUpdatedTime": "2022-02-16T16:36:23.291000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "UPDATE_COMPLETE", + "DisableRollback": true, + "NotificationARNs": [], + "Capabilities": [ + "CAPABILITY_IAM" + ], + "Outputs": [ + { + "OutputKey": "APIendpoint", + "OutputValue": "https://m9kyzunubc.execute-api.us-east-1.amazonaws.com", + "Description": "HTTP API endpoint URL" + }, + { + "OutputKey": "S3UploadBucketName", + "OutputValue": "sam-app-s3uploadbucket-ylnxiraqgwuq", + "Description": "S3 bucket for application uploads" + } + ], + "RoleARN": "arn:aws:iam::000011112222:role/mine1-cf", + "Tags": [], + "EnableTerminationProtection": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + } +]; + +const listRoles = [ + { + "Path": "/", + "RoleName": "mine1-cf", + "RoleId": "AROAYE32SRU55L7TD7HQ7", + "Arn": "arn:aws:iam::000011112222:role/mine1-cf", + "CreateDate": "2020-12-22T08:47:57Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "cloudformation.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "only-cf-role", + "RoleId": "AROAYE32SRU5UELB2F76P", + "Arn": "arn:aws:iam::000011112222:role/only-cf-role", + "CreateDate": "2020-12-25T09:09:48Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "cloudformation.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "EFS-Full" + ] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f6983e3d-4644-46d5-b55d-ca9290347b9f' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": '2fa46d77-1637-4c70-a8eb-7dc59993f359' + }, + "AttachedPolicies": [ + { + "PolicyName": 'only-cf-policy', + "PolicyArn": 'arn:aws:iam::000011112222:policy/only-cf-policy' + } + ], + "IsTruncated": false + } +]; + +const getRolePolicy = [ + { + "RoleName": 'only-cf-role', + "PolicyName": 'EFS-Full', + "PolicyDocument": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22elasticfilesystem%3A%2A%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D' + } +]; + +const getPolicy = [ + { + "Policy": { + "PolicyName": 'Allow_Manager_Role_Policy', + "PolicyId": 'ANPAYE32SRU57UHNCIGCT', + "Arn": 'arn:aws:iam::000011112222:policy/Allow_Manager_Role_Policy', + "Path": '/', + "DefaultVersionId": 'v5', + "AttachmentCount": 2, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true + } + } +]; + +const getPolicyVersion = [ + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor1%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPolicies%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPoliciesGrantingServiceAccess%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListRoles%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListUsers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListGroups%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor2%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Deny%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22iam%3ACreateGroup%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22arn%3Aaws%3Aiam%3A%3A000011112222%3Agroup%2F%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v5', + } + } +]; + +const createCache = (listStacks, describeStacks, listRoles, listAttachedRolePolicies, listRolePolicies, getRolePolicy, getPolicy, getPolicyVersion) => { + var stackName = (listStacks && listStacks.length) ? listStacks[0].StackName : null; + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + var policyArn = (listAttachedRolePolicies && listAttachedRolePolicies.AttachedPolicies) ? listAttachedRolePolicies.AttachedPolicies[0].PolicyArn : null; + var policyName = (listRolePolicies) ? listRolePolicies.PolicyNames[0] : null; + + return { + cloudformation: { + listStacks: { + 'us-east-1': { + data: listStacks + } + }, + describeStacks: { + 'us-east-1': { + [stackName]: { + data: { + "Stacks": [ + describeStacks + ] + } + } + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + }, + getPolicy: { + 'us-east-1': { + [policyArn]: { + data: getPolicy + } + } + }, + getRolePolicy: { + 'us-east-1': { + [policyName]: { + data: getRolePolicy + } + } + }, + getPolicyVersion: { + 'us-east-1': { + [policyArn]: { + data: getPolicyVersion + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error listing CloudFormation stacks' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': null + } + } + }; +}; + +describe('cloudformationAdminPriviliges', function () { + describe('run', function () { + it('should PASS if CloudFormation stack does not have admin privileges', function (done) { + const cache = createCache([listStacks[0]], describeStacks[0], [listRoles[1]], listAttachedRolePolicies[1], listRolePolicies[0], [getRolePolicy[0]]); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudFormation stack has admin privileges', function (done) { + const cache = createCache([listStacks[1]], describeStacks[1], [listRoles[0]], listAttachedRolePolicies[0], listRolePolicies[0], {}, getPolicy[0], [getPolicyVersion[0]]); + cloudformationAdminPriviliges.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no CloudFormation stacks found', function (done) { + const cache = createCache([]); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list CloudFormation stacks', function (done) { + const cache = createErrorCache(); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache([listStacks[1]], describeStacks[0], [listRoles[1]], null); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache([listStacks[1]], describeStacks[0], [listRoles[1]], listAttachedRolePolicies[0], null); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list CloudFormation stacks response not found', function (done) { + const cache = createNullCache(); + cloudformationAdminPriviliges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudformation/cloudformationDeletionPolicy.js b/plugins/aws/cloudformation/cloudformationDeletionPolicy.js new file mode 100644 index 000000000..6cb512294 --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationDeletionPolicy.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFormation Deletion Policy In Use', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that deletion policy is used for Amazon CloudFormation stacks.', + more_info: 'DeletionPolicy attribute allows to preserve and backup a resource when its stack is deleted. By default, AWS CloudFormation deletes the resource and all its content if a resource has no DeletionPolicy attribute in a template.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-deletionpolicy.html', + recommended_action: 'Add DeletionPolicy attribute in the AWS Cloudformation stack template.', + apis: ['CloudFormation:listStacks', 'CloudFormation:getTemplate'], + realtime_triggers: ['cloudformation:CreateStack','cloudformation:UpdateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + for (var stack of listStacks.data) { + if (!stack.StackId || !stack.StackName) continue; + + var resource = stack.StackId; + var template = helpers.addSource(cache, source, + ['cloudformation', 'getTemplate', region, stack.StackName]); + + if (!template || template.err || !template.data) { + helpers.addResult(results, 3, `Unable to query CloudFormation stack template: ${helpers.addError(template)}`, region, resource); + continue; + } + + if (template.data.TemplateBody) { + // eslint-disable-next-line no-useless-escape + var deletionPolicy = template.data.TemplateBody.includes('DeletionPolicy\":\"Retain'); + + if (deletionPolicy) { + helpers.addResult(results, 0, + 'Deletion Policy is used for CloudFormation stack', + region, resource); + } else { + helpers.addResult(results, 2, 'Deletion Policy is not used for CloudFormation stack', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/cloudformation/cloudformationDeletionPolicy.spec.js b/plugins/aws/cloudformation/cloudformationDeletionPolicy.spec.js new file mode 100644 index 000000000..39120312e --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationDeletionPolicy.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var cloudFormationDeletionPolicy = require('./cloudformationDeletionPolicy'); + +const createCache = (stacks, templates) => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: null, + data: stacks + } + }, + getTemplate: templates.reduce((acc, template, index) => { + acc['us-east-1'] = acc['us-east-1'] || {}; + acc['us-east-1'][stacks[index].StackName] = { + err: null, + data: template + }; + return acc; + }, {}) + } + }; +}; + +describe('CloudFormation Deletion Policy in Use', function () { + describe('run', function () { + it('should return unknown result if unable to list the CloudFormation stacks', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for CloudFormation stacks'); + done(); + }; + + const cache = createCache(null, []); + + cloudFormationDeletionPolicy.run(cache, {}, callback); + }); + + it('should return passing result if unable to list CloudFormation stacks information', function (done) { + const stacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + } + ]; + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query CloudFormation stack template'); + done(); + }; + + const cache = createCache(stacks, []); + + cloudFormationDeletionPolicy.run(cache, {}, callback); + }); + + it('should return unknown result if no CloudFormation stacks found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No CloudFormation stacks found'); + done(); + }; + + const cache = createCache([], []); + + cloudFormationDeletionPolicy.run(cache, {}, callback); + }); + + it('should return passing result if deletion policy is used for CloudFormation stack', function (done) { + const stacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + } + ]; + + const templates = [ + { + "ResponseMetadata": { + "RequestId": "ba242bd7b841-a7fa-4229xa00-4t2-4294", + }, + "TemplateBody": "{\"AWSTemplateFormatVersion\":\"2010-09-09\",\"Description\":\"The AWS CloudFormation template for this Serverless application\",\"Resources\":{\"ServerlessDeploymentBucket\":{\"Type\":\"AWS::S3::Bucket\",\"Properties\":{\"BucketEncryption\":{\"ServerSideEncryptionConfiguration\":[{\"ServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"AES256\"}}]}},\"DeletionPolicy\":\"Retain\",\"Properties\":{\"FunctionName\":{\"Ref\":\"testfunction\"}}}}}", + "StagesAvailable": [ + "Original", + "Processed" + ] + } + ]; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Deletion Policy is used for CloudFormation stack'); + done(); + }; + + const cache = createCache(stacks, templates); + + cloudFormationDeletionPolicy.run(cache, {}, callback); + }); + + it('should return failing result if deletion policy is not used for CloudFormation stack', function (done) { + const stacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + } + ]; + + const templates = [ + { + "ResponseMetadata": { + "RequestId": "ba242bd7b841-a7fa-4229xa00-4t2-4294", + }, + "TemplateBody": "{\"AWSTemplateFormatVersion\":\"2010-09-09\",\"Description\":\"The AWS CloudFormation template for this Serverless application\",\"Resources\":{\"ServerlessDeploymentBucket\":{\"Type\":\"AWS::S3::Bucket\",\"Properties\":{\"BucketEncryption\":{\"ServerSideEncryptionConfiguration\":[{\"ServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"AES256\"}}]}},\"Properties\":{\"FunctionName\":{\"Ref\":\"testfunction\"}}}}}", + "StagesAvailable": [ + "Original", + "Processed" + ] + } + ]; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Deletion Policy is not used for CloudFormation stack'); + done(); + }; + + const cache = createCache(stacks, templates); + + cloudFormationDeletionPolicy.run(cache, {}, callback); + }); + + }); +}); + diff --git a/plugins/aws/cloudformation/cloudformationInUse.js b/plugins/aws/cloudformation/cloudformationInUse.js new file mode 100644 index 000000000..3cdc1e8ac --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationInUse.js @@ -0,0 +1,48 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS CloudFormation In Use', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that Amazon CloudFormation service is in use within your AWS account to automate your infrastructure management and deployment.', + more_info: 'AWS CloudFormation is a service that helps you model and set up your AWS resources so that you can spend less time managing those resources and more time focusing on your applications that run in AWS. ' + + 'A stack is a collection of AWS resources that you can manage as a single unit. In other words, you can create, update, or delete a collection of resources by creating, updating, or deleting stacks.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html', + recommended_action: 'Check if CloudFormation is in use or not by observing the stacks', + apis: ['CloudFormation:describeStacks'], + realtime_triggers: ['cloudformation:CreateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudformation, function(region, rcb){ + var describeStacks = helpers.addSource(cache, source, + ['cloudformation', 'describeStacks', region]); + if (!describeStacks) return rcb(); + + if (describeStacks.err || !describeStacks.data) { + helpers.addResult(results, 3, + `Unable to query CloudFormation stacks: ${helpers.addError(describeStacks)}`, region); + return rcb(); + } + + if (describeStacks.data.length) { + helpers.addResult(results, 0, + 'CloudFormation service is being used', + region); + } else { + helpers.addResult(results, 2, + 'CloudFormation service is not being used', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudformation/cloudformationInUse.spec.js b/plugins/aws/cloudformation/cloudformationInUse.spec.js new file mode 100644 index 000000000..e821f41f0 --- /dev/null +++ b/plugins/aws/cloudformation/cloudformationInUse.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var cloudformationInUse = require('./cloudformationInUse'); + +const describeStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/sam-app/e26f50d0-6efd-11ec-bb7d-0e5217d78663", + "StackName": "sam-app", + "ChangeSetId": "arn:aws:cloudformation:us-east-1:000011112222:changeSet/samcli-deploy1641479723/70df5351-0c36-4f77-bf41-fe5fe49c4a3a", + "Description": "S3 Uploader - sample application", + "CreationTime": "2022-01-06T14:35:24.796000+00:00", + "LastUpdatedTime": "2022-01-06T14:35:52.880000+00:00", + "RollbackConfiguration": {}, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": true, + "NotificationARNs": [], + "Capabilities": [ + "CAPABILITY_IAM" + ], + "Outputs": [ + { + "OutputKey": "APIendpoint", + "OutputValue": "https://m9kyzunubc.execute-api.us-east-1.amazonaws.com", + "Description": "HTTP API endpoint URL" + }, + { + "OutputKey": "S3UploadBucketName", + "OutputValue": "sam-app-s3uploadbucket-ylnxiraqgwuq", + "Description": "S3 bucket for application uploads" + } + ], + "Tags": [], + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }, + {} +]; + + + +const createCache = (describeStacks, describeStacksErr) => { + return { + cloudformation: { + describeStacks: { + 'us-east-1': { + err: describeStacksErr, + data: describeStacks + }, + }, + }, + }; +}; + +describe('cloudformationInUse', function () { + describe('run', function () { + it('should PASS if Amazon CloudFormation service is currently in use', function (done) { + const cache = createCache([describeStacks[0]]); + cloudformationInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CloudFormation service is being used'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Amazon CloudFormation service is not currently in use', function (done) { + const cache = createCache(describeStacks[1]); + cloudformationInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CloudFormation service is not being used'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query CloudFormation stacks', function (done) { + const cache = createCache(null, { message: "Unable to query CloudFormation stacks" }); + cloudformationInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query CloudFormation stacks'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudformation/driftDetection.js b/plugins/aws/cloudformation/driftDetection.js new file mode 100644 index 000000000..7f4259c66 --- /dev/null +++ b/plugins/aws/cloudformation/driftDetection.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFormation Drift Detection', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that AWS CloudFormation stacks are not in a drifted state.', + more_info: 'AWS CloudFormation stack should not be in drifted state to ensure that stack template is aligned with the resources.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resource-import-resolve-drift.html', + recommended_action: 'Resolve CloudFormation stack drift by importing drifted resource back to the stack.', + apis: ['CloudFormation:listStacks'], + realtime_triggers: ['cloudformation:CreateStack','cloudformation:UpdateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + for (var stack of listStacks.data) { + if (!stack.StackId) return; + var resource = stack.StackId; + + if (stack.DriftInformation && stack.DriftInformation.StackDriftStatus && + stack.DriftInformation.StackDriftStatus.toUpperCase() === 'DRIFTED') { + helpers.addResult(results, 2, + `CloudFormation stack "${stack.StackName}" is in drifted state`, + region, resource); + } else { + helpers.addResult(results, 0, + `CloudFormation stack "${stack.StackName}" is not in drifted state`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudformation/driftDetection.spec.js b/plugins/aws/cloudformation/driftDetection.spec.js new file mode 100644 index 000000000..de53cf85b --- /dev/null +++ b/plugins/aws/cloudformation/driftDetection.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +const driftDetection = require('./driftDetection'); + +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "DRIFTED", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, +]; + +const createCache = (stacks) => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + data: stacks + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error listing CloudFormation stacks' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('driftDetection', function () { + describe('run', function () { + it('should PASS if CloudFormation stack is not in drifted state', function (done) { + const cache = createCache([listStacks[0]]); + driftDetection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudFormation stack is in drifted state', function (done) { + const cache = createCache([listStacks[1]]); + driftDetection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no CloudFormation stacks found', function (done) { + const cache = createCache([]); + driftDetection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list stacks', function (done) { + const cache = createErrorCache(); + driftDetection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list stacks response not found', function (done) { + const cache = createNullCache(); + driftDetection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudformation/plainTextParameters.js b/plugins/aws/cloudformation/plainTextParameters.js index e03a98bd0..92613b083 100644 --- a/plugins/aws/cloudformation/plainTextParameters.js +++ b/plugins/aws/cloudformation/plainTextParameters.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudFormation Plaintext Parameters', category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures CloudFormation parameters that reference sensitive values are configured to use NoEcho.', more_info: 'CloudFormation supports the NoEcho property for sensitive values, which should be used to ensure secrets are not exposed in the CloudFormation UI and APIs.', link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html', recommended_action: 'Update the sensitive parameters to use the NoEcho property.', - apis: ['CloudFormation:describeStacks'], + apis: ['CloudFormation:listStacks', 'CloudFormation:describeStacks'], settings: { plain_text_parameters: { name: 'CloudFormation Plaintext Parameters', @@ -17,58 +19,74 @@ module.exports = { default: 'secret,password,privatekey' } }, + realtime_triggers: ['cloudformation:CreateStack','cloudformation:UpdateStack','cloudformation:DeleteStack'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); var secretWords = this.settings.plain_text_parameters.default; - async.each(regions.cloudformation, function(region, rcb){ + secretWords = secretWords.split(','); + async.each(regions.cloudformation, function(region, rcb) { + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); - var describeStacks = helpers.addSource(cache, source, - ['cloudformation', 'describeStacks', region]); - if (!describeStacks) return rcb(); + if (!listStacks) return rcb(); - if (describeStacks.err || !describeStacks.data) { - helpers.addResult(results, 3, - 'Unable to describe stacks: ' + helpers.addError(describeStacks), region); + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); return rcb(); } - if (!describeStacks.data.length) { + if (!listStacks.data.length) { helpers.addResult(results, 0, 'No CloudFormation stacks found', region); return rcb(); } - - for (var s in describeStacks.data){ - // arn:aws:cloudformation:region:account-id:stack/stack-name/stack-id - var stack = describeStacks.data[s]; - var resource = stack.StackId; - let foundStrings = []; - if(!stack.Parameters || !stack.Parameters.length) { - helpers.addResult(results, 0, - 'Template does not contain any parameters', region, resource); - continue; + async.each(listStacks.data, function(stack, cb) { + if (!stack.StackId || !stack.StackName) return cb(); + + var describeStacks = helpers.addSource(cache, source, + ['cloudformation', 'describeStacks', region, stack.StackName]); + + if (!describeStacks || describeStacks.err || !describeStacks.data || + !describeStacks.data.Stacks || !describeStacks.data.Stacks.length) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stack detils: ${helpers.addError(describeStacks)}`, + region, stack.StackId); + return cb(); } - stack.Parameters.forEach(function(parameter){ - if(parameter.ParameterKey && secretWords.includes(parameter.ParameterKey.toLowerCase()) && !parameter.ParameterValue.match('^[*]+$')) { - foundStrings.push(parameter.ParameterKey); + for (var stackDetails of describeStacks.data.Stacks) { + var resource = stackDetails.StackId; + let foundStrings = []; + + if (!stackDetails.Parameters || !stackDetails.Parameters.length) { + helpers.addResult(results, 0, + 'Template does not contain any parameters', region, resource); + continue; } - }); - if(foundStrings && foundStrings.length) { - helpers.addResult(results, 2, - 'Template contains the following potentially-sensitive parameters: ' + foundStrings, region, resource); - } - else { - helpers.addResult(results, 0, - 'Template does not contain any potentially-sensitive parameters', region, resource); + for (var parameter of stackDetails.Parameters) { + if (parameter.ParameterKey && secretWords.includes(parameter.ParameterKey.toLowerCase()) && !parameter.ParameterValue.match('^[*]+$')) { + foundStrings.push(parameter.ParameterKey); + } + } + + if (foundStrings && foundStrings.length) { + helpers.addResult(results, 2, + `Template contains these potentially-sensitive parameters: ${foundStrings.join(', ')}`, + region, resource); + } else { + helpers.addResult(results, 0, + 'Template does not contain any potentially-sensitive parameters', + region, resource); + } } - } - rcb(); + cb(); + }, function(){ + rcb(); + }); }, function(){ callback(null, results, source); }); diff --git a/plugins/aws/cloudformation/plainTextParameters.spec.js b/plugins/aws/cloudformation/plainTextParameters.spec.js index 03e8abf7e..de78e0054 100644 --- a/plugins/aws/cloudformation/plainTextParameters.spec.js +++ b/plugins/aws/cloudformation/plainTextParameters.spec.js @@ -1,92 +1,129 @@ var expect = require('chai').expect; const plaintextParameters = require('./plainTextParameters'); +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, +]; + const describeStacks = [ { - StackId: 'arn:aws:cloudformation:us-east-1:55005500:stack/TestStack/1493b310-dc80-11ea-b8ab-1214c28caebf', - StackName: 'TestStack', - Parameters: [ + "Stacks": [ { - ParameterKey: 'Secret', - ParameterValue: 'bucketwithsecretparameter1' - }, - { - ParameterKey: 'Password', - ParameterValue: 'bucketwithsecretparameter1' - } - ], - CreationTime: '2020-08-13T13:34:52.435Z', - RollbackConfiguration: { RollbackTriggers: [] }, - StackStatus: 'CREATE_COMPLETE', - DisableRollback: false, - NotificationARNs: [], - Capabilities: [], - Outputs: [], - Tags: [], - DriftInformation: { StackDriftStatus: 'NOT_CHECKED' } + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "Parameters": [ + { + "ParameterKey": "Secret", + "ParameterValue": "bucketwithsecretparameter1" + }, + { + "ParameterKey": "Password", + "ParameterValue": "bucketwithsecretparameter1" + } + ], + "CreationTime": "2020-08-13T13:34:52.435Z", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": [], + "Capabilities": [], + "Outputs": [], + "Tags": [], + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }] }, { - StackId: 'arn:aws:cloudformation:us-east-1:55005500:stack/TestStack/1493b310-dc80-11ea-b8ab-1214c28caebf', - StackName: 'TestStack', - Parameters: [ + "Stacks": [ { - ParameterKey: 'S3BucketName', - ParameterValue: 'testbucketplaintext1' - } - ], - CreationTime: '2020-08-12T09:42:04.803Z', - RollbackConfiguration: { RollbackTriggers: [] }, - StackStatus: 'CREATE_COMPLETE', - DisableRollback: false, - NotificationARNs: [], - Capabilities: [], - Outputs: [], - Tags: [], - DriftInformation: { StackDriftStatus: 'NOT_CHECKED' } + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "Parameters": [ + { + "ParameterKey": "S3BucketName", + "ParameterValue": "testbucketplaintext1" + } + ], + "CreationTime": "2020-08-12T09:42:04.803Z", + "RollbackConfiguration": { + "RollbackTriggers": [ + + ] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }] }, { - StackId: 'arn:aws:cloudformation:us-east-1:55005500:stack/TestStack/1493b310-dc80-11ea-b8ab-1214c28caebf', - StackName: 'TestStack', - Parameters: [ + "Stacks": [ { - ParameterKey: 'Secret', - ParameterValue: '****' - } - ], - CreationTime: '2020-08-13T13:34:52.435Z', - RollbackConfiguration: { RollbackTriggers: [] }, - StackStatus: 'CREATE_COMPLETE', - DisableRollback: false, - NotificationARNs: [], - Capabilities: [], - Outputs: [], - Tags: [], - DriftInformation: { StackDriftStatus: 'NOT_CHECKED' } + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "Parameters": [ + { + "ParameterKey": "Secret", + "ParameterValue": "****" + } + ], + "CreationTime": "2020-08-13T13:34:52.435Z", + "RollbackConfiguration": { + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }] }, { - StackId: 'arn:aws:cloudformation:us-east-1:55005500:stack/TestStack/1493b310-dc80-11ea-b8ab-1214c28caebf', - StackName: 'TestStack', - Parameters: [], - CreationTime: '2020-08-12T09:42:04.803Z', - RollbackConfiguration: { RollbackTriggers: [] }, - StackStatus: 'CREATE_COMPLETE', - DisableRollback: false, - NotificationARNs: [], - Capabilities: [], - Outputs: [], - Tags: [], - DriftInformation: { StackDriftStatus: 'NOT_CHECKED' } + "Stacks": [ + { + "StackId": "arn:aws:cloudformation:us-east-1:55005500:stack/TestStack/1493b310-dc80-11ea-b8ab-1214c28caebf", + "StackName": "AKD", + "Parameters": [], + "CreationTime": "2020-08-12T09:42:04.803Z", + "RollbackConfiguration": { + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + } + }] } ] -const createCache = (stacks) => { +const createCache = (stacks, stackDetails) => { + var stackName = (stacks && stacks.length && stacks[0].StackName) ? stacks[0].StackName : null; return { cloudformation: { - describeStacks: { + listStacks: { 'us-east-1': { data: stacks }, }, + describeStacks: { + 'us-east-1': { + [stackName]: { + data: stackDetails + }, + }, + }, }, }; }; @@ -94,10 +131,17 @@ const createCache = (stacks) => { const createErrorCache = () => { return { cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error listing CloudFormation stacks' + }, + }, + }, describeStacks: { 'us-east-1': { err: { - message: 'error describing cloudformation stacks' + message: 'error describing CloudFormation stacks' }, }, }, @@ -108,6 +152,9 @@ const createErrorCache = () => { const createNullCache = () => { return { cloudformation: { + listStacks: { + 'us-east-1': null, + }, describeStacks: { 'us-east-1': null, }, @@ -117,17 +164,17 @@ const createNullCache = () => { describe('plaintextParameters', function () { describe('run', function () { - it('should FAIL if template contains one of secret words', function (done) { - const cache = createCache([describeStacks[0]]); + it('should PASS if template does not contain any potentially-sensitive parameters', function (done) { + const cache = createCache([listStacks[0]], describeStacks[1]); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); + expect(results[0].status).to.equal(0); done(); }); }); - it('should PASS if template does not contain any of secret words', function (done) { - const cache = createCache([describeStacks[1]]); + it('should PASS if template contains any potentially-sensitive parameters but with NoEcho enabled', function (done) { + const cache = createCache([listStacks[0]], describeStacks[2]); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -135,16 +182,16 @@ describe('plaintextParameters', function () { }); }); - it('should PASS if template contains any of secret words but with NoEcho enabled', function (done) { - const cache = createCache([describeStacks[2]]); + it('should FAIL if template contains any potentially-sensitive parameters', function (done) { + const cache = createCache([listStacks[0]], describeStacks[0]); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); done(); }); }); - it('should PASS if unable to describe stacks', function (done) { + it('should PASS if no CloudFormation stacks found', function (done) { const cache = createCache([]); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -153,25 +200,26 @@ describe('plaintextParameters', function () { }); }); - it('should PASS if there is no parameter in the stack', function (done) { - const cache = createCache([describeStacks[2]]); + it('should UNKNOWN if unable to list stacks', function (done) { + const cache = createErrorCache(); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(3); done(); }); }); - it('should not return any results if unable to fetch any stack description', function (done) { - const cache = createNullCache(); + it('should UNKNOWN if unable to describe stacks', function (done) { + const cache = createCache([listStacks[0]], []); plaintextParameters.run(cache, {}, (err, results) => { - expect(results.length).to.equal(0); + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); done(); }); }); - it('should UNKNOWN if error occurs while fetching stack description', function (done) { - const cache = createErrorCache(); + it('should UNKNOWN if no CloudFormation stack details found', function (done) { + const cache = createCache([listStacks[0]], { "Stacks": [] }); plaintextParameters.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); @@ -179,5 +227,13 @@ describe('plaintextParameters', function () { }); }); + it('should not return any results if list stacks response is not found', function (done) { + const cache = createNullCache(); + plaintextParameters.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); }); \ No newline at end of file diff --git a/plugins/aws/cloudformation/stackFailedStatus.js b/plugins/aws/cloudformation/stackFailedStatus.js new file mode 100644 index 000000000..f76a4579b --- /dev/null +++ b/plugins/aws/cloudformation/stackFailedStatus.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFormation Stack Failed Status', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that AWS CloudFormation stacks are not in Failed mode for more than the maximum failure limit hours.', + more_info: 'AWS CloudFormation stacks should not be in failed mode to avoid application downtime.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-console-view-stack-data-resources.html', + recommended_action: 'Remove or redeploy the CloudFormation failed stack.', + apis: ['CloudFormation:listStacks', 'CloudFormation:describeStackEvents'], + settings: { + stack_failed_state_hours_limit: { + name: 'CloudFormation Stack Failed State Limit', + description: 'A value to be used as a maximum limit in hours in which a CloudFormation stack can stay in failed state', + regex: '^[0-9]{1,4}$', + default: 0 + } + }, + realtime_triggers: ['cloudformation:CreateStack','cloudformation:UpdateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var stack_failed_state_hours_limit = ('stack_failed_state_hours_limit' in settings) ? parseInt(settings.stack_failed_state_hours_limit) : this.settings.stack_failed_state_hours_limit.default; + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + async.each(listStacks.data, function(stack, cb) { + if (!stack.StackId || !stack.StackName) return cb(); + var resource = stack.StackId; + + if (stack.StackStatus && ( + stack.StackStatus.toUpperCase() === 'CREATE_FAILED' || + stack.StackStatus.toUpperCase() === 'DELETE_FAILED' || + stack.StackStatus.toUpperCase() === 'ROLLBACK_FAILED' || + stack.StackStatus.toUpperCase() === 'UPDATE_ROLLBACK_FAILED')) { + var describeStackEvents = helpers.addSource(cache, source, + ['cloudformation', 'describeStackEvents', region, stack.StackName]); + + if (!describeStackEvents || describeStackEvents.err || !describeStackEvents.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stack events: ${helpers.addError(describeStackEvents)}`, region); + return cb(); + } + + if (!describeStackEvents.data.StackEvents || + !describeStackEvents.data.StackEvents.length) { + helpers.addResult(results, 0, 'No CloudFormation stack events found', region); + return cb(); + } + + var latestEvent = describeStackEvents.data.StackEvents[0]; + var now = new Date(); + var then = new Date(latestEvent.Timestamp); + var difference = helpers.hoursBetween(then, now); + + if (difference > stack_failed_state_hours_limit) { + helpers.addResult(results, 2, + `CloudFormation stack "${stack.StackName}" is in failed state for ${difference} hours of ${stack_failed_state_hours_limit} limit`, + region, resource); + } else { + helpers.addResult(results, 0, + `CloudFormation stack "${stack.StackName}" is in failed state for ${difference} hours of ${stack_failed_state_hours_limit} limit`, + region, resource); + } + + } else { + helpers.addResult(results, 0, + `CloudFormation stack "${stack.StackName}" is not in failed state`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudformation/stackFailedStatus.spec.js b/plugins/aws/cloudformation/stackFailedStatus.spec.js new file mode 100644 index 000000000..7c5ffdbd9 --- /dev/null +++ b/plugins/aws/cloudformation/stackFailedStatus.spec.js @@ -0,0 +1,183 @@ +var expect = require('chai').expect; +const stackFailedStatus = require('./stackFailedStatus'); + +var stackFailTime = new Date(); +stackFailTime.setMonth(stackFailTime.getMonth() - 1); + +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": [], + "Tags": [], + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_FAILED", + "DisableRollback": false, + "NotificationARNs": [], + "Tags": [], + "DriftInformation": { + "StackDriftStatus": "DRIFTED", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + } +]; + +const describeStackEvents = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "EventId": "181744d0-3733-11eb-ba27-12d63bc7967b", + "StackName": "AKD", + "LogicalResourceId": "AKD", + "PhysicalResourceId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": new Date(), + "ResourceStatus": "Create_Failed", + "ClientRequestToken": "Console-CreateStack-7e419cb6-89e6-d631-1815-395a30eb1348" + }, + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "EventId": "181744d0-3733-11eb-ba27-12d63bc7967b", + "StackName": "AKD", + "LogicalResourceId": "AKD", + "PhysicalResourceId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": stackFailTime, + "ResourceStatus": "Create_Failed", + "ClientRequestToken": "Console-CreateStack-7e419cb6-89e6-d631-1815-395a30eb1348" + }, +] + +const createCache = (stack, events) => { + var stackName = (stack && stack.length && stack[0].StackName) ? stack[0].StackName : null; + return { + cloudformation: { + listStacks: { + 'us-east-1': { + data: stack + }, + }, + describeStackEvents: { + 'us-east-1': { + [stackName]: { + data: { + StackEvents: events + }, + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error describing CloudFormation stacks' + }, + }, + }, + describeStackEvents: { + 'us-east-1': { + err: { + message: 'error describing CloudFormation stacks' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': null, + }, + describeStackEvents: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('stackFailedStatus', function () { + describe('run', function () { + it('should PASS if CloudFormation stack is not in failed state', function (done) { + const cache = createCache([listStacks[0]]); + stackFailedStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if CloudFormation stack is in failed state for less than the failed hours limit', function (done) { + const cache = createCache([listStacks[1]], [describeStackEvents[0]]); + const settings = {failed_hours_limit: 10}; + + stackFailedStatus.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudFormation stack is in failed state for more than the failed hours limit', function (done) { + const cache = createCache([listStacks[1]], [describeStackEvents[1]]); + const settings = {failed_hours_limit: 10}; + + stackFailedStatus.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no CloudFormation stacks found', function (done) { + const cache = createCache([]); + stackFailedStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe stacks', function (done) { + const cache = createErrorCache(); + stackFailedStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe stacks response is not found', function (done) { + const cache = createNullCache(); + stackFailedStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudformation/stackNotifications.js b/plugins/aws/cloudformation/stackNotifications.js new file mode 100644 index 000000000..f4cccf7e6 --- /dev/null +++ b/plugins/aws/cloudformation/stackNotifications.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFormation Stack SNS Notifications', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that AWS CloudFormation stacks have SNS topic associated.', + more_info: 'AWS CloudFormation stacks should have SNS topic associated to ensure stack events monitoring.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-console-view-stack-data-resources.html', + recommended_action: 'Associate an Amazon SNS topic to all CloudFormation stacks', + apis: ['CloudFormation:listStacks', 'CloudFormation:describeStacks'], + realtime_triggers: ['cloudformation:CreateStack','cloudformation:UpdateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + async.each(listStacks.data, function(stack, cb){ + if (!stack.StackId || !stack.StackName) return cb(); + + var describeStacks = helpers.addSource(cache, source, + ['cloudformation', 'describeStacks', region, stack.StackName]); + + if (!describeStacks || describeStacks.err || !describeStacks.data || + !describeStacks.data.Stacks || !describeStacks.data.Stacks.length) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stack details: ${helpers.addError(describeStacks)}`, + region, stack.StackId); + return cb(); + } + + for (var stackDetails of describeStacks.data.Stacks) { + var resource = stackDetails.StackId; + + if (stackDetails.NotificationARNs && stackDetails.NotificationARNs.length) { + helpers.addResult(results, 0, + `CloudFormation stack "${stackDetails.StackName}" has SNS topic associated`, + region, resource); + } else { + helpers.addResult(results, 2, + `CloudFormation stack "${stackDetails.StackName}" does not have SNS topic associated`, + region, resource); + } + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudformation/stackNotifications.spec.js b/plugins/aws/cloudformation/stackNotifications.spec.js new file mode 100644 index 000000000..4fd176e08 --- /dev/null +++ b/plugins/aws/cloudformation/stackNotifications.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +const stackNotifications = require('./stackNotifications'); + +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, +]; + +const describeStacks = [ + { + "Stacks": [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": ["arn:aws:sns:us-east-1:1234567890123456:mytopic"], + "Tags": [], + "EnableTerminationProtection": true, + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + }, + } + ] + }, + { + "Stacks": [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": [], + "Tags": [], + "EnableTerminationProtection": true, + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + } + ] + } +] + +const createCache = (stacks, stackDetails) => { + var stackName = (stacks && stacks.length && stacks[0].StackName) ? stacks[0].StackName : null; + return { + cloudformation: { + listStacks: { + 'us-east-1': { + data: stacks + }, + }, + describeStacks: { + 'us-east-1': { + [stackName]: { + data: stackDetails + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error listing CloudFormation stacks' + }, + }, + }, + describeStacks: { + 'us-east-1': { + err: { + message: 'error describing CloudFormation stacks' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': null, + }, + describeStacks: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('stackNotifications', function () { + describe('run', function () { + it('should PASS if CloudFormation stack has SNS topic associated', function (done) { + const cache = createCache([listStacks[0]], describeStacks[0]); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudFormation stack does not have SNS topic associated', function (done) { + const cache = createCache([listStacks[0]], describeStacks[1]); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no CloudFormation stacks found', function (done) { + const cache = createCache([]); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if No stack details found', function (done) { + const cache = createCache([listStacks[0]], {"Stacks": [] }); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list stacks', function (done) { + const cache = createErrorCache(); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe stacks', function (done) { + const cache = createCache([listStacks[0]], []); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list stacks response is not found', function (done) { + const cache = createNullCache(); + stackNotifications.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudformation/stackTerminationProtection.js b/plugins/aws/cloudformation/stackTerminationProtection.js new file mode 100644 index 000000000..0a58eaf46 --- /dev/null +++ b/plugins/aws/cloudformation/stackTerminationProtection.js @@ -0,0 +1,132 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFormation Stack Termination Protection Enabled', + category: 'CloudFormation', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that AWS CloudFormation stacks have termination protection enabled.', + more_info: 'AWS CloudFormation stacks should have termination protection enabled to avoid accidental stack deletion.', + link: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html', + recommended_action: 'Enable termination protection for CloudFormation stack', + apis: ['CloudFormation:listStacks', 'CloudFormation:describeStacks'], + remediation_description: 'Stack termination protection will be enabled for affected stacks.', + remediation_min_version: '202205161341', + apis_remediate: ['CloudFormation:listStacks', 'CloudFormation:describeStacks'], + actions: { + remediate: ['CloudFormation:updateTerminationProtection'], + rollback: ['CloudFormation:updateTerminationProtection'] + }, + permissions: { + remediate: ['cloudformation:UpdateTerminationProtection'], + rollback: ['cloudformation:UpdateTerminationProtection'] + }, + realtime_triggers: ['cloudformation:UpdateTerminationProtection', 'cloudformation:CreateStack','cloudformation:DeleteStack'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudformation, function(region, rcb){ + var listStacks = helpers.addSource(cache, source, + ['cloudformation', 'listStacks', region]); + + if (!listStacks) return rcb(); + + if (listStacks.err || !listStacks.data) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stacks: ${helpers.addError(listStacks)}`, region); + return rcb(); + } + + if (!listStacks.data.length) { + helpers.addResult(results, 0, 'No CloudFormation stacks found', region); + return rcb(); + } + + async.each(listStacks.data, function(stack, cb){ + if (!stack.StackId || !stack.StackName) return cb(); + + var describeStacks = helpers.addSource(cache, source, + ['cloudformation', 'describeStacks', region, stack.StackName]); + + if (!describeStacks || describeStacks.err || !describeStacks.data || + !describeStacks.data.Stacks || !describeStacks.data.Stacks.length) { + helpers.addResult(results, 3, `Unable to query for CloudFormation stack detils: ${helpers.addError(describeStacks)}`, + region, stack.StackId); + return cb(); + } + + for (var stackDetails of describeStacks.data.Stacks) { + if (!stackDetails.StackId) continue; + + var resource = stackDetails.StackId; + if (stackDetails.EnableTerminationProtection) { + helpers.addResult(results, 0, + `CloudFormation stack "${stackDetails.StackName}" has termination protection enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `CloudFormation stack "${stackDetails.StackName}" does not have termination protection enabled`, + region, resource); + } + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'stackTerminationProtection'; + var stackNameArr = resource.split(':'); + var stacks = stackNameArr[stackNameArr.length - 1]; + var stackName = stacks.split('/'); + stackName = stackName[1]; + + var stackLocation = stackNameArr[3]; + + // add the location of the cloudformation stack to the config + config.region = stackLocation; + var params = {}; + + // create the params necessary for the remediation + + params = { + EnableTerminationProtection: true, + StackName: stackName + }; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'TerminationProtection': 'Disabled', + 'StackName': stackName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'TerminationProtection': 'Enabled', + 'StackName': stackName + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; diff --git a/plugins/aws/cloudformation/stackTerminationProtection.spec.js b/plugins/aws/cloudformation/stackTerminationProtection.spec.js new file mode 100644 index 000000000..9a27103e2 --- /dev/null +++ b/plugins/aws/cloudformation/stackTerminationProtection.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +const stackTerminationProtection = require('./stackTerminationProtection'); + +const listStacks = [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "StackStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + }, +]; + +const describeStacks = [ + { + "Stacks": [ + { + "StackId": "arn:aws:cloudformation:us-east-1:000011112222:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": ["arn:aws:sns:us-east-1:1234567890123456:mytopic"], + "Tags": [], + "EnableTerminationProtection": true, + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + }, + } + ] + }, + { + "Stacks": [ + { + "StackId": "arn:aws:cloudformation:us-east-1:111122223333:stack/AKD/081ed430-3733-11eb-a560-12e26def3eab", + "StackName": "AKD", + "CreationTime": "2020-12-05T19:49:48.498000+00:00", + "RollbackConfiguration": { + "RollbackTriggers": [] + }, + "StackStatus": "CREATE_COMPLETE", + "DisableRollback": false, + "NotificationARNs": [], + "Tags": [], + "EnableTerminationProtection": false, + "DriftInformation": { + "StackDriftStatus": "IN_SYNC", + "LastCheckTimestamp": "2020-12-05T20:37:03.931000+00:00" + } + } + ] + } +] + +const createCache = (stacks, stackDetails) => { + var stackName = (stacks && stacks.length && stacks[0].StackName) ? stacks[0].StackName : null; + return { + cloudformation: { + listStacks: { + 'us-east-1': { + data: stacks + }, + }, + describeStacks: { + 'us-east-1': { + [stackName]: { + data: stackDetails + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': { + err: { + message: 'error listing CloudFormation stacks' + }, + }, + }, + describeStacks: { + 'us-east-1': { + err: { + message: 'error describing CloudFormation stacks' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudformation: { + listStacks: { + 'us-east-1': null, + }, + describeStacks: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('stackTerminationProtection', function () { + describe('run', function () { + it('should PASS if CloudFormation stack has SNS topic associated', function (done) { + const cache = createCache([listStacks[0]], describeStacks[0]); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudFormation stack does not have SNS topic associated', function (done) { + const cache = createCache([listStacks[0]], describeStacks[1]); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no CloudFormation stacks found', function (done) { + const cache = createCache([]); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if No stack details found', function (done) { + const cache = createCache([listStacks[0]], {"Stacks": [] }); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list stacks', function (done) { + const cache = createErrorCache(); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe stacks', function (done) { + const cache = createCache([listStacks[0]], []); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list stacks response is not found', function (done) { + const cache = createNullCache(); + stackTerminationProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.js b/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.js new file mode 100644 index 000000000..ba71622e5 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.js @@ -0,0 +1,65 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Custom Origin HTTPS Only', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures CloudFront Distribution Custom Origin is HTTPS Only.', + more_info: 'When you create a distribution, you specify the origin where CloudFront sends requests for the files. You can use several different kinds of origins with CloudFront.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-cloudfront-to-custom-origin.html', + recommended_action: 'Modify CloudFront distribution and update the Origin Protocol Policy setting to HTTPS Only.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found'); + return callback(null, results, source); + } + + for (let distribution of listDistributions.data){ + if (!distribution.ARN) continue; + + if (!distribution.Origins || !distribution.Origins.Items || !distribution.Origins.Items.length){ + helpers.addResult(results, 0, 'CloudFront distribution has no origins', 'global', distribution.ARN); + } else { + let items = distribution.Origins.Items; + let isCorrectPolicy = true; + for (let origin of items){ + if (!origin.CustomOriginConfig || !origin.CustomOriginConfig.OriginProtocolPolicy) continue; + + let originProtocolPolicy = origin.CustomOriginConfig.OriginProtocolPolicy.toLowerCase(); + + if (originProtocolPolicy == 'http-only' || originProtocolPolicy == 'match-viewer'){ + isCorrectPolicy = false; + break; + } + } + if (!isCorrectPolicy){ + helpers.addResult(results, 2, 'CloudFront distribution custom origin is not configured to use HTTPS only', 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, 'CloudFront distribution custom origin is configured to use HTTPS only', 'global', distribution.ARN); + } + } + } + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.spec.js b/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.spec.js new file mode 100644 index 000000000..872b34917 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontCustomOriginHttpsOnly.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +const cloudfrontCustomOriginHttpsOnly = require('./cloudfrontCustomOriginHttpsOnly'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + "Origins": { + "Items":[{ + "CustomOriginConfig": { + "OriginProtocolPolicy": "match-viewer" + }}] + }, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2018' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items":[{ + "CustomOriginConfig": { + "OriginProtocolPolicy": "https-only" + }}] + }, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items":[] + }, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +describe('cloudfrontCustomOriginHttpsOnly', function () { + describe('run', function () { + it('should PASS if CloudFront distributions is using https only', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontCustomOriginHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution custom origin is configured to use HTTPS only') + done(); + }); + }); + + it('should PASS if CloudFront distributions has no origins', function (done) { + const cache = createCache([listDistributions[2]]); + cloudfrontCustomOriginHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution has no origins') + done(); + }); + }); + it('should FAIL if CloudFront Distribution is not https only', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontCustomOriginHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution custom origin is not configured to use HTTPS only') + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontCustomOriginHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('No CloudFront distributions found') + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontCustomOriginHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('Unable to query for CloudFront distributions') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.js b/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.js new file mode 100644 index 000000000..b560a2446 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.js @@ -0,0 +1,51 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Distribution Field-Level Encryption', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that field-level encryption is enabled for your Amazon CloudFront web distributions.', + more_info: 'With Amazon CloudFront, you can enforce secure end-to-end connections to origin servers by using HTTPS. Field-level encryption adds an additional layer of security that lets you protect specific data throughout system processing so that only certain applications can see it.'+ + 'Field-level encryption allows you to enable users to securely upload sensitive information to web servers. ', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html', + recommended_action: 'Enable field-level encryption for CloudFront distributions.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to list CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found'); + return callback(null, results, source); + } + + listDistributions.data.forEach(distribution => { + if (distribution.DefaultCacheBehavior && + distribution.DefaultCacheBehavior.FieldLevelEncryptionId) { + helpers.addResult(results, 0, + 'Distribution has field level encryption enabled', 'global', distribution.ARN); + } else { + helpers.addResult(results, 2, + 'Distribution does not have field level encryption enabled', 'global', distribution.ARN); + } + }); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.spec.js b/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.spec.js new file mode 100644 index 000000000..9ec24116f --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontFieldLevelEncryption.spec.js @@ -0,0 +1,98 @@ +var expect = require('chai').expect; +const cloudfrontFieldLevelEncryption = require('./cloudfrontFieldLevelEncryption'); + +const listDistributions = [ + { + "Id": "EOLX89H5ATF35", + "ARN": "arn:aws:cloudfront::000011112222:distribution/EOLX89H5ATF35", + "DefaultCacheBehavior": { + "FieldLevelEncryptionId": "C3UBCCX0U4WM2I", + "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6" + }, + }, + { + "Id": "EB5R27UN5CRBS", + "ARN": "arn:aws:cloudfront::000011112222:distribution/EB5R27UN5CRBS", + "DefaultCacheBehavior": { + "FieldLevelEncryptionId": "", + "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6" + }, + }, +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontFieldLevelEncryption', function () { + describe('run', function () { + it('should PASS if distribution has field level encryption enabled', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontFieldLevelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if distribution does not have field level encryption enabled', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontFieldLevelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontFieldLevelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontFieldLevelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontFieldLevelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontGeoRestriction.js b/plugins/aws/cloudfront/cloudfrontGeoRestriction.js new file mode 100644 index 000000000..cf20706c5 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontGeoRestriction.js @@ -0,0 +1,97 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Geo Restriction', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that geo-restriction feature is enabled for your CloudFront distribution to allow or block location-based access.', + more_info: ' AWS CloudFront geo restriction feature can be used to assist in mitigation of Distributed Denial of Service (DDoS) attacks. ' + + 'Also you have the ability to block IP addresses based on Geo IP from reaching your distribution and your web application content delivered by the distribution.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html', + recommended_action: 'Enable CloudFront geo restriction to whitelist or block location-based access.', + apis: ['CloudFront:listDistributions'], + settings: { + cloudfront_whitelisted_geo_locations: { + name: 'CloudFront WhiteListed Geographic Locations', + description: 'A comma-delimited list of shorthands for geographic locations which should be allowed within CloudFront distribution geographic restriction settings, i.e. AR=Argentine, EG=Egypt', + regex: '[a-zA-Z0-9-,]', + default: '' + } + }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var config = { + cloudfront_whitelisted_geo_locations: settings.cloudfront_whitelisted_geo_locations || this.settings.cloudfront_whitelisted_geo_locations.default, + }; + + config.cloudfront_whitelisted_geo_locations = config.cloudfront_whitelisted_geo_locations.toUpperCase().replace(/\s/g, ''); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions), 'global'); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found', 'global'); + return callback(null, results, source); + } + + listDistributions.data.forEach(distribution => { + if (distribution.Restrictions && distribution.Restrictions.GeoRestriction + && distribution.Restrictions.GeoRestriction.RestrictionType + && distribution.Restrictions.GeoRestriction.RestrictionType.toLowerCase() === 'none') { + helpers.addResult(results, 2, + 'Geo restriction feature is not enabled within CloudFront distribution', 'global', distribution.ARN); + } else if (config.cloudfront_whitelisted_geo_locations.length) { + config.cloudfront_whitelisted_geo_locations = config.cloudfront_whitelisted_geo_locations.split(','); + if (distribution.Restrictions.GeoRestriction.RestrictionType.toLowerCase() === 'whitelist') { + let items = distribution.Restrictions.GeoRestriction.Items; + let missedLocations = config.cloudfront_whitelisted_geo_locations.filter(location => !items.includes(location)); + if (missedLocations.length) { + helpers.addResult(results, 2, + `CloudFront distribution does not have these locations whitelisted: ${missedLocations.join(' ,')}`, + 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, + 'CloudFront distribution is whitelisting required geographic locations', + 'global', distribution.ARN); + } + } else if (distribution.Restrictions.GeoRestriction.RestrictionType.toLowerCase() === 'blacklist') { + let items = distribution.Restrictions.GeoRestriction.Items; + let blockedLocations = config.cloudfront_whitelisted_geo_locations.filter(location => items.includes(location)); + + if (blockedLocations.length) { + helpers.addResult(results, 2, + `CloudFront distribution has these locations blacklisted: ${blockedLocations.join(' ,')}`, + 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, + 'CloudFront distribution is whitelisting required geographic locations', + 'global', distribution.ARN); + } + } else { + helpers.addResult(results, 2, + 'Geo restriction feature is not enabled within CloudFront distribution', 'global', distribution.ARN); + } + } else { + helpers.addResult(results, 0, + 'Geo restriction feature is enabled within CloudFront distribution', 'global', distribution.ARN); + } + }); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontGeoRestriction.spec.js b/plugins/aws/cloudfront/cloudfrontGeoRestriction.spec.js new file mode 100644 index 000000000..fe21ce572 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontGeoRestriction.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +const cloudfrontGeoRestriction = require('./cloudfrontGeoRestriction'); + +const listDistributions = [ + { + "Id": "E1A8WDMPAL5GUL", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1A8WDMPAL5GUL", + "Restrictions": { + "GeoRestriction": { + "RestrictionType": "whitelist", + "Quantity": 1, + "Items": [ + "AR" + ] + } + }, + }, + { + "Id": "E2D1TO5LAMVJCU", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E2D1TO5LAMVJCU", + "Restrictions": { + "GeoRestriction": { + "RestrictionType": "none", + "Quantity": 1, + "Items": [ + "AR" + ] + } + }, + }, + { + "Id": "E1A8WDMPAL5GUL", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1A8WDMPAL5GUL", + "Restrictions": { + "GeoRestriction": { + "RestrictionType": "blacklist", + "Quantity": 1, + "Items": [ + "AF" + ] + } + }, + }, + { + "Id": "E1A8WDMPAL5GUL", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1A8WDMPAL5GUL", + "Restrictions": { + "GeoRestriction": { + "RestrictionType": "whitelist", + "Quantity": 1, + "Items": [ + "AF" + ] + } + }, + }, +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontGeoRestriction', function () { + describe('run', function () { + it('should PASS if CloudFront distribution is whitelisting required geographic locations', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontGeoRestriction.run(cache, {cloudfront_whitelisted_geo_locations: 'AR'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CloudFront distribution is whitelisting required geographic locations'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if Geo restriction feature is enabled within CloudFront distribution', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontGeoRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Geo restriction feature is enabled within CloudFront distribution'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if geo restriction is not enabled within CloudFront distribution', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontGeoRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Geo restriction feature is not enabled within CloudFront distribution'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront distribution does not have required locations whitelisted', function (done) { + const cache = createCache([listDistributions[3]]); + cloudfrontGeoRestriction.run(cache, {cloudfront_whitelisted_geo_locations: 'AR'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CloudFront distribution does not have these locations whitelisted'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontGeoRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No CloudFront distributions found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for CloudFront distributions', function (done) { + const cache = createCache([], { message: 'Unable to query for CloudFront distributions' }); + cloudfrontGeoRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CloudFront distributions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontGeoRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontHttpsOnly.js b/plugins/aws/cloudfront/cloudfrontHttpsOnly.js index c404d4e87..96e7faa13 100644 --- a/plugins/aws/cloudfront/cloudfrontHttpsOnly.js +++ b/plugins/aws/cloudfront/cloudfrontHttpsOnly.js @@ -3,19 +3,40 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudFront HTTPS Only', category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures CloudFront distributions are configured to redirect non-HTTPS traffic to HTTPS.', more_info: 'For maximum security, CloudFront distributions can be configured to only accept HTTPS connections or to redirect HTTP connections to HTTPS.', link: 'http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html', recommended_action: 'Remove HTTP-only listeners from distributions.', - apis: ['CloudFront:listDistributions'], + apis: ['CloudFront:listDistributions', 'CloudFront:getDistribution'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + 'CloudFront HTTPS redirection should be used to ensure site visitors ' + 'are always connecting over a secure channel.' }, + remediation_description: 'CloudFront distribution will be configured to only accept HTTPS connections or to redirect HTTP connections to HTTPS.', + remediation_min_version: '202101041100', + apis_remediate: ['CloudFront:listDistributions', 'CloudFront:getDistribution'], + actions: { + remediate: ['CloudFront:updateDistribution'], + rollback: ['CloudFront:updateDistribution'] + }, + permissions: { + remediate: ['cloudfront:UpdateDistribution'], + rollback: ['cloudfront:UpdateDistribution'] + }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + remediation_inputs: { + cdnPolicyOption: { + name: 'Viewer Protocol Policy Option', + description: 'https-only | redirect-to-https', + regex: '^(https-only|redirect-to-https)$', + required: false + } + }, run: function(cache, settings, callback) { - var results = []; var source = {}; @@ -52,5 +73,61 @@ module.exports = { }); callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'cloudfrontHttpsOnly'; + var distributionNameArr = resource.split(':'); + var distributionName = distributionNameArr[distributionNameArr.length - 1].split('/'); + var cdnId = distributionName[1]; + var distributionLocation = helpers.defaultRegion(settings); + + var getDistribution = helpers.addSource(cache, {}, + ['cloudfront', 'getDistribution', distributionLocation, cdnId]); + + var params = {}; + + if (getDistribution && + getDistribution.data && + getDistribution.data.ETag && + getDistribution.data.Distribution && + getDistribution.data.Distribution.DistributionConfig) { + params['DistributionConfig'] = getDistribution.data.Distribution.DistributionConfig; + + if (settings.input && settings.input.cdnPolicyOption) params['DistributionConfig']['ViewerProtocolPolicy'] = settings.input.cdnPolicyOption; + else params['DistributionConfig']['DefaultCacheBehavior']['ViewerProtocolPolicy'] = 'redirect-to-https'; + + params['Id'] = cdnId; + params['IfMatch'] = getDistribution.data.ETag; + } else { + return callback('Unable to get CloudFront distribution', null); + } + + config.region = distributionLocation; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'HTTPSOnly': 'Disabled', + 'CloudFront': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'HTTPSOnly', + 'CloudTrail': cdnId + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontHttpsOnly.spec.js b/plugins/aws/cloudfront/cloudfrontHttpsOnly.spec.js new file mode 100644 index 000000000..70f6c1abb --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontHttpsOnly.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const cloudfrontHttpsOnly = require('./cloudfrontHttpsOnly'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + "DefaultCacheBehavior": { + "TargetOriginId": "S3-cdn-oai/data", + "TrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "TrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "ViewerProtocolPolicy": "https-only", + "AllowedMethods": { + "Quantity": 2, + "Items": [ + "HEAD", + "GET" + ], + }, + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + "DefaultCacheBehavior": { + "TargetOriginId": "S3-cdn-oai/data", + "TrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "TrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "ViewerProtocolPolicy": "redirect-to-https", + "AllowedMethods": { + "Quantity": 2, + "Items": [ + "HEAD", + "GET" + ], + }, + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + "DefaultCacheBehavior": { + "TargetOriginId": "S3-cdn-oai/data", + "TrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "TrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "ViewerProtocolPolicy": "http-and-https", + "AllowedMethods": { + "Quantity": 2, + "Items": [ + "HEAD", + "GET" + ], + }, + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontHttpsOnly', function () { + describe('run', function () { + it('should PASS if CloudFront distribution is set to use HTTPS only', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if CloudFront distribution is configured to redirect non-HTTPS traffic to HTTPS', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront distribution is not configured to use HTTPS', function (done) { + const cache = createCache([listDistributions[2]]); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontInUse.js b/plugins/aws/cloudfront/cloudfrontInUse.js new file mode 100644 index 000000000..ed63e1415 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontInUse.js @@ -0,0 +1,45 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Enabled', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that AWS CloudFront service is used within your AWS account.', + more_info: 'Amazon CloudFront is a web service that speeds up distribution of your static and dynamic web content, such as .html, .css, .js, and image files, to your users. CloudFront delivers your content through a worldwide network of data centers called edge locations.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Introduction.html', + recommended_action: 'Create CloudFront distributions as per requirement.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to list CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (listDistributions.data.length) { + helpers.addResult(results, 0, + 'CloudFront service is in use', + 'global'); + } else { + helpers.addResult(results, 2, + 'CloudFront service is not in use', + 'global'); + } + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontInUse.spec.js b/plugins/aws/cloudfront/cloudfrontInUse.spec.js new file mode 100644 index 000000000..2e7edeb03 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontInUse.spec.js @@ -0,0 +1,82 @@ +var expect = require('chai').expect; +const cloudfrontInUse = require('./cloudfrontInUse'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + }, + {} +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontInUse', function () { + describe('run', function () { + it('should PASS if AWS CloudFront service is in use', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront service is in use') + done(); + }); + }); + + it('should FAIL if CloudFront service is not in use', function (done) { + const cache = createCache(listDistributions[1]); + cloudfrontInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront service is not in use') + done(); + }); + }); + + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache(null, { message: 'Unable to list distributions' }); + cloudfrontInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('Unable to list distributions') + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js index 0c8610d5e..2fca857eb 100644 --- a/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js +++ b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js @@ -3,14 +3,16 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudFront Logging Enabled', category: 'CloudFront', - description: 'Ensures CloudFront distributions have request logging enabled.', - more_info: 'Logging requests to CloudFront ' + + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures CloudFront distributions have S3 legacy logging enabled.', + more_info: 'Logging S3 legacy to CloudFront ' + 'distributions is a helpful way of detecting and ' + 'investigating potential attacks, malicious activity, ' + - 'or misuse of backend resources. Logs can be sent to S3 ' + + 'or misuse of backend resources. Logs can be sent to S3 ' + 'and processed for further analysis.', - link: 'http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html', - recommended_action: 'Enable CloudFront request logging.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/standard-logging-legacy-s3.html', + recommended_action: 'Enable CloudFront S3 legacy logging.', apis: ['CloudFront:listDistributions', 'CloudFront:getDistribution'], compliance: { hipaa: 'As part of the audit control requirement for HIPAA, request logging for ' + @@ -20,6 +22,20 @@ module.exports = { 'for PCI. Enable CloudFront logging to log requests sent to ' + 'applications in a PCI environment.' }, + asl: { + conditions: [ + { + service: 'cloudfront', + api: 'getDistribution', + property: 'Distribution.DistributionConfig.Logging.Enabled', + transform: 'STRING', + op: 'EQ', + value: 'true' + } + ] + }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + run: function(cache, settings, callback) { @@ -48,6 +64,12 @@ module.exports = { var getDistribution = helpers.addSource(cache, source, ['cloudfront', 'getDistribution', region, Distribution.Id]); + if (!getDistribution || getDistribution.err || !getDistribution.data || !getDistribution.data.Distribution) { + helpers.addResult(results, 3, + `Unable to get CloudFront distribution: ${helpers.addError(getDistribution)}`); + return; + } + if (getDistribution.data && getDistribution.data.Distribution && getDistribution.data.Distribution.DistributionConfig && @@ -55,10 +77,10 @@ module.exports = { var logging = getDistribution.data.Distribution.DistributionConfig.Logging; if (logging.Enabled){ helpers.addResult(results, 0, - 'Request logging is enabled', 'global', Distribution.ARN); + 'S3 legacy logging is enabled', 'global', Distribution.ARN); } else { helpers.addResult(results, 2, - 'Request logging is not enabled', 'global', Distribution.ARN); + 'S3 legacy logging is not enabled', 'global', Distribution.ARN); } } }); diff --git a/plugins/aws/cloudfront/cloudfrontLoggingEnabled.spec.js b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.spec.js new file mode 100644 index 000000000..e5b52cffc --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.spec.js @@ -0,0 +1,178 @@ +var expect = require('chai').expect; +const cloudfrontLoggingEnabled = require('./cloudfrontLoggingEnabled'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + "DefaultCacheBehavior": { + "TargetOriginId": "S3-cdn-oai/data", + "TrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "TrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "ViewerProtocolPolicy": "https-only", + "AllowedMethods": { + "Quantity": 2, + "Items": [ + "HEAD", + "GET" + ], + }, + }, + } +]; + +const getDistribution = [ + { + "ETag": "EHQJVBQTCVNEN", + "Distribution": { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "DistributionConfig": { + "DefaultRootObject": "", + "OriginGroups": { + "Quantity": 0 + }, + "Logging": { + "Enabled": true, + "IncludeCookies": false, + "Bucket": "s3://abc", + "Prefix": "logs/" + }, + } + } + }, + { + "ETag": "EHQJVBQTCVNEN", + "Distribution": { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "DistributionConfig": { + "DefaultRootObject": "", + "OriginGroups": { + "Quantity": 0 + }, + "Logging": { + "Enabled": false, + "IncludeCookies": false, + "Bucket": "", + "Prefix": "" + }, + } + } + } +]; + +const createCache = (data, err, getData, getErr) => { + var distributionId = (data && data.length) ? data[0].Id : null; + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + }, + getDistribution: { + 'us-east-1': { + [distributionId]: { + data: getData, + err: getErr + } + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontLoggingEnabled', function () { + describe('run', function () { + it('should PASS if Request logging is enabled', function (done) { + const cache = createCache([listDistributions[0]], null, getDistribution[0]); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Request logging is not enabled', function (done) { + const cache = createCache([listDistributions[0]], null, getDistribution[1]); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to get distributions', function (done) { + const cache = createCache([listDistributions[0]], null, null, { message: 'Unable to get distribution'}); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.js b/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.js new file mode 100644 index 000000000..749b1dc5f --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.js @@ -0,0 +1,73 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Distribution Origins TLS Version', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures CloudFront Distribution custom origin TLS version is not deprecated.', + more_info: 'The TLS (Transport Layer Security) protocol secures transmission of data over the internet using standard encryption technology. Encryption should be set with the latest version of TLS where possible.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html', + recommended_action: 'Modify cloudFront distribution and update the TLS version.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + const deprecatedVersion = [ + 'SSLv3', + 'TLSv1', + 'TLSv1.1', + ]; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found'); + return callback(null, results, source); + } + + for (let distribution of listDistributions.data){ + if (!distribution.ARN) continue; + if (!distribution.Origins || !distribution.Origins.Items || !distribution.Origins.Items.length){ + helpers.addResult(results, 0, 'CloudFront distribution has no origins', 'global', distribution.ARN); + } else { + let origins = distribution.Origins.Items; + for (let origin of origins){ + if (!origin.CustomOriginConfig || + !origin.CustomOriginConfig.OriginSslProtocols || + !origin.CustomOriginConfig.OriginSslProtocols.Items) { + helpers.addResult(results, 0, 'CloudFront distribution does not have custom origins or origins do not have SSL protocol items', 'global', distribution.ARN); + continue; + } + + let sslItems = origin.CustomOriginConfig.OriginSslProtocols.Items; + let isDeprecated = false; + for (let item of sslItems){ + if (deprecatedVersion.includes(item)){ + isDeprecated = true; + break; + } + } + if (isDeprecated){ + helpers.addResult(results, 2, 'CloudFront distribution custom origin TLS version is deprecated', 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, 'CloudFront distribution custom origin TLS version is not deprecated', 'global', distribution.ARN); + } + } + } + } + callback(null, results, source); + } +}; diff --git a/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.spec.js b/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.spec.js new file mode 100644 index 000000000..3f7291515 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontOriginTlsVersion.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +const cloudfrontOriginTLSVersion = require('./cloudfrontOriginTlsVersion'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + "Origins": { + "Items":[ + { + "CustomOriginConfig": { + "OriginSslProtocols": { + "Items": [ + 'SSLv3', + 'TLSv1', + ]}}} + ]}, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2018' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items":[ + { + "CustomOriginConfig": { + "OriginSslProtocols": { + "Items": [ + 'TLSv1.2', + ]}} + }] + }, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items": [] + }, + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +describe('cloudfrontOriginTLSVersion', function () { + describe('run', function () { + it('should PASS if CloudFront distributions custom origin TLS version is not deprecated', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontOriginTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution custom origin TLS version is not deprecated') + done(); + }); + }); + + it('should PASS if CloudFront distributions has no origins', function (done) { + const cache = createCache([listDistributions[2]]); + cloudfrontOriginTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution has no origins') + done(); + }); + }); + it('should FAIL if CloudFront Distribution custom origin TLS version is deprecated', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontOriginTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution custom origin TLS version is deprecated') + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontOriginTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('No CloudFront distributions found') + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontOriginTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('Unable to query for CloudFront distributions') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.js b/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.js new file mode 100644 index 000000000..2bee035b5 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.js @@ -0,0 +1,54 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront TLS Deprecated Protocols', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures AWS CloudFront distribution is not using deprecated TLS Version.', + more_info: 'Use latest TLS policy for CloudFront distribution to meet compliance and regulatory requirements within your organisation and to adhere to AWS security best policies.', + link: 'https://aws.amazon.com/about-aws/whats-new/2020/07/cloudfront-tls-security-policy/', + recommended_action: 'Modify cloudFront distribution and update the TLS version.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + const deprecatedTLSVersions = [ + 'SSLv3', + 'TLSv1', + 'TLSv1_2016', + 'TLSv1.1_2016', + ]; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found'); + return callback(null, results, source); + } + + for (let distribution of listDistributions.data){ + if (!distribution.ARN) continue; + + if (distribution.ViewerCertificate && distribution.ViewerCertificate.MinimumProtocolVersion && !deprecatedTLSVersions.includes(distribution.ViewerCertificate.MinimumProtocolVersion)){ + helpers.addResult(results, 0, 'CloudFront distribution is not using deprecated TLS versions', 'global', distribution.ARN); + + } else { + helpers.addResult(results, 2, 'CloudFront distribution is using deprecated TLS version', 'global', distribution.ARN); + } + } + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.spec.js b/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.spec.js new file mode 100644 index 000000000..89d287716 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontTlsDeprecatedProtocols.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +const cloudfrontTLSVersion = require('./cloudfrontTlsDeprecatedProtocols'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontTLSVersion', function () { + describe('run', function () { + it('should PASS if CloudFront distributions TLS version is not deprecated', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution is not using deprecated TLS version') + done(); + }); + }); + + it('should FAIL if CloudFront DistributionTLS version is deprecated', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution is using deprecated TLS version') + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('No CloudFront distributions found') + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('Unable to query for CloudFront distributions') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.js b/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.js new file mode 100644 index 000000000..bf6a230b8 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.js @@ -0,0 +1,52 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront TLS Insecure Cipher', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures CloudFront distribution TLS Version is not using insecure cipher.', + more_info: 'The TLS (Transport Layer Security) protocol secures transmission of data over the internet using standard encryption technology. Encryption should be set with the latest version of TLS where possible.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html', + recommended_action: 'Modify cloudFront distribution and update the TLS version.', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + const deprecatedTLSVersions = [ + 'TLSv1.2_2018', + 'TLSv1.2_2019', + ]; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found'); + return callback(null, results, source); + } + + for (let distribution of listDistributions.data){ + if (!distribution.ARN) continue; + + if (distribution.ViewerCertificate && distribution.ViewerCertificate.MinimumProtocolVersion && !deprecatedTLSVersions.includes(distribution.ViewerCertificate.MinimumProtocolVersion)){ + helpers.addResult(results, 0, 'CloudFront distribution TLS version is secure', 'global', distribution.ARN); + } else { + helpers.addResult(results, 2, 'CloudFront distribution TLS version is insecure', 'global', distribution.ARN); + } + } + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.spec.js b/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.spec.js new file mode 100644 index 000000000..5c918a107 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontTlsInsecureCipher.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +const cloudfrontTLSWeakCipher = require('./cloudfrontTlsInsecureCipher'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2018' + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + 'ViewerCertificate': { + 'MinimumProtocolVersion': 'TLSv1.2_2021' + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontTLSWeakCipher', function () { + describe('run', function () { + it('should PASS if CloudFront distributions TLS version is not weak cipher suite', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontTLSWeakCipher.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution TLS version is secure') + done(); + }); + }); + + it('should FAIL if CloudFront Distribution TLS version is weak cipher suite', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontTLSWeakCipher.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('CloudFront distribution TLS version is insecure') + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontTLSWeakCipher.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('No CloudFront distributions found') + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontTLSWeakCipher.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + expect(results[0].message).to.include('Unable to query for CloudFront distributions') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontWafEnabled.js b/plugins/aws/cloudfront/cloudfrontWafEnabled.js index e55786f44..16d79c033 100644 --- a/plugins/aws/cloudfront/cloudfrontWafEnabled.js +++ b/plugins/aws/cloudfront/cloudfrontWafEnabled.js @@ -3,11 +3,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudFront WAF Enabled', category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures CloudFront distributions have WAF enabled.', - more_info: 'Enabling WAF allows control over requests to the Cloudfront Distribution, allowing or denying traffic based off rules in the Web ACL', + more_info: 'Enabling WAF allows control over requests to the CloudFront Distribution, allowing or denying traffic based off rules in the Web ACL', link: 'https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-associating-cloudfront-distribution.html', - recommended_action: '1. Enter the WAF service. 2. Enter Web ACLs and filter by global. 3. If no Web ACL is found, Create a new global Web ACL and in Resource type to associate with web ACL, select the Cloudfront Distribution. ', - apis: ['CloudFront:listDistributions', 'CloudFront:getDistribution'], + recommended_action: '1. Enter the WAF service. 2. Enter Web ACLs and filter by global. 3. If no Web ACL is found, Create a new global Web ACL and in Resource type to associate with web ACL, select the CloudFront Distribution. ', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + run: function(cache, settings, callback) { @@ -31,21 +35,19 @@ module.exports = { helpers.addResult(results, 0, 'No CloudFront distributions found'); return callback(null, results, source); } - var badFlag = false; + // loop through Instances for every reservation listDistributions.data.forEach(distribution => { if (!distribution.WebACLId || distribution.WebACLId === '') { helpers.addResult(results, 2, - 'The Cloudfront Distribution does not have WAF enabled', ['global'], distribution.ARN); - badFlag = true; + 'The CloudFront Distribution does not have WAF enabled', 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, + 'The CloudFront Distribution has WAF enabled', 'global', distribution.ARN); } }); - if (!badFlag) { - helpers.addResult(results, 0, 'All CloudFront distributions have WAF enabled'); - } - return callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/aws/cloudfront/cloudfrontWafEnabled.spec.js b/plugins/aws/cloudfront/cloudfrontWafEnabled.spec.js new file mode 100644 index 000000000..de90a62b5 --- /dev/null +++ b/plugins/aws/cloudfront/cloudfrontWafEnabled.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +const cloudfrontWafEnabled = require('./cloudfrontWafEnabled'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "ca44237b-b1d8-46b2-abad-ada48c7f0894", + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "WebACLId": "", + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudfrontWafEnabled', function () { + describe('run', function () { + it('should PASS if CloudFront distributions has WAF enabled', function (done) { + const cache = createCache([listDistributions[0]]); + cloudfrontWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront Distribution does not have WAF enabled', function (done) { + const cache = createCache([listDistributions[1]]); + cloudfrontWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + cloudfrontWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + cloudfrontWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + cloudfrontWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/compressObjectsAutomatically.js b/plugins/aws/cloudfront/compressObjectsAutomatically.js new file mode 100644 index 000000000..dff50d55c --- /dev/null +++ b/plugins/aws/cloudfront/compressObjectsAutomatically.js @@ -0,0 +1,51 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Compress Objects Automatically', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that your Amazon CloudFront distributions are configured to automatically compress files(object).', + more_info: ' CloudFront data transfer is based on the total amount of data served, sending compressed files to the viewers is much less expensive than sending uncompressed files. ' + + 'To optimize your AWS cloud costs and speed up your web applications, configure your CloudFront distributions to compress the web content served with compression enabled.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html', + recommended_action: 'Ensures that CloudFront is configured to automatically compress files', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions), 'global'); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found', 'global'); + return callback(null, results, source); + } + + // loop through Instances for every reservation + listDistributions.data.forEach(distribution => { + if (distribution.DefaultCacheBehavior && distribution.DefaultCacheBehavior.Compress) { + helpers.addResult(results, 0, + 'CloudFront distribution is configured to compress files automatically', 'global', distribution.ARN); + } else { + helpers.addResult(results, 2, + 'CloudFront distribution is not configured to compress files automatically', 'global', distribution.ARN); + } + }); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/compressObjectsAutomatically.spec.js b/plugins/aws/cloudfront/compressObjectsAutomatically.spec.js new file mode 100644 index 000000000..8f0459850 --- /dev/null +++ b/plugins/aws/cloudfront/compressObjectsAutomatically.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +const compressObjectsAutomatically = require('./compressObjectsAutomatically'); + +const listDistributions = [ + { + "Id": "E1A8WDMPAL5GUL", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1A8WDMPAL5GUL", + "DefaultCacheBehavior": { + "Compress": true + } + }, + { + "Id": "E2D1TO5LAMVJCU", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E2D1TO5LAMVJCU", + "DefaultCacheBehavior": { + "Compress": false + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('compressObjectsAutomatically', function () { + describe('run', function () { + it('should PASS if CloudFront web distribution is currently configured to compress files (objects) automatically', function (done) { + const cache = createCache([listDistributions[0]]); + compressObjectsAutomatically.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront web distribution is currently configured to compress files (objects) automatically.', function (done) { + const cache = createCache([listDistributions[1]]); + compressObjectsAutomatically.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + compressObjectsAutomatically.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + compressObjectsAutomatically.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + compressObjectsAutomatically.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/enableOriginFailOver.js b/plugins/aws/cloudfront/enableOriginFailOver.js new file mode 100644 index 000000000..bc2e718ef --- /dev/null +++ b/plugins/aws/cloudfront/enableOriginFailOver.js @@ -0,0 +1,52 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudFront Enable Origin Failover', + category: 'CloudFront', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Origin Failover feature is enabled for your CloudFront distributions in order to improve the availability of the content delivered to your end users.', + more_info: ' With Origin Failover capability, you can setup two origins for your CloudFront web distributions primary and secondary. In the event of primary origin failure, ' + + 'your content is automatically served from the secondary origin, maintaining the distribution high reliability. ', + link: 'https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_OriginGroupFailoverCriteria.html', + recommended_action: 'Modify CloudFront distributions and configure origin group instead of a single origin', + apis: ['CloudFront:listDistributions'], + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + + + run: function(cache, settings, callback) { + + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions), 'global'); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No CloudFront distributions found', 'global'); + return callback(null, results, source); + } + + // loop through Instances for every reservation + listDistributions.data.forEach(distribution => { + if (distribution.OriginGroups && distribution.OriginGroups.Quantity) { + helpers.addResult(results, 0, + 'CloudFront distribution have origin failover enabled.', 'global', distribution.ARN); + } else { + helpers.addResult(results, 2, + 'CloudFront distribution does not have origin failover enabled.', 'global', distribution.ARN); + } + }); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudfront/enableOriginFailOver.spec.js b/plugins/aws/cloudfront/enableOriginFailOver.spec.js new file mode 100644 index 000000000..91faa6a22 --- /dev/null +++ b/plugins/aws/cloudfront/enableOriginFailOver.spec.js @@ -0,0 +1,100 @@ +var expect = require('chai').expect; +const enableOriginFailOver = require('./enableOriginFailOver'); + +const listDistributions = [ + { + "Id": "E1A8WDMPAL5GUL", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1A8WDMPAL5GUL", + "OriginGroups": { + "Quantity": 1 + }, + }, + { + "Id": "E2D1TO5LAMVJCU", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E2D1TO5LAMVJCU", + "OriginGroups": { + "Quantity": 0 + }, + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('enableOriginFailOver', function () { + describe('run', function () { + it('should PASS if CloudFront distribution have origin failover enabled.', function (done) { + const cache = createCache([listDistributions[0]]); + enableOriginFailOver.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CloudFront distribution have origin failover enabled.'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront distribution does not have origin failover enabled.', function (done) { + const cache = createCache([listDistributions[1]]); + enableOriginFailOver.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CloudFront distribution does not have origin failover enabled.'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + enableOriginFailOver.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No CloudFront distributions found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if query for CloudFront distributions', function (done) { + const cache = createCache([], { message: 'query for CloudFront distributions' }); + enableOriginFailOver.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CloudFront distributions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + enableOriginFailOver.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/insecureProtocols.js b/plugins/aws/cloudfront/insecureProtocols.js index 00c58cdce..c09361e81 100644 --- a/plugins/aws/cloudfront/insecureProtocols.js +++ b/plugins/aws/cloudfront/insecureProtocols.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Insecure CloudFront Protocols', category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', description: 'Detects the use of insecure HTTPS SSL/TLS protocols for use with HTTPS traffic between viewers and CloudFront', more_info: 'CloudFront supports SSLv3 and TLSv1 protocols for use with HTTPS traffic, but only TLSv1.1 or higher should be used unless there is a valid business justification to support the older, insecure SSLv3.', link: 'http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html', @@ -24,6 +26,8 @@ module.exports = { default: 'true' } }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + run: function(cache, settings, callback) { var config = { @@ -73,13 +77,13 @@ module.exports = { 'global', distribution.ARN, custom); } } else if (distribution.ViewerCertificate.MinimumProtocolVersion === 'SSLv3') { - helpers.addResult(results, 1, 'Distribution is using insecure SSLv3', + helpers.addResult(results, 2, 'Distribution is using insecure SSLv3', 'global', distribution.ARN); } else if (distribution.ViewerCertificate.MinimumProtocolVersion === 'TLSv1') { - helpers.addResult(results, 1, 'Distribution is using insecure TLSv1.0', + helpers.addResult(results, 2, 'Distribution is using insecure TLSv1.0', 'global', distribution.ARN); } else if (distribution.ViewerCertificate.MinimumProtocolVersion === 'TLSv1_2016') { - helpers.addResult(results, 1, 'Distribution is using insecure TLSv1_2016', + helpers.addResult(results, 2, 'Distribution is using insecure TLSv1_2016', 'global', distribution.ARN); } else if (distribution.ViewerCertificate.MinimumProtocolVersion === 'TLSv1.1_2016') { helpers.addResult(results, 0, 'Distribution is using secure TLSv1.1_2016', diff --git a/plugins/aws/cloudfront/insecureProtocols.spec.js b/plugins/aws/cloudfront/insecureProtocols.spec.js new file mode 100644 index 000000000..ef52472be --- /dev/null +++ b/plugins/aws/cloudfront/insecureProtocols.spec.js @@ -0,0 +1,204 @@ +var expect = require('chai').expect; +const insecureProtocols = require('./insecureProtocols'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "CloudFrontDefaultCertificate": true, + "MinimumProtocolVersion": "TLSv1", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "CloudFrontDefaultCertificate": true, + "MinimumProtocolVersion": "TLSv1", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "MinimumProtocolVersion": "SSLv3", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "MinimumProtocolVersion": "TLSv1", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "MinimumProtocolVersion": "TLSv1_2016", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "MinimumProtocolVersion": "TLSv1.1_2016", + "CertificateSource": "cloudfront" + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "ViewerCertificate": { + "MinimumProtocolVersion": "TLSv1.2_2018", + "CertificateSource": "cloudfront" + }, + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('insecureProtocols', function () { + describe('run', function () { + it('should PASS if Distribution is not configured for SSL delivery', function (done) { + const cache = createCache([listDistributions[0]]); + insecureProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if Distribution is using secure default certificate', function (done) { + const cache = createCache([listDistributions[1]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Distribution is using the insecure default CloudFront TLS certificate', function (done) { + const cache = createCache([listDistributions[2]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'false' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Distribution is using insecure SSLv3', function (done) { + const cache = createCache([listDistributions[3]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'false' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Distribution is using insecure TLSv1.0', function (done) { + const cache = createCache([listDistributions[4]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'false' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Distribution is using insecure TLSv1_2016', function (done) { + const cache = createCache([listDistributions[5]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'false' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if Distribution is using secure TLSv1.1_2016', function (done) { + const cache = createCache([listDistributions[6]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if Distribution is using secure TLSv1.2_2018', function (done) { + const cache = createCache([listDistributions[7]]); + insecureProtocols.run(cache, { insecure_cloudfront_ignore_default: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + insecureProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + insecureProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + insecureProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/publicS3Origin.js b/plugins/aws/cloudfront/publicS3Origin.js index a519dc4df..30efea691 100644 --- a/plugins/aws/cloudfront/publicS3Origin.js +++ b/plugins/aws/cloudfront/publicS3Origin.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Public S3 CloudFront Origin', category: 'CloudFront', + domain: 'Content Delivery', + severity: 'High', description: 'Detects the use of an S3 bucket as a CloudFront origin without an origin access identity', more_info: 'When S3 is used as an origin for a CloudFront bucket, the contents should be kept private and an origin access identity should allow CloudFront access. This prevents someone from bypassing the caching benefits that CloudFront provides, repeatedly loading objects directly from S3, and amassing a large access bill.', link: 'http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html', @@ -11,9 +13,11 @@ module.exports = { apis: ['CloudFront:listDistributions'], compliance: { hipaa: 'HIPAA requires that access to protected information is controlled and audited. ' + - 'If an S3 bucket backing a CloudFront distribution does not require the end ' + - 'user to access the contents through CloudFront, this policy may be violated.' + 'If an S3 bucket backing a CloudFront distribution does not require the end ' + + 'user to access the contents through CloudFront, this policy may be violated.' }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + run: function(cache, settings, callback) { @@ -45,21 +49,25 @@ module.exports = { 'global', distribution.ARN); return cb(); } - + let publicOrigins = []; for (var o in distribution.Origins.Items) { var origin = distribution.Origins.Items[o]; - - if (origin.S3OriginConfig && + if (origin.DomainName && origin.DomainName.match(/s3(.*)\.amazonaws\.com/) && + origin.S3OriginConfig && (!origin.S3OriginConfig.OriginAccessIdentity || - !origin.S3OriginConfig.OriginAccessIdentity.length)) { - helpers.addResult(results, 2, 'CloudFront distribution is using an S3 ' + - 'origin without an origin access identity', 'global', distribution.ARN); - } else { - helpers.addResult(results, 0, 'CloudFront distribution origin is not setup ' + - 'without an origin access identity', 'global', distribution.ARN); + !origin.S3OriginConfig.OriginAccessIdentity.length) && !origin.OriginAccessControlId) { + publicOrigins.push(origin.Id); } } + if (publicOrigins.length) { + helpers.addResult(results, 2, 'CloudFront distribution is using these S3 ' + + `origins without an origin access identity: ${publicOrigins.join(',')}`, 'global', distribution.ARN); + } else { + helpers.addResult(results, 0, 'CloudFront distribution does not have any origin setup ' + + 'without an origin access identity', 'global', distribution.ARN); + } + cb(); }, function(){ diff --git a/plugins/aws/cloudfront/publicS3Origin.spec.js b/plugins/aws/cloudfront/publicS3Origin.spec.js new file mode 100644 index 000000000..5f407484b --- /dev/null +++ b/plugins/aws/cloudfront/publicS3Origin.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +const publicS3Origin = require('./publicS3Origin'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + "S3OriginConfig": { + "OriginAccessIdentity": null + }, + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1FNBIV9X9FNYA" + }, + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "S3-cdn-oai/data", + "DomainName": "cdn-oai.s3.amazonaws.com", + "OriginPath": "/data", + } + ] + }, + "OriginGroups": { + "Quantity": 0 + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('publicS3Origin', function () { + describe('run', function () { + it('should PASS if CloudFront distribution origin is not setup without an origin access identity', function (done) { + const cache = createCache([listDistributions[1]]); + publicS3Origin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront CloudFront distribution is using an S3 origin without an origin access identity', function (done) { + const cache = createCache([listDistributions[0]]); + publicS3Origin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + publicS3Origin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + publicS3Origin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + publicS3Origin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudfront/secureOrigin.js b/plugins/aws/cloudfront/secureOrigin.js index 3a948497c..f65ba8155 100644 --- a/plugins/aws/cloudfront/secureOrigin.js +++ b/plugins/aws/cloudfront/secureOrigin.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Secure CloudFront Origin', category: 'CloudFront', + domain: 'Content Delivery', + severity: 'Medium', description: 'Detects the use of secure web origins with secure protocols for CloudFront.', more_info: 'Traffic passed between the CloudFront edge nodes and the backend resource should be sent over HTTPS with modern protocols for all web-based origins.', link: 'http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web.html', @@ -15,6 +17,8 @@ module.exports = { 'ensures that traffic between CloudFront and any backend resource is ' + 'encrypted in transit.' }, + realtime_triggers: ['cloudfront:CreateDistribution','cloudfront:UpdateDistribution','cloudfront:DeleteDistribution'], + run: function(cache, settings, callback) { diff --git a/plugins/aws/cloudfront/secureOrigin.spec.js b/plugins/aws/cloudfront/secureOrigin.spec.js new file mode 100644 index 000000000..256ba95f1 --- /dev/null +++ b/plugins/aws/cloudfront/secureOrigin.spec.js @@ -0,0 +1,220 @@ +var expect = require('chai').expect; +const secureOrigin = require('./secureOrigin'); + +const listDistributions = [ + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items": [ + { + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "http-only", + "OriginSslProtocols": { + "Quantity": 4, + "Items": [ + "SSLv3", + "TLSv1", + "TLSv1.1", + "TLSv1.2" + ] + }, + "OriginReadTimeout": 30, + "OriginKeepaliveTimeout": 5 + }, + } + ] + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items": [ + { + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "https-only", + "OriginSslProtocols": { + "Quantity": 4, + "Items": [ + "SSLv3", + "TLSv1", + "TLSv1.1", + "TLSv1.2" + ] + }, + "OriginReadTimeout": 30, + "OriginKeepaliveTimeout": 5 + }, + } + ] + } + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items": [ + { + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "match-viewer", + "OriginSslProtocols": { + "Quantity": 3, + "Items": [ + "TLSv1", + "TLSv1.1", + "TLSv1.2" + ] + }, + }, + } + ] + }, + }, + { + "Id": "E1JHW5DZR5X4HW", + "ARN": "arn:aws:cloudfront::111122223333:distribution/E1JHW5DZR5X4HW", + "Origins": { + "Items": [ + { + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "match-viewer", + "OriginSslProtocols": { + "Quantity": 3, + "Items": [ + "SSLv3", + "TLSv1.1", + "TLSv1.2" + ] + }, + }, + } + ] + } + } +]; + +const createCache = (data, err) => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('secureOrigin', function () { + describe('run', function () { + it('should PASS if CloudFront origin is using https-only', function (done) { + const cache = createCache([listDistributions[1]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should WARN if CloudFront origin is using match-viewer', function (done) { + const cache = createCache([listDistributions[2]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront origin is using http-only', function (done) { + const cache = createCache([listDistributions[0]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront origin is using SSLv3 and TLSv1 protocols', function (done) { + const cache = createCache([listDistributions[1]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if CloudFront origin is using SSLv3 protocols', function (done) { + const cache = createCache([listDistributions[3]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].region).to.equal('global'); + done(); + }); + }); + + it('should WARN if CloudFront origin is using TLSv1 protocol', function (done) { + const cache = createCache([listDistributions[2]]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(1); + expect(results[1].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no CloudFront distributions found', function (done) { + const cache = createCache([]); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to list distributions', function (done) { + const cache = createCache([], { message: 'Unable to list distributions' }); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if list distributions response not found', function (done) { + const cache = createNullCache(); + secureOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js b/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js index 4ea12c4ac..74f80f90b 100644 --- a/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js +++ b/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.js @@ -3,12 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail Bucket Access Logging', - category: 'CloudTrail', + category: 'S3', + domain: 'Compliance', + severity: 'Medium', description: 'Ensures CloudTrail logging bucket has access logging enabled to detect tampering of log files', more_info: 'CloudTrail buckets should utilize access logging for an additional layer of auditing. If the log files are deleted or modified in any way, the additional access logs can help determine who made the changes.', recommended_action: 'Enable access logging on the CloudTrail bucket from the S3 console', link: 'http://docs.aws.amazon.com/AmazonS3/latest/UG/ManagingBucketLogging.html', - apis: ['CloudTrail:describeTrails', 'S3:getBucketLogging'], + apis: ['CloudTrail:describeTrails', 'S3:getBucketLogging', 'S3:listBuckets'], compliance: { hipaa: 'Access logging for CloudTrail helps ensure strict integrity controls, ' + 'verifying that the audit logs for the AWS environment are not modified.', @@ -17,17 +19,42 @@ module.exports = { 'helps audit the bucket in which these logs are stored.', cis1: '2.6 Ensure CloudTrail bucket access logging is enabled' }, + settings: { + whitelist_ct_access_logging_buckets: { + name: 'Whitelist Cloud Trail Access Logging Buckets', + description: 'All buckets against this regex will be whitelisted', + regex: '^.*$', + default: '', + } + }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:DeleteTrail','cloudtrail:UpdateTrail','s3:PutBucketLogging','s3:DeleteBucket'], run: function(cache, settings, callback) { + var config = { + whitelist_ct_access_logging_buckets: settings.whitelist_ct_access_logging_buckets || this.settings.whitelist_ct_access_logging_buckets.default + }; + var regBucket; + if (config.whitelist_ct_access_logging_buckets.length) regBucket= new RegExp(config.whitelist_ct_access_logging_buckets); var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + var defaultRegion = helpers.defaultRegion(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + if (!listBuckets || listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } async.each(regions.cloudtrail, function(region, rcb){ var describeTrails = helpers.addSource(cache, source, ['cloudtrail', 'describeTrails', region]); - + if (!describeTrails) return rcb(); if (describeTrails.err || !describeTrails.data) { @@ -42,9 +69,22 @@ module.exports = { } async.each(describeTrails.data, function(trail, cb){ - if (!trail.S3BucketName) return cb(); - // Skip CloudSploit-managed events bucket - if (trail.S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) return cb(); + if (!trail.S3BucketName || (trail.HomeRegion && trail.HomeRegion.toLowerCase() !== region)) return cb(); + // Skip CloudExploit-managed events bucket + if (trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) return cb(); + + if (regBucket && regBucket.test(trail.S3BucketName)) { + helpers.addResult(results, 0, + 'Bucket is whitelisted', region, `arn:${awsOrGov}:s3:::`+trail.S3BucketName); + return cb(); + } + + if (!listBuckets.data.find(bucket => bucket.Name == trail.S3BucketName)) { + helpers.addResult(results, 2, + 'Unable to locate S3 bucket, it may have been deleted', + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); + return cb(); + } var s3Region = helpers.defaultRegion(settings); @@ -54,7 +94,7 @@ module.exports = { if (!getBucketLogging || getBucketLogging.err || !getBucketLogging.data) { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + trail.S3BucketName + ': ' + helpers.addError(getBucketLogging), - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); return cb(); } @@ -64,11 +104,11 @@ module.exports = { getBucketLogging.data.LoggingEnabled) { helpers.addResult(results, 0, 'Bucket: ' + trail.S3BucketName + ' has S3 access logs enabled', - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } else { helpers.addResult(results, 1, 'Bucket: ' + trail.S3BucketName + ' has S3 access logs disabled', - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } cb(); diff --git a/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.spec.js b/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.spec.js new file mode 100644 index 000000000..a5ad27e0b --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailBucketAccessLogging.spec.js @@ -0,0 +1,189 @@ +var expect = require('chai').expect; +var cloudtrailBucketAccessLogging = require('./cloudtrailBucketAccessLogging'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "aws-cloudtrail-logs-123456654321-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-37b755bd", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-3", + "S3BucketName": "codepipeline-cloudtrail-placeholder-bucket-us-east-1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const listBuckets = [ + { + "Name": "aws-cloudtrail-logs-123456654321-119d2f9a", + "CreationDate": "2020-11-04T20:20:20.000Z" + }, + { + "Name": "aws-cloudtrail-logs-123456654321-37b755bd", + "CreationDate": "2020-11-01T19:04:08.000Z" + } +]; + +const getBucketLogging = [ + { + "LoggingEnabled": true + }, + { + "LoggingEnabled": false + } +]; + +const createCache = (trails, buckets, getBucketLogging) => { + var bucketName = (trails && trails.length) ? trails[0].S3BucketName : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + } + }, + getBucketLogging: { + 'us-east-1': { + [bucketName]: { + data: getBucketLogging + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + s3: { + listBuckets: { + 'us-east-1': null + }, + getBucketLogging: { + 'us-east-1': null + } + } + }; +}; + +describe('cloudtrailBucketAccessLogging', function () { + describe('run', function () { + it('should PASS if bucket has S3 access logs enabled', function (done) { + const cache = createCache([trails[0]], [listBuckets[0]], getBucketLogging[0]); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if bucket has S3 access logs disabled', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]], getBucketLogging[1]); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Unable to locate S3 bucket, it may have been deleted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketLogging[1]); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no S3 bucket to check', function (done) { + const cache = createCache([], [], null); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for bucket policy', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]]); + cloudtrailBucketAccessLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if bucket gets whitelisted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketLogging[1]); + cloudtrailBucketAccessLogging.run(cache, { whitelist_ct_access_logging_buckets:'codepipeline-cloudtrail' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket is whitelisted'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailBucketDelete.js b/plugins/aws/cloudtrail/cloudtrailBucketDelete.js index c43a1b01d..ab9fc6ce8 100644 --- a/plugins/aws/cloudtrail/cloudtrailBucketDelete.js +++ b/plugins/aws/cloudtrail/cloudtrailBucketDelete.js @@ -3,23 +3,50 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail Bucket Delete Policy', - category: 'CloudTrail', + category: 'S3', + domain: 'Compliance', + severity: 'Medium', description: 'Ensures CloudTrail logging bucket has a policy to prevent deletion of logs without an MFA token', more_info: 'To provide additional security, CloudTrail logging buckets should require an MFA token to delete objects', recommended_action: 'Enable MFA delete on the CloudTrail bucket', link: 'http://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete', - apis: ['CloudTrail:describeTrails', 'S3:getBucketVersioning'], + apis: ['CloudTrail:describeTrails', 'S3:getBucketVersioning', 'S3:listBuckets'], compliance: { hipaa: 'An MFA delete policy helps ensure that individuals attempting to ' + 'delete CloudTrail logs have verified their identity. HIPAA requires ' + 'strict access controls for users modifying the environments in which ' + 'HIPAA data is stored.' }, + settings: { + whitelist_ct_deleted_buckets: { + name: 'Whitelist Cloud Trail Deleted Buckets', + description: 'All buckets against this regex will be whitelisted', + regex: '^.*$', + default: '', + } + }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:DeleteTrail','cloudtrail:UpdateTrail','s3:DeleteBucket'], run: function(cache, settings, callback) { + var config = { + whitelist_ct_deleted_buckets: settings.whitelist_ct_deleted_buckets || this.settings.whitelist_ct_deleted_buckets.default + }; + var regBucket; + if (config.whitelist_ct_deleted_buckets.length) regBucket= new RegExp(config.whitelist_ct_deleted_buckets); var results = []; var source = {}; var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + if (!listBuckets || listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } async.each(regions.cloudtrail, function(region, rcb){ @@ -40,9 +67,22 @@ module.exports = { } async.each(describeTrails.data, function(trail, cb){ - if (!trail.S3BucketName) return cb(); - // Skip CloudSploit-managed events bucket - if (trail.S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) return cb(); + if (!trail.S3BucketName || (trail.HomeRegion && trail.HomeRegion.toLowerCase() !== region)) return cb(); + // Skip CloudExploit-managed events bucket + if (trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) return cb(); + + if (regBucket && regBucket.test(trail.S3BucketName)) { + helpers.addResult(results, 0, + 'Bucket is whitelisted', region, `arn:${awsOrGov}:s3:::`+trail.S3BucketName); + return cb(); + } + + if (!listBuckets.data.find(bucket => bucket.Name == trail.S3BucketName)) { + helpers.addResult(results, 2, + 'Unable to locate S3 bucket, it may have been deleted', + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); + return cb(); + } var s3Region = helpers.defaultRegion(settings); @@ -52,7 +92,7 @@ module.exports = { if (!getBucketVersioning || getBucketVersioning.err || !getBucketVersioning.data) { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + trail.S3BucketName + ': ' + helpers.addError(getBucketVersioning), - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); return cb(); } @@ -61,11 +101,11 @@ module.exports = { getBucketVersioning.data.MFADelete === 'Enabled') { helpers.addResult(results, 0, 'Bucket: ' + trail.S3BucketName + ' has MFA delete enabled', - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } else { helpers.addResult(results, 1, 'Bucket: ' + trail.S3BucketName + ' has MFA delete disabled', - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } cb(); diff --git a/plugins/aws/cloudtrail/cloudtrailBucketDelete.spec.js b/plugins/aws/cloudtrail/cloudtrailBucketDelete.spec.js new file mode 100644 index 000000000..474dc274f --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailBucketDelete.spec.js @@ -0,0 +1,189 @@ +var expect = require('chai').expect; +var cloudtrailBucketDelete = require('./cloudtrailBucketDelete'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "aws-cloudtrail-logs-123456654321-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-37b755bd", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-3", + "S3BucketName": "codepipeline-cloudtrail-placeholder-bucket-us-east-1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const listBuckets = [ + { + "Name": "aws-cloudtrail-logs-123456654321-119d2f9a", + "CreationDate": "2020-11-04T20:20:20.000Z" + }, + { + "Name": "aws-cloudtrail-logs-123456654321-37b755bd", + "CreationDate": "2020-11-01T19:04:08.000Z" + } +]; + +const getBucketVersioning = [ + { + "MFADelete": "Enabled" + }, + { + "MFADelete": "Disabled" + } +]; + +const createCache = (trails, buckets, getBucketVersioning) => { + var bucketName = (trails && trails.length) ? trails[0].S3BucketName : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + } + }, + getBucketVersioning: { + 'us-east-1': { + [bucketName]: { + data: getBucketVersioning + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + s3: { + listBuckets: { + 'us-east-1': null + }, + getBucketVersioning: { + 'us-east-1': null + } + } + }; +}; + +describe('cloudtrailBucketDelete', function () { + describe('run', function () { + it('should PASS if bucket has MFA delete enabled', function (done) { + const cache = createCache([trails[0]], [listBuckets[0]], getBucketVersioning[0]); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if bucket has MFA delete enabled', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]], getBucketVersioning[1]); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Unable to locate S3 bucket, it may have been deleted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketVersioning[1]); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no S3 bucket to check', function (done) { + const cache = createCache([], [], null); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for bucket policy', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]]); + cloudtrailBucketDelete.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if bucket gets whitelisted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketVersioning[1]); + cloudtrailBucketDelete.run(cache, { whitelist_ct_deleted_buckets: 'codepipeline-cloudtrail' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket is whitelisted'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailBucketPrivate.js b/plugins/aws/cloudtrail/cloudtrailBucketPrivate.js index 53172b9ee..ea6611930 100644 --- a/plugins/aws/cloudtrail/cloudtrailBucketPrivate.js +++ b/plugins/aws/cloudtrail/cloudtrailBucketPrivate.js @@ -3,21 +3,50 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail Bucket Private', - category: 'CloudTrail', + category: 'S3', + domain: 'Compliance', + severity: 'High', description: 'Ensures CloudTrail logging bucket is not publicly accessible', more_info: 'CloudTrail buckets contain large amounts of sensitive account data and should only be accessible by logged in users.', recommended_action: 'Set the S3 bucket access policy for all CloudTrail buckets to only allow known users to access its files.', link: 'http://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html', - apis: ['CloudTrail:describeTrails', 'S3:getBucketAcl'], + apis: ['CloudTrail:describeTrails', 'S3:getBucketAcl', 'S3:listBuckets'], compliance: { cis1: '2.3 Ensure the S3 bucket used to store CloudTrail logs is not publicly accessible' }, + settings: { + whitelist_ct_private_buckets: { + name: 'Whitelist Cloud Trail Private Buckets', + description: 'All buckets against this regex will be whitelisted', + regex: '^.*$', + default: '', + } + + }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:DeleteTrail','cloudtrail:UpdateTrail','s3:PutBucketPublicAccessBlock','s3:PutBucketAcl','s3:DeleteBucket'], run: function(cache, settings, callback) { + var config = { + whitelist_ct_private_buckets: settings.whitelist_ct_private_buckets || this.settings.whitelist_ct_private_buckets.default + }; + var regBucket; + if (config.whitelist_ct_private_buckets.length) regBucket= new RegExp(config.whitelist_ct_private_buckets); var results = []; var source = {}; var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + if (!listBuckets || listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + async.each(regions.cloudtrail, function(region, rcb){ var describeTrails = helpers.addSource(cache, source, @@ -37,9 +66,22 @@ module.exports = { } async.each(describeTrails.data, function(trail, cb){ - if (!trail.S3BucketName) return cb(); - // Skip CloudSploit-managed events bucket - if (trail.S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) return cb(); + if (!trail.S3BucketName || (trail.HomeRegion && trail.HomeRegion.toLowerCase() !== region)) return cb(); + // Skip CloudExploit-managed events bucket + if (trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) return cb(); + + if (regBucket && regBucket.test(trail.S3BucketName)) { + helpers.addResult(results, 0, + 'Bucket is whitelisted', region, `arn:${awsOrGov}:s3:::`+trail.S3BucketName); + return cb(); + } + + if (!listBuckets.data.find(bucket => bucket.Name == trail.S3BucketName)) { + helpers.addResult(results, 2, + 'Unable to locate S3 bucket, it may have been deleted', + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); + return cb(); + } var s3Region = helpers.defaultRegion(settings); @@ -49,8 +91,7 @@ module.exports = { if (!getBucketAcl || getBucketAcl.err || !getBucketAcl.data) { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + trail.S3BucketName + ': ' + helpers.addError(getBucketAcl), - region, 'arn:aws:s3:::' + trail.S3BucketName); - + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); return cb(); } @@ -69,11 +110,11 @@ module.exports = { if (allowsAllUsersTypes.length) { helpers.addResult(results, 2, 'Bucket: ' + trail.S3BucketName + ' allows global access to: ' + allowsAllUsersTypes.concat(', '), - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } else { helpers.addResult(results, 0, 'Bucket: ' + trail.S3BucketName + ' does not allow public access', - region, 'arn:aws:s3:::' + trail.S3BucketName); + region, `arn:${awsOrGov}:s3:::` + trail.S3BucketName); } cb(); diff --git a/plugins/aws/cloudtrail/cloudtrailBucketPrivate.spec.js b/plugins/aws/cloudtrail/cloudtrailBucketPrivate.spec.js new file mode 100644 index 000000000..99623d772 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailBucketPrivate.spec.js @@ -0,0 +1,208 @@ +var expect = require('chai').expect; +var cloudtrailBucketPrivate = require('./cloudtrailBucketPrivate'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "aws-cloudtrail-logs-123456654321-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-37b755bd", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-3", + "S3BucketName": "codepipeline-cloudtrail-placeholder-bucket-us-east-1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const listBuckets = [ + { + "Name": "aws-cloudtrail-logs-123456654321-119d2f9a", + "CreationDate": "2020-11-04T20:20:20.000Z" + }, + { + "Name": "aws-cloudtrail-logs-123456654321-37b755bd", + "CreationDate": "2020-11-01T19:04:08.000Z" + } +]; + +const getBucketAcl = [ + { + Grants: [ + { + Grantee: { + DisplayName: 'cloudexploit', + ID: '91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5', + Type: 'Group', + URI: [ 'AllUsers' ] + }, + Permission: 'FULL_CONTROL' + } + ] + }, + { + Grants: [ + { + Grantee: { + DisplayName: 'cloudexploit', + ID: '91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5', + Type: 'CanonicalUser' + }, + Permission: 'FULL_CONTROL' + } + ] + } +]; + +const createCache = (trails, buckets, getBucketAcl) => { + var bucketName = (trails && trails.length) ? trails[0].S3BucketName : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + } + }, + getBucketAcl: { + 'us-east-1': { + [bucketName]: { + data: getBucketAcl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + s3: { + listBuckets: { + 'us-east-1': null + }, + getBucketAcl: { + 'us-east-1': null + } + } + }; +}; + +describe('cloudtrailBucketPrivate', function () { + describe('run', function () { + it('should PASS if bucket does not allow global access', function (done) { + const cache = createCache([trails[0]], [listBuckets[0]], getBucketAcl[1]); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if bucket allows global access', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]], getBucketAcl[0]); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Unable to locate S3 bucket, it may have been deleted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketAcl[0]); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no S3 bucket to check', function (done) { + const cache = createCache([], [], null); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for bucket policy', function (done) { + const cache = createCache([trails[1]], [listBuckets[1]]); + cloudtrailBucketPrivate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if bucket gets whitelisted', function (done) { + const cache = createCache([trails[2]], [listBuckets[1]], getBucketAcl[0]); + cloudtrailBucketPrivate.run(cache, { whitelist_ct_private_buckets: 'codepipeline-cloudtrail' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket is whitelisted'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailDataEvents.js b/plugins/aws/cloudtrail/cloudtrailDataEvents.js new file mode 100644 index 000000000..c805cacf7 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailDataEvents.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Data Events', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Low', + description: 'Ensure Data events are included into Amazon CloudTrail trails configuration.', + more_info: 'AWS CloudTrail trails should be configured to enable Data Events in order to log S3 object-level API operations.', + recommended_action: 'Update CloudTrail to enable data events.', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html', + apis: ['CloudTrail:describeTrails', 'CloudTrail:getEventSelectors'], + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:DeleteTrail','cloudtrail:PutEventSelectors'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudtrail, function(region, rcb){ + + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 0, 'No CloudTrail trails found', region); + return rcb(); + } + + async.each(describeTrails.data, function(trail, cb){ + if (!trail.TrailARN) return cb(); + + var resource = trail.TrailARN; + + var getEventSelectors = helpers.addSource(cache, source, + ['cloudtrail', 'getEventSelectors', region, trail.TrailARN]); + + if (!getEventSelectors || + getEventSelectors.err || + !getEventSelectors.data || + !getEventSelectors.data.EventSelectors) { + helpers.addResult(results, 3, + `Unable to query event selectors: ${helpers.addError(getEventSelectors)}`, region, resource); + return cb(); + } + + var dataResourceFound = false; + for (var e in getEventSelectors.data.EventSelectors){ + var eventSelector = getEventSelectors.data.EventSelectors[e]; + + if (eventSelector.DataResources && eventSelector.DataResources.length) { + dataResourceFound = true; + break; + } + } + + if (dataResourceFound) { + helpers.addResult(results, 0, + `CloudTrail trail "${trail.Name}" has Data Events configured`, region, resource); + } else { + helpers.addResult(results, 2, + `CloudTrail trail "${trail.Name}" does not have Data Events configured`, region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailDataEvents.spec.js b/plugins/aws/cloudtrail/cloudtrailDataEvents.spec.js new file mode 100644 index 000000000..7b7f563a1 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailDataEvents.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +const cloudtrailDataEvents = require('./cloudtrailDataEvents'); + +const describeTrails = [ + { + "Name": "test-trail", + "S3BucketName": "test-bucket-ct-1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "test-trail-1", + "S3BucketName": "test-bucket-ct", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getEventSelectors = [ + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail", + "EventSelectors": [ + { + "ReadWriteType": "All", + "IncludeManagementEvents": true, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "EventSelectors": [ + { + "ReadWriteType": "All", + "IncludeManagementEvents": true, + "DataResources": [], + "ExcludeManagementEventSources": [] + } + ] + } +]; + +const createCache = (trails, getEventSelectors) => { + var trailArn = (trails && trails.length) ? trails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + }, + getEventSelectors: { + 'us-east-1': { + [trailArn]: { + data: getEventSelectors + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing CloudTrail trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailDataEvents', function () { + describe('run', function () { + it('should PASS if CloudTrail trail has data events configured', function (done) { + const cache = createCache([describeTrails[0]], getEventSelectors[0]); + cloudtrailDataEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail trail does not have data events configured', function (done) { + const cache = createCache([describeTrails[1]], getEventSelectors[1]); + cloudtrailDataEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no CloudTrail trails found', function (done) { + const cache = createCache([]); + cloudtrailDataEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query trails', function (done) { + const cache = createErrorCache(); + cloudtrailDataEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results describe trail response not found', function (done) { + const cache = createNullCache(); + cloudtrailDataEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.js b/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.js new file mode 100644 index 000000000..c199b7b39 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Delivery Failing', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensures that Amazon CloudTrail trail log files are delivered to destination S3 bucket.', + more_info: 'Amazon CloudTrail trail logs should be delivered to destination S3 bucket to be used for security audits.', + recommended_action: 'Modify CloudTrail trail configurations so that logs are being delivered', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/userguide/how-cloudtrail-works.html', + apis: ['CloudTrail:describeTrails', 'CloudTrail:getTrailStatus'], + settings: { + trails_to_check: { + name: 'CloudTrail Trails to Check', + description: 'Comma separated string of CloudTrail trail names. If provided, only check provided trails and pass all other without checking', + regex: '^[0-9A-Za-z._-]{3,128}$', + default: '' + } + }, + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + trails_to_check: settings.trails_to_check || this.settings.trails_to_check.default + }; + + var checkProvidedOnly = (config.trails_to_check.length) ? true : false; + + config.trails_to_check = config.trails_to_check.split(','); + + async.each(regions.cloudtrail, function(region, rcb){ + + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 2, 'CloudTrail is not enabled', region); + return rcb(); + } + + async.each(describeTrails.data, function(trail, cb){ + if (!trail.TrailARN || (trail.S3BucketName && trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET)) return cb(); + + var resource = trail.TrailARN; + + if (checkProvidedOnly && trail.Name && !config.trails_to_check.includes(trail.Name)) { + helpers.addResult(results, 0, + `CloudTrail trail "${trail.Name}" is set to pass without checking logs delivery status`, + region, resource); + return cb(); + } + + var getTrailStatus = helpers.addSource(cache, source, + ['cloudtrail', 'getTrailStatus', region, trail.TrailARN]); + + if (!getTrailStatus || getTrailStatus.err || !getTrailStatus.data) { + helpers.addResult(results, 3, + `Unable to query for CloudTrail trail status: ${helpers.addError(getTrailStatus)}`, + region, resource); + return cb(); + } + + if (getTrailStatus.data.LatestDeliveryError) { + helpers.addResult(results, 2, + `Logs for CloudTrail trail "${trail.Name}" are not being delivered`, + region, resource); + } else { + helpers.addResult(results, 0, + `Logs for CloudTrail trail "${trail.Name}" are being delivered`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.spec.js b/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.spec.js new file mode 100644 index 000000000..36a1fb4e5 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailDeliveryFailing.spec.js @@ -0,0 +1,183 @@ +var expect = require('chai').expect; +const cloudtrailDeliveryFailing = require('./cloudtrailDeliveryFailing'); + +const describeTrails = [ + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-1", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getTrailStatus = [ + { + "IsLogging": true, + "LatestDeliveryTime": 1604522848.468, + "StartLoggingTime": 1604521222.552, + "LatestDeliveryAttemptTime": "2020-11-04T20:47:28Z", + "LatestNotificationAttemptTime": "", + "LatestNotificationAttemptSucceeded": "", + "LatestDeliveryAttemptSucceeded": "2020-11-04T20:47:28Z", + "TimeLoggingStarted": "2020-11-04T20:20:22Z", + "TimeLoggingStopped": "" + }, + { + "IsLogging": true, + "LatestDeliveryTime": 1604522848.468, + "StartLoggingTime": 1604521222.552, + "LatestDeliveryError": "NoSuchBucket", + "LatestDeliveryAttemptTime": "2020-11-04T20:47:28Z", + "LatestNotificationAttemptTime": "", + "LatestNotificationAttemptSucceeded": "", + "LatestDeliveryAttemptSucceeded": "2020-11-04T20:47:28Z", + "TimeLoggingStarted": "2020-11-04T20:20:22Z", + "TimeLoggingStopped": "" + } +]; + + +const createCache = (trail, status) => { + if (trail && trail.length) var trailArn = trail[0].TrailARN; + + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + data: trail + }, + }, + getTrailStatus: { + 'us-east-1': { + [trailArn]: { + data: status + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing CloudTrail trails' + }, + }, + }, + getTrailStatus: { + 'us-east-1': { + err: { + message: 'error getting CloudTrail trail status' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': null, + }, + getTrailStatus: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailDeliveryFailing', function () { + describe('run', function () { + it('should PASS if logs for CloudTrail trail are being delivered', function (done) { + const cache = createCache([describeTrails[0]], getTrailStatus[0]); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if CloudTrail trail is set to pass without checking logs delivery status', function (done) { + const cache = createCache(describeTrails, getTrailStatus[0]); + cloudtrailDeliveryFailing.run(cache, { trails_to_check: 'trail-1' }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if logs for CloudTrail trail are not being delivered', function (done) { + const cache = createCache([describeTrails[0]], getTrailStatus[1]); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe CloudTrail trails', function (done) { + const cache = createErrorCache(); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get CloudTrail trail status', function (done) { + const cache = createCache([describeTrails[0]]); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe CloudTrail trails response not found', function (done) { + const cache = createNullCache(); + cloudtrailDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailEnabled.js b/plugins/aws/cloudtrail/cloudtrailEnabled.js index a6dea402b..80636cff5 100644 --- a/plugins/aws/cloudtrail/cloudtrailEnabled.js +++ b/plugins/aws/cloudtrail/cloudtrailEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail Enabled', category: 'CloudTrail', + domain: 'Compliance', + severity: 'High', description: 'Ensures CloudTrail is enabled for all regions within an account', more_info: 'CloudTrail should be enabled for all regions in order to detect suspicious activity in regions that are not typically used.', recommended_action: 'Enable CloudTrail for all regions and ensure that at least one region monitors global service events', @@ -18,6 +20,8 @@ module.exports = { 'within environments containing cardholder data.', cis1: '2.1 Ensure CloudTrail is enabled in all regions' }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:StartLogging','cloudtrail:StopLogging','cloudtrail:DeleteTrail'], + run: function(cache, settings, callback) { var results = []; var source = {}; diff --git a/plugins/aws/cloudtrail/cloudtrailEnabled.spec.js b/plugins/aws/cloudtrail/cloudtrailEnabled.spec.js new file mode 100644 index 000000000..a29a610a7 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailEnabled.spec.js @@ -0,0 +1,198 @@ +var expect = require('chai').expect; +var cloudtrailEnabled = require('./cloudtrailEnabled'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-3", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": false, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-3", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getTrailStatus = [ + { + "IsLogging": true, + "LatestDeliveryTime": 1604522848.468, + "StartLoggingTime": 1604521222.552, + "LatestDeliveryAttemptTime": "2020-11-04T20:47:28Z", + "LatestNotificationAttemptTime": "", + "LatestNotificationAttemptSucceeded": "", + "LatestDeliveryAttemptSucceeded": "2020-11-04T20:47:28Z", + "TimeLoggingStarted": "2020-11-04T20:20:22Z", + "TimeLoggingStopped": "" + }, + { + "IsLogging": false, + "LatestDeliveryTime": 1604522848.468, + "StartLoggingTime": 1604521222.552, + "LatestDeliveryError": "NoSuchBucket", + "LatestDeliveryAttemptTime": "2020-11-04T20:47:28Z", + "LatestNotificationAttemptTime": "", + "LatestNotificationAttemptSucceeded": "", + "LatestDeliveryAttemptSucceeded": "2020-11-04T20:47:28Z", + "TimeLoggingStarted": "2020-11-04T20:20:22Z", + "TimeLoggingStopped": "" + } +] + +const createCache = (trails, getTrailStatus) => { + var trailARN = (trails && trails.length) ? trails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + }, + getTrailStatus: { + 'us-east-1': { + [trailARN]: { + data: getTrailStatus + } + } + } + } + }; +}; + +const createErrorCache = (trails) => { + var trailARN = (trails && trails.length) ? trails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + getTrailStatus: { + 'us-east-1': { + [trailARN]: { + err: { + message: 'error getting trail status' + } + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailEnabled', function () { + describe('run', function () { + it('should PASS if CloudTrail is enabled and monitoring regional and global services', function (done) { + const cache = createCache([trails[0]], getTrailStatus[0]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if CloudTrail is configured and enabled to monitor global services', function (done) { + const cache = createCache([trails[0]], getTrailStatus[0]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should PASS if CloudTrail is enabled and monitoring regional services', function (done) { + const cache = createCache([trails[2]], getTrailStatus[0]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail is configured for regional monitoring but is not logging API calls', function (done) { + const cache = createCache([trails[0]], getTrailStatus[1]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail is configured for regional monitoring but is not logging API calls', function (done) { + const cache = createCache([trails[0]], getTrailStatus[1]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail is not configured to monitor global services', function (done) { + const cache = createCache([trails[2]], getTrailStatus[1]); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailEncryption.js b/plugins/aws/cloudtrail/cloudtrailEncryption.js index 3798d7102..cebd3790a 100644 --- a/plugins/aws/cloudtrail/cloudtrailEncryption.js +++ b/plugins/aws/cloudtrail/cloudtrailEncryption.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail Encryption', category: 'CloudTrail', + domain: 'Compliance', + severity: 'High', description: 'Ensures CloudTrail encryption at rest is enabled for logs', more_info: 'CloudTrail log files contain sensitive information about an account and should be encrypted at rest for additional protection.', recommended_action: 'Enable CloudTrail log encryption through the CloudTrail console or API', @@ -12,6 +14,26 @@ module.exports = { compliance: { cis2: '2.7 Ensure CloudTrail logs are encrypted at rest using KMS CMKs' }, + remediation_description: 'Encryption for the affected Cloud trails will be enabled.', + remediation_min_version: '202010302230', + apis_remediate: ['CloudTrail:describeTrails'], + remediation_inputs: { + kmsKeyIdforCt: { + name: '(Mandatory) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: true + } + }, + actions: { + remediate: ['CloudTrail:updateTrail'], + rollback: ['CloudTrail:updateTrail'] + }, + permissions: { + remediate: ['cloudtrail:UpdateTrail'], + rollback: ['cloudtrail:UpdateTrail'] + }, + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail'], run: function(cache, settings, callback) { var results = []; @@ -34,7 +56,7 @@ module.exports = { helpers.addResult(results, 2, 'CloudTrail is not enabled', region); } else if (describeTrails.data[0]) { for (var t in describeTrails.data) { - if (describeTrails.data[t].S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) continue; + if (describeTrails.data[t].S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) continue; if (!describeTrails.data[t].KmsKeyId) { helpers.addResult(results, 2, 'CloudTrail encryption is not enabled', region, describeTrails.data[t].TrailARN); @@ -50,5 +72,55 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'cloudtrailEncryption'; + var ctNameArr = resource.split(':'); + var ctName = ctNameArr[ctNameArr.length - 1].split('/'); + // find the location of the ct needing to be remediated + + var ctLocation = ctNameArr[3]; + var err; + // add the location of the ct to the config + config.region = ctLocation; + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforCt) { + params = { + 'Name': resource, + 'KmsKeyId': settings.input.kmsKeyIdforCt, + }; + } else { + err = 'KmsKeyId is mandatory to enable encryption'; + return callback(err, null); + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'CloudTrail': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'CloudTrail': ctName + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/cloudtrail/cloudtrailEncryption.spec.js b/plugins/aws/cloudtrail/cloudtrailEncryption.spec.js new file mode 100644 index 000000000..580f43a84 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailEncryption.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +var cloudtrailEncryption = require('./cloudtrailEncryption'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const createCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + }; +}; + +const createErrorCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailEncryption', function () { + describe('run', function () { + it('should PASS if CloudTrail encryption is enabled', function (done) { + const cache = createCache([trails[0]]); + cloudtrailEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail encryption is not enabled', function (done) { + const cache = createCache([trails[1]]); + cloudtrailEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe CloudTrail response not found', function (done) { + const cache = createNullCache(); + cloudtrailEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailFileValidation.js b/plugins/aws/cloudtrail/cloudtrailFileValidation.js index 0644893e3..a390b93c7 100644 --- a/plugins/aws/cloudtrail/cloudtrailFileValidation.js +++ b/plugins/aws/cloudtrail/cloudtrailFileValidation.js @@ -4,11 +4,19 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail File Validation', category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', description: 'Ensures CloudTrail file validation is enabled for all regions within an account', more_info: 'CloudTrail file validation is essentially a hash of the file which can be used to ensure its integrity in the case of an account compromise.', recommended_action: 'Enable CloudTrail file validation for all regions', link: 'http://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-enabling.html', apis: ['CloudTrail:describeTrails'], + remediation_description: 'File validation will be enabled for the trail', + remediation_min_version: '202010311808', + apis_remediate: ['CloudTrail:describeTrails'], + actions: {remediate: ['CloudTrail:updateTrail'], rollback: ['CloudTrail:updateTrail']}, + permissions: {remediate: ['cloudtrail:UpdateTrail'], rollback: ['cloudtrail:UpdateTrail']}, + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail'], compliance: { hipaa: 'The auditing requirements of HIPAA require logs to be kept securely ' + 'in a manner that prevents tampering. CloudTrail log validation ' + @@ -38,7 +46,7 @@ module.exports = { helpers.addResult(results, 2, 'CloudTrail is not enabled', region); } else if (describeTrails.data[0]) { for (var t in describeTrails.data) { - if (describeTrails.data[t].S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) continue; + if (describeTrails.data[t].S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) continue; if (!describeTrails.data[t].LogFileValidationEnabled) { helpers.addResult(results, 2, 'CloudTrail log file validation is not enabled', region, describeTrails.data[t].TrailARN); @@ -54,5 +62,48 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'cloudtrailFileValidation'; + var trailName; + + if (resource && resource.length) { + trailName = resource.split('/')[1]; + config.region = resource.split(':')[3]; + } else { + return callback('No resource to remediate'); + } + + var params = { + 'Name': trailName, + 'EnableLogFileValidation': true + }; + + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'File Validation': 'Enabled', + 'Name': trailName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENABLED', + 'Name': trailName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailFileValidation.spec.js b/plugins/aws/cloudtrail/cloudtrailFileValidation.spec.js new file mode 100644 index 000000000..92cfb7bce --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailFileValidation.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var cloudtrailFileValidation = require('./cloudtrailFileValidation'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const createCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + }; +}; + +const createErrorCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailFileValidation', function () { + describe('run', function () { + it('should PASS if CloudTrail log file validation is enabled', function (done) { + const cache = createCache([trails[0]]); + cloudtrailFileValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail log file validation is not enabled', function (done) { + const cache = createCache([trails[1]]); + cloudtrailFileValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailFileValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailFileValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe CloudTrail response not found', function (done) { + const cache = createNullCache(); + cloudtrailFileValidation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailHasTags.js b/plugins/aws/cloudtrail/cloudtrailHasTags.js new file mode 100644 index 000000000..32c473e11 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailHasTags.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Has Tags', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Low', + description: 'Ensure that AWS CloudTrail trails have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify CloudTrail trails and add tags.', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_AddTags.html', + apis: ['CloudTrail:describeTrails', 'CloudTrail:listTags'], + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:AddTags','cloudtrail:RemoveTags','cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for CloudTrail trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 0, 'CloudTrail is not enabled', region); + return rcb(); + } + + for (let trail of describeTrails.data){ + if (!trail.TrailARN || (trail.HomeRegion && trail.HomeRegion.toLowerCase() !== region)) continue; + // Skip CloudExploit-managed events bucket + if (trail.TrailARN == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) continue; + + let listTags = helpers.addSource(cache, source, + ['cloudtrail', 'listTags', region, trail.TrailARN]); + + if (!listTags || listTags.err || !listTags.data || !listTags.data.ResourceTagList || !listTags.data.ResourceTagList.length) { + helpers.addResult(results, 3, + `Unable to list trail tags: ${helpers.addError(listTags)}`, region); + continue; + } + + if (!listTags.data.ResourceTagList[0].TagsList || + !listTags.data.ResourceTagList[0].TagsList.length){ + helpers.addResult(results, 2, 'CloudTrail trail does not have tags', region, trail.TrailARN); + } else { + helpers.addResult(results, 0, 'CloudTrail trail has tags', region, trail.TrailARN); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailHasTags.spec.js b/plugins/aws/cloudtrail/cloudtrailHasTags.spec.js new file mode 100644 index 000000000..57b3180ea --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailHasTags.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var cloudtrailHasTags = require('./cloudtrailHasTags'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, +]; + +const listTags = [ + { + ResourceTagList : [ + {TagsList: []} + ] + }, + { + ResourceTagList : [ + {TagsList: [ + {key: 'value'} + ]} + ] + } +] + +const createCache = (trails, listTags) => { + var trailARN = (trails && trails.length) ? trails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + }, + listTags: { + 'us-east-1': { + [trailARN]: { + data: listTags + } + } + } + } + }; +}; + +const createErrorCache = (trails) => { + var trailARN = (trails && trails.length) ? trails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + listTags: { + 'us-east-1': { + [trailARN]: { + err: { + message: 'error getting trail status' + } + } + } + } + }, + }; +}; + +describe('cloudtrailHasTags', function () { + describe('run', function () { + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for CloudTrail trails'); + done(); + }); + }); + it('should Passing result if cloud trail is not enabled', function (done) { + const cache = createCache([], null); + cloudtrailHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CloudTrail is not enabled'); + done(); + }); + }); + + it('should Unknown result if unable to query listTags', function (done) { + const cache = createCache([trails[0]], null); + cloudtrailHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list trail tags'); + done(); + }); + }); + + it('should Failing result if trails have no tags', function (done) { + const cache = createCache([trails[0]], listTags[0]); + cloudtrailHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CloudTrail trail does not have tags'); + done(); + }); + }); + it('should Passing result if trails have tags', function (done) { + const cache = createCache([trails[0]], listTags[1]); + cloudtrailHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CloudTrail trail has tags'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailManagementEvents.js b/plugins/aws/cloudtrail/cloudtrailManagementEvents.js new file mode 100644 index 000000000..08dbc0bae --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailManagementEvents.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Management Events', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensures that AWS CloudTrail trails are configured to log management events.', + more_info: 'AWS CloudTrail trails should be configured to log management events to record management operations that are performed on resources in your AWS account.', + recommended_action: 'Update CloudTrail to enable management events logging', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-management-events-with-cloudtrail.html', + apis: ['CloudTrail:describeTrails', 'CloudTrail:getEventSelectors'], + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:PutEventSelectors','cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 2, 'CloudTrail is not enabled', region); + return rcb(); + } + + async.each(describeTrails.data, function(trail, cb){ + if (!trail.TrailARN) return cb(); + + var resource = trail.TrailARN; + var getEventSelectors = helpers.addSource(cache, source, + ['cloudtrail', 'getEventSelectors', region, trail.TrailARN]); + + if (!getEventSelectors || getEventSelectors.err || + !getEventSelectors.data || !getEventSelectors.data.EventSelectors) { + helpers.addResult(results, 3, + `Unable to query for event selectors: ${helpers.addError(getEventSelectors)}`, region, resource); + return cb(); + } + + var managementResourceFound = false; + for (var eventSelector of getEventSelectors.data.EventSelectors){ + if (eventSelector.IncludeManagementEvents) { + managementResourceFound = true; + break; + } + } + + if (managementResourceFound) { + helpers.addResult(results, 0, + `CloudTrail trail "${trail.Name}" is configured to log management events`, + region, resource); + } else { + helpers.addResult(results, 2, + `CloudTrail trail "${trail.Name}" is not configured to log management events`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailManagementEvents.spec.js b/plugins/aws/cloudtrail/cloudtrailManagementEvents.spec.js new file mode 100644 index 000000000..c061bf14b --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailManagementEvents.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +const cloudtrailManagementEvents = require('./cloudtrailManagementEvents'); + +const describeTrails = [ + { + "Name": "test-trail", + "S3BucketName": "test-bucket-ct-1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "test-trail-1", + "S3BucketName": "test-bucket-ct", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getEventSelectors = [ + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail", + "EventSelectors": [ + { + "ReadWriteType": "All", + "IncludeManagementEvents": true, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "EventSelectors": [ + { + "ReadWriteType": "All", + "IncludeManagementEvents": false, + "DataResources": [], + "ExcludeManagementEventSources": [] + } + ] + } +]; + +const createCache = (describeTrails, getEventSelectors, describeTrailsErr, getEventSelectorsErr) => { + var trailARN = (describeTrails && describeTrails.length) ? describeTrails[0].TrailARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: describeTrailsErr, + data: describeTrails + } + }, + getEventSelectors: { + 'us-east-1': { + [trailARN]: { + err: getEventSelectorsErr, + data: getEventSelectors + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null + } + } + }; +}; + +describe('cloudtrailManagementEvents', function () { + describe('run', function () { + it('should PASS if CloudTrail trail is configured to log management events', function (done) { + const cache = createCache([describeTrails[0]], getEventSelectors[0]); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudTrail trail is not configured to log management events', function (done) { + const cache = createCache([describeTrails[1]], getEventSelectors[1]); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createCache(null, null, { message: "Unable to describe trails" }, null); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for event selectors', function (done) { + const cache = createCache([describeTrails[0]], getEventSelectors[0], null, { message: "Unable to get event selectors" }); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return any results describe trails response not found', function (done) { + const cache = createNullCache(); + cloudtrailManagementEvents.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.js b/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.js new file mode 100644 index 000000000..b9761dd36 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.js @@ -0,0 +1,93 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Notifications Enabled', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'MEDIUM', + description: 'Ensure that Amazon CloudTrail trails are using active Simple Notification Service (SNS) topics to deliver notifications.', + more_info: 'CloudTrail trails should reference active SNS topics to notify for log files delivery to S3 buckets. Otherwise, you will lose the ability to take immediate actions based on log information.', + recommended_action: 'Make sure that CloudTrail trails are using active SNS topics and that SNS topics have not been deleted after trail creation.', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/userguide/configure-sns-notifications-for-cloudtrail.html', + apis: ['CloudTrail:describeTrails', 'SNS:listTopics', 'SNS:getTopicAttributes'], + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for CloudTrail trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 0, 'No CloudTrail trails found', region); + return rcb(); + } + + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + `Unable to list SNS topics: ${helpers.addError(listTopics)}`, region); + return rcb(); + } + + var trailFound; + for (let trail of describeTrails.data) { + if (!trail.TrailARN || + (trail.HomeRegion && trail.HomeRegion.toLowerCase() != region)) continue; + + trailFound = true; + var resource = trail.TrailARN; + + if (!trail.SnsTopicARN) { + helpers.addResult(results, 2, + 'CloudTrail trail has no SNS topic attached', region, resource); + continue; + } + + var getTopicAttributes = helpers.addSource(cache, source, + ['sns', 'getTopicAttributes', region, trail.SnsTopicARN]); + + if (!getTopicAttributes) { + helpers.addResult(results, 2, + 'CloudTrail trail SNS topic not found', region, resource); + continue; + } + + if (getTopicAttributes.err || + !getTopicAttributes.data) { + helpers.addResult(results, 3, + `Unable to query for SNS topic attributes: ${helpers.addError(getTopicAttributes)}`, + region, resource); + } else { + helpers.addResult(results, 0, + 'CloudTrail trail is using active SNS topic', + region, resource); + } + } + + if (!trailFound) { + helpers.addResult(results, 0, 'No CloudTrail trails found', region); + } + + rcb(); + }, function() { + return callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.spec.js b/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.spec.js new file mode 100644 index 000000000..74ca8ec9e --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailNotificationsEnabled.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +const cloudtrailNotificationsEnabled = require('./cloudtrailNotificationsEnabled'); + +const describeTrails = [ + { + "Name": "codepipeline-source-trail", + "S3BucketName": "codepipeline-cloudtrail-placeholder-bucket-us-east-1", + "S3KeyPrefix": "cloud-trail-000011112222-06bac57c-6f83-44ce-b54e-45af1ca29746", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:000011112222:trail/codepipeline-source-trail", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-1", + "S3BucketName": "aws-logs-000011112222-us-east-1", + "S3KeyPrefix": "trail1", + "SnsTopicName": "arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1", + "SnsTopicARN": "arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:000011112222:trail/trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; +const listTopics =[ + { + "TopicArn": "arn:aws:sns:us-east-1:000011112222:Default_CloudWatch_Alarms_Topic" + }, + { + "TopicArn": "arn:aws:sns:us-east-1:000011112222:khulnasoft-cspm-sns-000011112222" + }, + { + "TopicArn": "arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1" + } +]; + +const getTopicAttributes =[ + { + "Attributes": { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__default_statement_ID\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":[\"SNS:GetTopicAttributes\",\"SNS:SetTopicAttributes\",\"SNS:AddPermission\",\"SNS:RemovePermission\",\"SNS:DeleteTopic\",\"SNS:Subscribe\",\"SNS:ListSubscriptionsByTopic\",\"SNS:Publish\"],\"Resource\":\"arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1\",\"Condition\":{\"StringEquals\":{\"AWS:SourceOwner\":\"000011112222\"}}},{\"Sid\":\"AWSCloudTrailSNSPolicy20150319\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudtrail.amazonaws.com\"},\"Action\":\"SNS:Publish\",\"Resource\":\"arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1\",\"Condition\":{\"StringEquals\":{\"AWS:SourceArn\":\"arn:aws:cloudtrail:us-east-1:000011112222:trail/trail-1\"}}}]}", + "Owner": "000011112222", + "SubscriptionsPending": "0", + "TopicArn": "arn:aws:sns:us-east-1:000011112222:aws-cloudtrail-logs-000011112222-8260eca1", + "EffectiveDeliveryPolicy": "{\"http\":{\"defaultHealthyRetryPolicy\":{\"minDelayTarget\":20,\"maxDelayTarget\":20,\"numRetries\":3,\"numMaxDelayRetries\":0,\"numNoDelayRetries\":0,\"numMinDelayRetries\":0,\"backoffFunction\":\"linear\"},\"disableSubscriptionOverrides\":false}}", + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + {} +]; + +const createCache = (describeTrails, listTopics, getTopicAttributes, describeTrailsErr, listTopicsErr, getTopicAttributesErr) => { + let arn = (describeTrails && describeTrails.length) ? describeTrails[0].SnsTopicARN : null; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: describeTrails, + err: describeTrailsErr + } + }, + }, + sns: { + listTopics: { + 'us-east-1': { + data: listTopics, + err: listTopicsErr + } + }, + getTopicAttributes: { + 'us-east-1': { + [arn]: { + data: getTopicAttributes, + err: getTopicAttributesErr + } + } + } + + } + } +}; + +describe('cloudtrailNotificationsEnabled', function () { + describe('run', function () { + it('should PASS if CloudTrail trail is using active SNS topic', function (done) { + const cache = createCache([describeTrails[1]], listTopics[2], getTopicAttributes[0]); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CloudTrail trail is using active SNS topic') + done(); + }); + }); + + it('should FAIL if CloudTrail trail has no SNS topic attached', function (done) { + const cache = createCache([describeTrails[0]], listTopics[2], null, null, null, { message: 'CloudTrail trail has no SNS topic attached'} ); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CloudTrail trail has no SNS topic attached') + done(); + }); + }); + + it('should PASS if no trail found', function (done) { + const cache = createCache([]); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No CloudTrail trails found') + done(); + }); + }); + + it('should UNKNOWN if unable to query for CloudTrail trails', function (done) { + const cache = createCache([], [], null, { message: 'Unable to query for CloudTrail trails' }); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CloudTrail trails') + done(); + }); + }); + + it('should UNKNOWN if unable to list SNS topics', function (done) { + const cache = createCache([describeTrails[1]], [], null, null, { message: 'Unable to list SNS topics' }); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list SNS topics') + done(); + }); + }); + + it('should UNKNOWN if unable to query for SNS topic attributes', function (done) { + const cache = createCache([describeTrails[1]], listTopics[1], null, null, null, { message: 'Unable to query for SNS topic attributes' }); + cloudtrailNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SNS topic attributes') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailObjectLock.js b/plugins/aws/cloudtrail/cloudtrailObjectLock.js new file mode 100644 index 000000000..a630ccd59 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailObjectLock.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Object Lock Enabled', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensures that AWS CloudTrail S3 buckets use Object Lock for data protection and regulatory compliance.', + more_info: 'CloudTrail buckets should be configured to have object lock enabled. You can use it to prevent an object from being deleted or overwritten for a fixed amount of time or indefinitely.', + recommended_action: 'Edit trail to use a bucket with object locking enabled.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-managing.html', + apis: ['CloudTrail:describeTrails', 'S3:getObjectLockConfiguration', 'S3:listBuckets'], + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.cloudtrail, function(region, rcb){ + + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + 'Unable to query for trails: ' + helpers.addError(describeTrails), region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 2, 'CloudTrail is not enabled', region); + return rcb(); + } + + async.each(describeTrails.data, function(trail, cb){ + if (!trail.S3BucketName || (trail.HomeRegion && trail.HomeRegion.toLowerCase() !== region)) return cb(); + // Skip CloudExploit-managed events bucket + if (trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) return cb(); + + var s3Region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var resource = `arn:${awsOrGov}:s3:::` + trail.S3BucketName; + + var getObjectLockConfiguration = helpers.addSource(cache, source, + ['s3', 'getObjectLockConfiguration', s3Region, trail.S3BucketName]); + + if (!getObjectLockConfiguration) { + helpers.addResult(results, 3, + 'Error querying for object lock configuration for bucket: ' + trail.S3BucketName + ': ' + helpers.addError(getObjectLockConfiguration), + region, resource); + + return cb(); + } + + if (getObjectLockConfiguration.err && + getObjectLockConfiguration.err.code && + getObjectLockConfiguration.err.code === 'ObjectLockConfigurationNotFoundError') { + helpers.addResult(results, 2, + 'Object lock is not enabled for bucket: ' + trail.S3BucketName, + region, resource); + return cb(); + } + + if (getObjectLockConfiguration.err || !getObjectLockConfiguration.data) { + helpers.addResult(results, 3, + 'Unable to query for object lock configuration for bucket: ' + trail.S3BucketName, + region, resource); + return cb(); + } + + if (getObjectLockConfiguration.data.ObjectLockConfiguration && + getObjectLockConfiguration.data.ObjectLockConfiguration.ObjectLockEnabled && + getObjectLockConfiguration.data.ObjectLockConfiguration.ObjectLockEnabled.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, + 'Object lock is enabled for bucket: ' + trail.S3BucketName, + region, resource); + } else { + helpers.addResult(results, 2, + 'Object lock is not enabled for bucket: ' + trail.S3BucketName, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudtrail/cloudtrailObjectLock.spec.js b/plugins/aws/cloudtrail/cloudtrailObjectLock.spec.js new file mode 100644 index 000000000..fe386ac79 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailObjectLock.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +var cloudtrailObjectLock = require('./cloudtrailObjectLock'); + +const trails = [ + { + "Name": "akhtar-ct3-57", + "S3BucketName": "akhtar-cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/akhtar-ct3-57", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "akhtar-ct2-57", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/akhtar-ct2-57", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const bucketObjectLockConfigurations = [ + { + ObjectLockEnabled: 'Enabled' , + Rule: { + DefaultRetention: { + Mode: 'GOVERNANCE', + Days: 1 + } + } + } +]; + +const createCache = (trails, bucketObjectLockConfigurations) => { + if (trails && trails.length) var s3BucketName = trails[0].S3BucketName; + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + s3: { + getObjectLockConfiguration: { + 'us-east-1': { + [s3BucketName]: { + data: { + ObjectLockConfiguration: bucketObjectLockConfigurations + } + } + } + } + } + }; +}; + +const createErrorCache = (trails) => { + if (trails && trails.length) { + var s3BucketName = trails[0].S3BucketName + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + s3: { + getObjectLockConfiguration: { + 'us-east-1': { + [s3BucketName]: { + err: { + code: 'ObjectLockConfigurationNotFoundError' + } + } + } + } + } + } + } + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailObjectLock', function () { + describe('run', function () { + it('should PASS if object lock is enabled for s3 bucket', function (done) { + const cache = createCache([trails[0]], bucketObjectLockConfigurations[0]); + cloudtrailObjectLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if object lock configuration does not exist for s3 bucket', function (done) { + const cache = createErrorCache([trails[1]]); + cloudtrailObjectLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailObjectLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for trails', function (done) { + const cache = createErrorCache(); + cloudtrailObjectLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for trails', function (done) { + const cache = createNullCache(); + cloudtrailObjectLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailS3Bucket.js b/plugins/aws/cloudtrail/cloudtrailS3Bucket.js new file mode 100644 index 000000000..2bd182659 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailS3Bucket.js @@ -0,0 +1,94 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail S3 Bucket', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensure that AWS CloudTrail trail uses the designated Amazon S3 bucket.', + more_info: 'Ensure that your Amazon CloudTrail trail is configured to use the appropriated S3 bucket in order to meet regulatory compliance requirements within your organization.', + recommended_action: 'Modify ClouTrail trails to configure designated S3 bucket', + link: 'https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-update-a-trail-console.html', + apis: ['CloudTrail:describeTrails'], + settings: { + trail_s3_bucket_name: { + name: 'Trail S3 Bucket Name', + description: 'Amazon S3 bucket name designated for CloudTrail trails', + regex: '^.*$', + default: '' + }, + trails_to_check: { + name: 'CloudTrail Trails to Check', + description: 'Comma separated string of CloudTrail trail names. If provided, only check provided trails and pass all other without checking', + regex: '^[0-9A-Za-z._-]{3,128}$', + default: '' + } + }, + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + trail_s3_bucket_name: settings.trail_s3_bucket_name || this.settings.trail_s3_bucket_name.default, + trails_to_check: settings.trails_to_check || this.settings.trails_to_check.default + }; + + if (!config.trail_s3_bucket_name.length) return callback(null, results, source); + + var checkProvidedOnly = (config.trails_to_check.length) ? true : false; + + config.trails_to_check = config.trails_to_check.split(','); + + async.each(regions.cloudtrail, function(region, rcb){ + + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 2, 'CloudTrail is not enabled', region); + return rcb(); + } + + async.each(describeTrails.data, function(trail, cb){ + if (!trail.TrailARN || (trail.S3BucketName && trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET)) return cb(); + + var resource = trail.TrailARN; + + if (checkProvidedOnly && trail.Name && !config.trails_to_check.includes(trail.Name)) { + helpers.addResult(results, 0, + `CloudTrail trail "${trail.Name}" is set to pass without checking S3 bucket name`, + region, resource); + return cb(); + } + + if (trail.S3BucketName && trail.S3BucketName === config.trail_s3_bucket_name) { + helpers.addResult(results, 0, + `CloudTrail trail "${trail.Name}" has correct S3 bucket configured`, + region, resource); + } else { + helpers.addResult(results, 2, + `CloudTrail trail "${trail.Name}" does not have correct S3 bucket configured`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/cloudtrailS3Bucket.spec.js b/plugins/aws/cloudtrail/cloudtrailS3Bucket.spec.js new file mode 100644 index 000000000..dda1367e5 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailS3Bucket.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +const cloudtrailS3Bucket = require('./cloudtrailS3Bucket'); + +const describeTrails = [ + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-1", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const createCache = (trail, status) => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + data: trail + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing CloudTrail trails' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': null, + } + } + }; +}; + +describe('cloudtrailS3Bucket', function () { + describe('run', function () { + it('should PASS if CloudTrail trail has correct S3 bucket configured', function (done) { + const cache = createCache([describeTrails[0]]); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'aws-cloudtrail-logs-119d2f9a' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if CloudTrail trail is set to pass without checking S3 bucket name', function (done) { + const cache = createCache(describeTrails); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'sample-bucket-123', trails_to_check: 'trail-1' }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudTrail trail does not have correct S3 bucket configured', function (done) { + const cache = createCache([describeTrails[0]]); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'sample-bucket-123' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'sample-bucket-123' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe CloudTrail trails', function (done) { + const cache = createErrorCache(); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'sample-bucket-123' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe CloudTrail trails response not found', function (done) { + const cache = createNullCache(); + cloudtrailS3Bucket.run(cache, { trail_s3_bucket_name: 'sample-bucket-123' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if S3 bucket name is not provided in settings', function (done) { + const cache = createNullCache(); + cloudtrailS3Bucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/cloudtrailToCloudwatch.js b/plugins/aws/cloudtrail/cloudtrailToCloudwatch.js index d49fc5930..14f07c635 100644 --- a/plugins/aws/cloudtrail/cloudtrailToCloudwatch.js +++ b/plugins/aws/cloudtrail/cloudtrailToCloudwatch.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'CloudTrail To CloudWatch', category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', description: 'Ensures CloudTrail logs are being properly delivered to CloudWatch', more_info: 'Sending CloudTrail logs to CloudWatch enables easy integration with AWS CloudWatch alerts, as well as an additional backup log storage location.', recommended_action: 'Enable CloudTrail CloudWatch integration for all regions', @@ -12,6 +14,7 @@ module.exports = { compliance: { cis1: '2.4 Ensure CloudTrail trails are integrated with CloudWatch Logs' }, + realtime_triggers: ['cloudtrail:CreateTrail', 'cloudtrail:UpdateTrail','cloudtrail:DeleteTrail'], run: function(cache, settings, callback) { var results = []; @@ -34,7 +37,7 @@ module.exports = { helpers.addResult(results, 2, 'CloudTrail is not enabled', region); } else if (describeTrails.data[0]) { for (var t in describeTrails.data) { - if (describeTrails.data[t].S3BucketName == helpers.CLOUDSPLOIT_EVENTS_BUCKET) continue; + if (describeTrails.data[t].S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) continue; if (!describeTrails.data[t].CloudWatchLogsLogGroupArn) { helpers.addResult(results, 2, 'CloudTrail CloudWatch integration is not enabled', region, describeTrails.data[t].TrailARN); @@ -51,4 +54,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/cloudtrail/cloudtrailToCloudwatch.spec.js b/plugins/aws/cloudtrail/cloudtrailToCloudwatch.spec.js new file mode 100644 index 000000000..02c6ccbf3 --- /dev/null +++ b/plugins/aws/cloudtrail/cloudtrailToCloudwatch.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +var cloudtrailToCloudwatch = require('./cloudtrailToCloudwatch'); + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "cloudtrail-bucket", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": true, + "CloudWatchLogsLogGroupArn": "arn:aws:logs:us-east-1:112233445566:log-group:aws-cloudtrail-logs-112233445566-5f8a5d87:*", + "CloudWatchLogsRoleArn": "arn:aws:iam::112233445566:role/service-role/CloudTrailCloudwatchRole", + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "aws-cloudtrail-logs-123456654321-test-events-690d8af2", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const createCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: trails, + }, + } + }, + }; +}; + +const createErrorCache = (trails) => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing trails' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail: { + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('cloudtrailToCloudwatch', function () { + describe('run', function () { + it('should PASS if CloudTrail CloudWatch integration is enabled', function (done) { + const cache = createCache([trails[0]]); + cloudtrailToCloudwatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail CloudWatch integration is not enabled', function (done) { + const cache = createCache([trails[1]]); + cloudtrailToCloudwatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no CloudTrail is not enabled', function (done) { + const cache = createCache([]); + cloudtrailToCloudwatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for trails', function (done) { + const cache = createErrorCache(); + cloudtrailToCloudwatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe CloudTrail response not found', function (done) { + const cache = createNullCache(); + cloudtrailToCloudwatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudtrail/globalLoggingDuplicated.js b/plugins/aws/cloudtrail/globalLoggingDuplicated.js new file mode 100644 index 000000000..27827911c --- /dev/null +++ b/plugins/aws/cloudtrail/globalLoggingDuplicated.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudTrail Global Services Logging Duplicated', + category: 'CloudTrail', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensures that AWS CloudTrail trails are not duplicating global services events in log files.', + more_info: 'Only one trail should have Include Global Services feature enabled to avoid duplication of global services events in log files.', + recommended_action: 'Update CloudTrail trails to log global services events enabled for only one trail', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/cloudtrail-integration.html', + apis: ['CloudTrail:describeTrails'], + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var globalTrails = []; + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to query for CloudTrail trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 2, 'CloudTrail is not enabled', region); + return rcb(); + } + + describeTrails.data.forEach(trail => { + if (!trail.TrailARN || (trail.S3BucketName && trail.S3BucketName == helpers.CLOUDEXPLOIT_EVENTS_BUCKET)) return; + + if (!globalTrails.includes(trail.Name) && trail.IncludeGlobalServiceEvents) { + globalTrails.push(trail.Name); + } + }); + + rcb(); + }, function(){ + if (!globalTrails.length) { + helpers.addResult(results, 2, + 'CloudTrail global services event logging is not enabled'); + } else if (globalTrails.length < 2) { + helpers.addResult(results, 0, + 'CloudTrail global services event logs are not being duplicated'); + } else { + helpers.addResult(results, 2, + 'CloudTrail global services event logs are being duplicated'); + } + + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/cloudtrail/globalLoggingDuplicated.spec.js b/plugins/aws/cloudtrail/globalLoggingDuplicated.spec.js new file mode 100644 index 000000000..8e1ea4433 --- /dev/null +++ b/plugins/aws/cloudtrail/globalLoggingDuplicated.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +const globalLoggingDuplicated = require('./globalLoggingDuplicated'); + +const describeTrails = [ + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "management-events", + "S3BucketName": "aws-cloudtrail-logs-111122223333-119d2f9a", + "IncludeGlobalServiceEvents": false, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-events2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const createCache = (trails) => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + data: trails + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': { + err: { + message: 'error describing CloudTrail trails' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + cloudtrail:{ + describeTrails: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('globalLoggingDuplicated', function () { + describe('run', function () { + it('should PASS if CloudTrail global services event logs are not being duplicated', function (done) { + const cache = createCache([describeTrails[0]]); + globalLoggingDuplicated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if CloudTrail global services event logging is not enabled', function (done) { + const cache = createCache([describeTrails[3]]); + globalLoggingDuplicated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail global services event logs are being duplicated', function (done) { + const cache = createCache([describeTrails[3]]); + globalLoggingDuplicated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if CloudTrail is not enabled', function (done) { + const cache = createCache([]); + globalLoggingDuplicated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe CloudTrail trails', function (done) { + const cache = createErrorCache(); + globalLoggingDuplicated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); + \ No newline at end of file diff --git a/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.js b/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.js new file mode 100644 index 000000000..517b9e218 --- /dev/null +++ b/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPC Flow Logs Metric Alarm', + category: 'CloudWatch', + domain: 'Compliance', + severity: 'Low', + description: 'Ensure that an AWS CloudWatch alarm exists and configured for metric filter attached with VPC flow logs CloudWatch group.', + more_info: 'A metric alarm watches a single CloudWatch metric or the result of a math expression based on CloudWatch metrics. ' + + 'The alarm performs one or more actions based on the value of the metric or expression relative to a threshold over a number of time periods. ' + + 'The action can be sending a notification to an Amazon SNS topic.', + recommended_action: 'Create a CloudWatch group, attached metric filter to log VPC flow logs changes and create an CloudWatch alarm for the metric filter.', + link: 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html', + apis: ['CloudWatchLogs:describeMetricFilters', 'CloudWatch:describeAlarms'], + settings: { + vpc_flow_log_group: { + name: 'CloudWatch VPC Flow Log Group Name', + description: 'Existing CloudWatch log group name created to log VPC flow logs', + regex: '^.*$', + default: 'vpc_flow_logs' + } + }, + realtime_triggers: ['cloudwatchlogs:PutMetricFilter','cloudwatchlogs:DeleteMetricFilter', 'cloudwatch:PutMetricAlarm','cloudwatch:DeleteAlarms'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + vpc_flow_log_group: settings.vpc_flow_log_group || this.settings.vpc_flow_log_group.default + }; + + if (!config.vpc_flow_log_group.length) return callback(null, results, source); + + async.each(regions.cloudwatchlogs, function(region, rcb){ + var describeMetricFilters = helpers.addSource(cache, source, + ['cloudwatchlogs', 'describeMetricFilters', region]); + + if (!describeMetricFilters) return rcb(); + + if (describeMetricFilters.err || !describeMetricFilters.data) { + helpers.addResult(results, 3, + `Unable to describe CloudWatch logs metric filters: ${helpers.addError(describeMetricFilters)}`, region); + return rcb(); + } + + if (!describeMetricFilters.data.length) { + helpers.addResult(results, 2, + 'No CloudWatch logs metric filters found', region); + return rcb(); + } + + let cwVpcLogGroup = describeMetricFilters.data.find(metrics => metrics.logGroupName === config.vpc_flow_log_group); + + if (!cwVpcLogGroup) { + helpers.addResult(results, 2, + 'Unable to locate the specified log group', region); + return rcb(); + } + + let metricTransformations = cwVpcLogGroup.metricTransformations && cwVpcLogGroup.metricTransformations.length? + cwVpcLogGroup.metricTransformations.map(transformation => transformation.metricName) : []; + + var describeAlarms = helpers.addSource(cache, source, + ['cloudwatch', 'describeAlarms', region]); + + if (!describeAlarms || + describeAlarms.err || !describeAlarms.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudWatch metric alarms: ' + helpers.addError(describeAlarms), region); + return rcb(); + } + + if (!describeAlarms.data.length) { + helpers.addResult(results, 2, + 'No CloudWatch metric alarms found', region); + return rcb(); + } + + let metricAlarm = describeAlarms.data.find(alarm => metricTransformations.includes(alarm.MetricName)); + + if (metricAlarm && metricAlarm.AlarmActions && metricAlarm.AlarmActions.length){ + helpers.addResult(results, 0, + 'CloudWatch alarm is configured for VPC flow logs and has an SNS topic attached for notifications', + region); + } else if (metricAlarm) { + helpers.addResult(results, 0, + 'CloudWatch alarm is configured for the VPC flow logs but has no SNS topic attached for notifications', + region); + } else { + helpers.addResult(results, 2, + 'CloudWatch alarm is not configured for the VPC flow logs', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.spec.js b/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.spec.js new file mode 100644 index 000000000..877f76967 --- /dev/null +++ b/plugins/aws/cloudwatch/vpcFlowLogsMetricAlarm.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +const vpcFlowLogsMetricAlarm = require('./vpcFlowLogsMetricAlarm'); + +const describeMetricFilters = [ + { + "filterName": "mine1", + "metricTransformations": [ + { + "metricName": "cc-vpc-flow-log-metric", + "metricNamespace": "LogMetrics", + "metricValue": "1" + } + ], + "creationTime": 1646064572235, + "logGroupName": "vpc_flow_log_group_name" + }, + { + "filterName": "mine1", + "metricTransformations": [ + { + "metricName": "cdc-vpc-flow-log-metric", + "metricNamespace": "LogMetrics", + "metricValue": "1" + } + ], + "creationTime": 1646064572235, + "logGroupName": "vpdc_flow_log_group_name" + } +]; + +const describeAlarms = [ + { + "AlarmName": "vpc_flow_log_alarm", + "AlarmArn": "arn:aws:cloudwatch:us-east-1:000011112222:alarm:vpc_flow_log_alarm", + "AlarmDescription": "Triggered by 'REJECT' packets.", + "AlarmConfigurationUpdatedTimestamp": "2022-02-28T16:16:32.485000+00:00", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [ + "arn:aws:sns:us-east-1:000011112222:cc-vpc-flow-log-notifications" + ], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "2022-02-28T16:16:32.485000+00:00", + "MetricName": "cc-vpc-flow-log-metric", + "Namespace": "LogMetrics", + "Statistic": "Sum", + "Dimensions": [], + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 1.0, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "missing" + }, + { + "AlarmName": "vpd_flow_log_alarm", + "AlarmArn": "arn:aws:cloudwatch:us-east-1:000011112222:alarm:vpd_flow_log_alarm", + "AlarmDescription": "Triggered by 'REJECT' packets.", + "AlarmConfigurationUpdatedTimestamp": "2022-02-28T16:16:32.485000+00:00", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [ + "arn:aws:sns:us-east-1:000011112222:cc-vpc-flow-log-notifications" + ], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "2022-02-28T16:16:32.485000+00:00", + "MetricName": "cdcc-vpc-flow-log-metric", + "Namespace": "LogMetrics", + "Statistic": "Sum", + "Dimensions": [], + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 1.0, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "missing" + }, +]; + +const createCache = (metrics, alarms) => { + return { + cloudwatchlogs: { + describeMetricFilters: { + 'us-east-1': { + data: metrics + }, + }, + }, + cloudwatch: { + describeAlarms: { + 'us-east-1': { + data: alarms + } + }, + }, + }; +}; + +const createErrorCache = () => { + return { + cloudwatchlogs: { + describeMetricFilters: { + 'us-east-1': { + err: { + message: 'error describing metric filters' + }, + }, + }, + cloudwatch:{ + describeAlarms: { + 'us-east-1': { + message: 'error describing metric filters alarm' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + cloudwatchlogs: { + describeMetricFilters: { + 'us-east-1': null, + }, + }, + cloudwatch: { + describeAlarms: { + 'us-east-1': null + }, + }, + }; +}; + + +describe('vpcFlowLogsMetricAlarm', function () { + describe('run', function () { + it('should PASS if CloudWatch alarm is configured for the VPC Flow Logs', function (done) { + const cache = createCache([describeMetricFilters[0]], [describeAlarms[0]]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CloudWatch alarm is configured for VPC flow logs and has an SNS topic attached for notifications') + done(); + }); + }); + + it('should FAIL if CloudWatch alarm is not configured for the VPC Flow Logs', function (done) { + const cache = createCache([describeMetricFilters[0]], [describeAlarms[1]]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CloudWatch alarm is not configured for the VPC flow logs') + done(); + }); + }); + + it('should FAIL if no CloudWatch metric alarms found', function (done) { + const cache = createCache([describeMetricFilters[0]],[]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No CloudWatch metric alarms found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for CloudWatch metric alarms', function (done) { + const cache = createCache([describeMetricFilters[0]]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CloudWatch metric alarms') + done(); + }); + }); + + it('should FAIL if Unable to locate the specified log group', function (done) { + const cache = createCache([describeMetricFilters[1]], describeAlarms[1]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Unable to locate the specified log group') + done(); + }); + }); + + it('should FAIL if No CloudWatch logs metric filters found', function (done) { + const cache = createCache([]); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No CloudWatch logs metric filters found') + done(); + }); + }); + + it('should UNKNOWN if Unable to describe CloudWatch logs metric filters', function (done) { + const cache = createErrorCache(); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe CloudWatch logs metric filters') + done(); + }); + }); + + it('should not return anything if describe CloudWatch logs metric filters response not found', function (done) { + const cache = createNullCache(); + vpcFlowLogsMetricAlarm.run(cache, { vpc_flow_log_group: 'vpc_flow_log_group_name' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if log group name is not provided in settings', function (done) { + const cache = createNullCache(); + vpcFlowLogsMetricAlarm.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/cloudwatchlogs/logGroupsEncrypted.js b/plugins/aws/cloudwatchlogs/logGroupsEncrypted.js new file mode 100644 index 000000000..28c9ca128 --- /dev/null +++ b/plugins/aws/cloudwatchlogs/logGroupsEncrypted.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudWatch Log Groups Encrypted', + category: 'CloudWatchLogs', + domain: 'Compliance', + severity: 'High', + description: 'Ensure that the CloudWatch Log groups are encrypted using desired encryption level.', + more_info: 'Log group data is always encrypted in CloudWatch Logs. You can optionally use AWS Key Management Service for this encryption. ' + + 'After you associate a customer managed key with a log group, all newly ingested data for the log group is encrypted using this key. ' + + 'This data is stored in encrypted format throughout its retention period. CloudWatch Logs decrypts this data whenever it is requested.', + recommended_action: 'Ensure CloudWatch Log groups have encryption enabled with desired AWS KMS key', + link: 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html', + apis: ['CloudWatchLogs:describeLogGroups', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + cloudwatchlog_groups_desired_encryption_level: { + name: 'CloudWatch Log Groups Target Ecryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + }, + cloudwatchlog_whitelist: { + name: 'Lambda Functions Whitelisted', + description: 'A comma-delimited list of known lambda function Function Names that should be whitelisted', + regex: '^.{1,255}$', + default: 'Khulnasoft-CSPM-Token-Rotator-Function,-CreateCSPMKeyFunction-,-TriggerDiscoveryFunction-,-GenerateVolumeScanningEx-,-GenerateCSPMExternalIdFu-' + } + }, + realtime_triggers: ['cloudwatchlogs:CreateLogGroup','cloudwatchlogs:DeleteLogGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.cloudwatchlog_groups_desired_encryption_level || this.settings.cloudwatchlog_groups_desired_encryption_level.default, + cloudwatchlog_whitelist: settings.cloudwatchlog_whitelist || this.settings.cloudwatchlog_whitelist.default + }; + + if (config.cloudwatchlog_whitelist && + config.cloudwatchlog_whitelist.length) { + config.cloudwatchlog_whitelist = config.cloudwatchlog_whitelist.split(','); + } else { + config.cloudwatchlog_whitelist = []; + } + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.cloudwatchlogs, function(region, rcb){ + var describeLogGroups = helpers.addSource(cache, source, + ['cloudwatchlogs', 'describeLogGroups', region]); + + if (!describeLogGroups) return rcb(); + + if (describeLogGroups.err || !describeLogGroups.data) { + helpers.addResult(results, 3, `Unable to query CloudWatch log groups: ${helpers.addError(describeLogGroups)}`, region); + return rcb(); + } + + if (!describeLogGroups.data.length) { + helpers.addResult(results, 0, 'No CloudWatch log groups found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let logGroup of describeLogGroups.data) { + if (!logGroup.arn) continue; + let resource = logGroup.arn; + + let whitelisted = false; + if (config.cloudwatchlog_whitelist.length) { + config.cloudwatchlog_whitelist.forEach(whitelist => { + if (resource.indexOf(whitelist) > -1) { + whitelisted = true; + } + }); + } + + if (whitelisted) { + helpers.addResult(results, 0, + 'The cloudwatch log group is whitelisted.', + region, resource); + return rcb(); + } + + if (!logGroup.kmsKeyId) { + currentEncryptionLevel = 2; //awskms + } else { + var kmsKeyId = logGroup.kmsKeyId.split('/')[1] ? logGroup.kmsKeyId.split('/')[1] : logGroup.kmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, logGroup.kmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `CloudWatch log group is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `CloudWatch log group is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudwatchlogs/logGroupsEncrypted.spec.js b/plugins/aws/cloudwatchlogs/logGroupsEncrypted.spec.js new file mode 100644 index 000000000..ddbdcb4ab --- /dev/null +++ b/plugins/aws/cloudwatchlogs/logGroupsEncrypted.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +var logGroupsEncrypted = require('./logGroupsEncrypted'); + + +const describeLogGroups = [ + { + logGroupName: 'akhtar-lg', + creationTime: 1636375012619, + metricFilterCount: 0, + arn: 'arn:aws:logs:us-east-1:000011112222:log-group:akhtar-lg:*', + storedBytes: 0, + kmsKeyId: 'arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250' + }, + { + logGroupName: 'test-lg-1', + creationTime: 1607077091876, + retentionInDays: 3, + metricFilterCount: 0, + arn: 'arn:aws:logs:us-east-1:000011112222:log-group:test-lg-1:*', + storedBytes: 0 + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (logGroups, keys, describeKey, logGroupErr, keysErr, describeKeyErr) => { + var keyId = (logGroups && logGroups.length && logGroups[0].kmsKeyId) ? logGroups[0].kmsKeyId.split('/')[1] : null; + return { + cloudwatchlogs: { + describeLogGroups: { + 'us-east-1': { + err: logGroupErr, + data: logGroups + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + + +describe('logGroupsEncrypted', function () { + describe('run', function () { + it('should PASS if CloudWatch Logs log group is encrypted with desired encryption level', function (done) { + const cache = createCache([describeLogGroups[0]], listKeys, describeKey[0]); + logGroupsEncrypted.run(cache, {cloudwatchlog_groups_desired_encryption_level :'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CloudWatch log group is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should FAIL if CloudWatch Logs log groups is not encrypted with desired encyption level', function (done) { + const cache = createCache([describeLogGroups[1]], listKeys, describeKey[1]); + logGroupsEncrypted.run(cache, { cloudwatchlog_groups_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CloudWatch log group is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if no CloudWatch Logs log groups found', function (done) { + const cache = createCache([]); + logGroupsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No CloudWatch log groups found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list CloudWatch Logs log groups', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list CloudWatch Logs log groups encryption" }); + logGroupsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + logGroupsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cloudwatchlogs/logRetentionPeriod.js b/plugins/aws/cloudwatchlogs/logRetentionPeriod.js new file mode 100644 index 000000000..36ca3d48c --- /dev/null +++ b/plugins/aws/cloudwatchlogs/logRetentionPeriod.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CloudWatch Log Retention Period', + category: 'CloudWatchLogs', + domain: 'Compliance', + severity: 'Medium', + description: 'Ensures that the CloudWatch Log retention period is set above a specified length of time.', + more_info: 'Retention settings can be used to specify how long log events are kept in CloudWatch Logs. Expired log events get deleted automatically.', + recommended_action: 'Ensure CloudWatch logs are retained for at least 90 days.', + link: 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Working-with-log-groups-and-streams.html', + apis: ['CloudWatchLogs:describeLogGroups'], + settings: { + minimum_log_retention_period: { + name: 'CloudWatch Log Minimum Retention Period', + description: 'If set, CloudWatch Logs log groups should have a retention setting greater or equal to this value', + regex: '^[0-9]*$', + default: '90' + } + }, + realtime_triggers: ['cloudwatchlogs:CreateLogGroup', 'cloudwatchlogs:PutRetentionPolicy','cloudwatchlogs:DeleteLogGroup'], + + run: function(cache, settings, callback) { + var config = { + minimum_log_retention_period: parseInt(settings.minimum_log_retention_period || this.settings.minimum_log_retention_period.default) + }; + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + async.each(regions.cloudwatchlogs, function(region, rcb){ + var describeLogGroups = helpers.addSource(cache, source, ['cloudwatchlogs', 'describeLogGroups', region]); + + if (!describeLogGroups) return rcb(); + + if (describeLogGroups.err || + !describeLogGroups.data) { + helpers.addResult(results, 3, `Unable to query CloudWatch Logs log groups: ${helpers.addError(describeLogGroups)}`, region); + return rcb(); + } + + if (!describeLogGroups.data.length) { + helpers.addResult(results, 0, 'No CloudWatch Logs log groups found', region); + return rcb(); + } + + for (let logGroup of describeLogGroups.data) { + if (logGroup.retentionInDays) { + if (logGroup.retentionInDays < config.minimum_log_retention_period) { + helpers.addResult(results, 2, + `Log group retention period of ${logGroup.retentionInDays} is less than required retention period of ${config.minimum_log_retention_period}`, region, + logGroup.arn); + } else { + helpers.addResult(results, 0, + `Log group retention period of ${logGroup.retentionInDays} is greater than or equal to the required retention period of ${config.minimum_log_retention_period}`, region, + logGroup.arn); + } + } else { + helpers.addResult(results, 0, 'Log group retention period is set to never expire', region, logGroup.arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cloudwatchlogs/logRetentionPeriod.spec.js b/plugins/aws/cloudwatchlogs/logRetentionPeriod.spec.js new file mode 100644 index 000000000..a29ddc3e5 --- /dev/null +++ b/plugins/aws/cloudwatchlogs/logRetentionPeriod.spec.js @@ -0,0 +1,85 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var logs = require('./logRetentionPeriod.js') + +const createCache = (groups) => { + return { + cloudwatchlogs: { + describeLogGroups: { + 'us-east-1': { + data: groups + } + } + } + } +}; + +describe('CloudWatch Log Retention Period', function () { + describe('run', function () { + it('should FAIL if the retention is too low', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(2); + done() + }; + + const cache = createCache([{ + "retentionInDays": 7, + "arn": "test1", + }]) + + logs.run(cache, { minimum_log_retention_period: 10 }, callback); + }); + + it('should PASS if no CloudWatch Logs log groups found', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0); + done() + }; + + const cache = createCache([]) + + logs.run(cache, { minimum_log_retention_period: 10 }, callback); + }); + + it('should PASS if a Log group retention period is set to never expire', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0); + done() + }; + + const cache = createCache([{ + "arn": "test1", + }]) + + logs.run(cache, { minimum_log_retention_period: 10 }, callback); + }); + + it('should PASS if retention period greater than the set retention period', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0); + done() + }; + + const cache = createCache([{ + "retentionInDays": 91, + "arn": "test1", + }]) + + logs.run(cache, { minimum_log_retention_period: 90 }, callback); + }); + + it('should PASS if retention period equal to the set retention period', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0); + done() + }; + + const cache = createCache([{ + "retentionInDays": 90, + "arn": "test1", + }]) + + logs.run(cache, { minimum_log_retention_period: 90 }, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/cloudwatchlogs/monitoringMetrics.js b/plugins/aws/cloudwatchlogs/monitoringMetrics.js index a843076d3..4e98dae0b 100644 --- a/plugins/aws/cloudwatchlogs/monitoringMetrics.js +++ b/plugins/aws/cloudwatchlogs/monitoringMetrics.js @@ -57,20 +57,28 @@ var filterPatterns = [ { name: 'VPC Changes', pattern: '{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }' + }, + { + name: 'Organizations Changes', + pattern: '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) }' } ]; module.exports = { title: 'CloudWatch Monitoring Metrics', category: 'CloudWatchLogs', + domain: 'Compliance', + severity: 'Medium', description: 'Ensures metric filters are setup for CloudWatch logs to detect security risks from CloudTrail.', - more_info: 'Sending CloudTrail logs to CloudWatch is only useful if metrics are setup to detect risky activity from those logs. There are numerous metrics that should be used. For the exact filter patterns, please see this plugin on GitHub: https://github.com/khulnasoft/cloudsploit/blob/master/plugins/aws/cloudwatchlogs/monitoringMetrics.js', + more_info: 'Sending CloudTrail logs to CloudWatch is only useful if metrics are setup to detect risky activity from those logs. There are numerous metrics that should be used. For the exact filter patterns, please see this plugin on GitHub: https://github.com/cloudexploit/scans/blob/master/plugins/aws/cloudwatchlogs/monitoringMetrics.js', recommended_action: 'Enable metric filters to detect malicious activity in CloudTrail logs sent to CloudWatch.', link: 'http://docs.aws.amazon.com/awscloudtrail/latest/userguide/send-cloudtrail-events-to-cloudwatch-logs.html', apis: ['CloudTrail:describeTrails', 'CloudWatchLogs:describeMetricFilters'], compliance: { cis1: '3.0 Monitoring metrics are enabled' }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:UpdateTrail','cloudtrail:DeleteTrail', 'cloudwatchlogs:PutMetricFilter','cloudwatchlogs:DeleteMetricFilter'], + run: function(cache, settings, callback) { var results = []; @@ -81,8 +89,17 @@ module.exports = { var describeTrails = helpers.addSource(cache, source, ['cloudtrail', 'describeTrails', region]); - if (!describeTrails || describeTrails.err || - !describeTrails.data || !describeTrails.data.length) { + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + `Unable to describe CloudTrail trails: ${helpers.addError(describeTrails)}`, region); + return rcb(); + } + + if (!describeTrails.data.length) { + helpers.addResult(results, 0, + 'No CloudTrail trails found', region); return rcb(); } @@ -95,7 +112,11 @@ module.exports = { } } - if (!trailsInRegion.length) return rcb(); + if (!trailsInRegion.length) { + helpers.addResult(results, 0, + 'No CloudTrail trails found in current home region', region); + return rcb(); + } var describeMetricFilters = helpers.addSource(cache, source, ['cloudwatchlogs', 'describeMetricFilters', region]); @@ -104,14 +125,12 @@ module.exports = { describeMetricFilters.err || !describeMetricFilters.data) { helpers.addResult(results, 3, 'Unable to query for CloudWatchLogs metric filters: ' + helpers.addError(describeMetricFilters), region); - return rcb(); } if (!describeMetricFilters.data.length) { helpers.addResult(results, 2, 'There are no CloudWatch metric filters in this region', region); - return rcb(); } diff --git a/plugins/aws/codeartifact/codeartifactDomainEncrypted.js b/plugins/aws/codeartifact/codeartifactDomainEncrypted.js new file mode 100644 index 000000000..80186bb0b --- /dev/null +++ b/plugins/aws/codeartifact/codeartifactDomainEncrypted.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CodeArtifact Domain Encrypted', + category: 'CodeArtifact', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that AWS CodeArtifact domains have encryption enabled with desired encryption level.', + more_info: 'CodeArtifact domains make it easier to manage multiple repositories across an organization. By default, domain assets are encrypted with AWS-managed KMS key. ' + + 'Encrypt them using customer-managed keys in order to gain more granular control over encryption/decryption process', + recommended_action: 'Encrypt CodeArtifact domains with desired encryption level', + link: 'https://docs.aws.amazon.com/codeartifact/latest/ug/domain-create.html', + apis: ['CodeArtifact:listDomains', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + codeartifact_domain_encryption_level: { + name: 'CodeArtifact Domain Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['codeartifact:CreateDomain', 'codeartifact:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.codeartifact_domain_encryption_level || this.settings.codeartifact_domain_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.codeartifact, function(region, rcb){ + var listDomains = helpers.addSource(cache, source, + ['codeartifact', 'listDomains', region]); + + if (!listDomains) return rcb(); + + if (listDomains.err || !listDomains.data) { + helpers.addResult(results, 3, + `Unable to list CodeArtifact domains: ${helpers.addError(listDomains)}`, region); + return rcb(); + } + + if (!listDomains.data.length) { + helpers.addResult(results, 0, + 'No CodeArtifact domains found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let domain of listDomains.data) { + if (!domain.arn) continue; + + let resource = domain.arn; + if (domain.encryptionKey) { + var kmsKeyId = domain.encryptionKey.split('/')[1] ? domain.encryptionKey.split('/')[1] : domain.encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, domain.encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `CodeArtifact domain is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `CodeArtifact domain is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'CodeArtifact domain does not have encryption enabled for assets', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codeartifact/codeartifactDomainEncrypted.spec.js b/plugins/aws/codeartifact/codeartifactDomainEncrypted.spec.js new file mode 100644 index 000000000..5efe70c79 --- /dev/null +++ b/plugins/aws/codeartifact/codeartifactDomainEncrypted.spec.js @@ -0,0 +1,142 @@ +var expect = require('chai').expect; +var codeartifactDomainEncrypted = require('./codeartifactDomainEncrypted'); + +const listDomains = [ + { + name: 'test-domain', + owner: '000011112222', + arn: 'arn:aws:codeartifact:us-east-1:000011112222:domain/test-domain', + status: 'Active', + encryptionKey: 'arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250' + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (domains, keys, describeKey, domainsErr, keysErr, describeKeyErr) => { + var keyId = (domains && domains.length) ? domains[0].encryptionKey.split('/')[1] : null; + return { + codeartifact: { + listDomains: { + 'us-east-1': { + err: domainsErr, + data: domains + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('codeartifactDomainEncrypted', function () { + describe('run', function () { + it('should PASS if CodeArtifact domain is encrypted with desired encryption level', function (done) { + const cache = createCache(listDomains, listKeys, describeKey[0]); + codeartifactDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CodeArtifact domain is not encrypted with desired encyption level', function (done) { + const cache = createCache(listDomains, listKeys, describeKey[1]); + codeartifactDomainEncrypted.run(cache, {codeartifact_domain_encryption_level:'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no CodeArtifact domains found', function (done) { + const cache = createCache([]); + codeartifactDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list CodeArtifact Domains', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list CodeArtifact Domains" }); + codeartifactDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDomains, null, null, null, { message: "Unable to list KMS keys" }); + codeartifactDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/codebuild/buildProjectEnvPriviligedMode.js b/plugins/aws/codebuild/buildProjectEnvPriviligedMode.js new file mode 100644 index 000000000..4ea182abc --- /dev/null +++ b/plugins/aws/codebuild/buildProjectEnvPriviligedMode.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Build Project Environment Privileged Mode', + category: 'CodeBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your AWS CodeBuild build project environment has privileged mode disabled.', + more_info: 'Enabling privileged mode for CodeBuild project environments allows the build container to have elevated permissions on the host machine, which can potentially lead to security vulnerabilities and unauthorized access.', + recommended_action: 'Modify CodeBuild build project and disable environment privileged mode.', + link: 'https://docs.aws.amazon.com/codebuild/latest/userguide/change-project-console.html', + apis: ['CodeBuild:listProjects', 'CodeBuild:batchGetProjects','STS:GetCallerIdentity'], + realtime_triggers: ['codebuild:CreateProject', 'codebuild:UpdateProject','codebuild:DeleteProject'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['STS', 'GetCallerIdentity', acctRegion, 'data']); + + async.each(regions.codebuild, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, + ['codebuild', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, + `Unable to list CodeBuild projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, + 'No CodeBuild projects found', region); + return rcb(); + } + + for (let project of listProjects.data) { + var resource = `arn:${awsOrGov}:codebuild:${region}:${accountId}:project/${project}`; + + let batchGetProjects = helpers.addSource(cache, source, + ['codebuild', 'batchGetProjects', region, project]); + + if (!batchGetProjects || batchGetProjects.err || !batchGetProjects.data || + !batchGetProjects.data.projects || !batchGetProjects.data.projects.length) { + helpers.addResult(results, 3, + `Unable to query CodeBuild project: ${helpers.addError(batchGetProjects)}`, region, resource); + } else { + if (batchGetProjects.data.projects[0] && + batchGetProjects.data.projects[0].environment && + batchGetProjects.data.projects[0].environment.privilegedMode) { + helpers.addResult(results, 2, + 'CodeBuild project environment has privileged mode enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'CodeBuild project environment has privileged mode disabled', region, resource); + } + } + + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codebuild/buildProjectEnvPriviligedMode.spec.js b/plugins/aws/codebuild/buildProjectEnvPriviligedMode.spec.js new file mode 100644 index 000000000..652a67efe --- /dev/null +++ b/plugins/aws/codebuild/buildProjectEnvPriviligedMode.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const buildProjectEnvPriviligedMode = require('./buildProjectEnvPriviligedMode'); + +const listProjects = [ + 'test-project' +]; + + +const batchGetProjects = [ + { + "projects": [ + { + "name": "test-project", + "arn": "arn:aws:codebuild:us-east-1:111122223333:project/test-project", + "environment": { + "type": "ARM_CONTAINER", + "image": "aws/codebuild/amazonlinux2-aarch64-standard:2.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + "privilegedMode": true, + "imagePullCredentialsType": "CODEBUILD" + }, + } + ], + }, + { + "projects": [ + { + "name": "test-project", + "arn": "arn:aws:codebuild:us-east-1:111122223333:project/test-project", + "environment": { + "type": "ARM_CONTAINER", + "image": "aws/codebuild/amazonlinux2-aarch64-standard:2.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + "privilegedMode": false, + "imagePullCredentialsType": "CODEBUILD" + }, + } + ], + } +] + +const createCache = (listProjects, batchGetProjects, listProjectsErr, batchGetProjectsErr) => { + let project = (listProjects && listProjects.length) ? listProjects[0] : null; + return { + codebuild: { + listProjects: { + 'us-east-1': { + data: listProjects, + err: listProjectsErr + } + }, + batchGetProjects: { + 'us-east-1': { + [project]: { + data: batchGetProjects, + err: batchGetProjectsErr + } + } + } + } + } +}; + +const createNullCache = () => { + return { + codebuild: { + listProjects: { + 'us-east-1': { 'err': 'Error listing batchProjects' }, + }, + }, + }; +}; + +describe('buildProjectEnvPriviligedMode', function () { + describe('run', function () { + + it('should PASS if no CodeBuild projects found', function (done) { + const cache = createCache([]); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No CodeBuild projects found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list codebuild project', function (done) { + const cache = createNullCache(); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list CodeBuild projects:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get CodeBuild project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeBuild project' }); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CodeBuild project:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get CodeBuild project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeBuild project' }); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CodeBuild project:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CodeBuild project environment has privileged mode enabled', function (done) { + const cache = createCache(listProjects, batchGetProjects[0], null, null); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CodeBuild project environment has privileged mode enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if CodeBuild project environment has privileged mode disabled', function (done) { + const cache = createCache(listProjects, batchGetProjects[1], null, null); + buildProjectEnvPriviligedMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CodeBuild project environment has privileged mode disabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/codebuild/codebuildProjectLoggingEnabled.js b/plugins/aws/codebuild/codebuildProjectLoggingEnabled.js new file mode 100644 index 000000000..7a89db200 --- /dev/null +++ b/plugins/aws/codebuild/codebuildProjectLoggingEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CodeBuild Project Logging Enabled', + category: 'CodeBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your AWS CodeBuild build project has S3 or Cloudwatch logs enabled.', + more_info: 'Monitoring AWS CodeBuild projects helps maintaining the reliability, availability, and performance of the resource. It helps to easily debug multi-point failure and potential incidents.', + recommended_action: 'Ensure that CodeBuild project has logging enabled.', + link: 'https://docs.aws.amazon.com/codebuild/latest/userguide/monitoring-builds.html', + apis: ['CodeBuild:listProjects', 'CodeBuild:batchGetProjects', 'STS:GetCallerIdentity'], + realtime_triggers: ['codebuild:CreateProject', 'codebuild:UpdateProject', 'codebuild:DeleteProject'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['STS', 'GetCallerIdentity', acctRegion, 'data']); + + async.each(regions.codebuild, function(region, rcb) { + var listProjects =helpers.addSource(cache, source, + ['codebuild', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, + `Unable to list CodeBuild projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, + 'No CodeBuild projects found', region); + return rcb(); + } + + for (let project of listProjects.data) { + var resource = `arn:${awsOrGov}:codebuild:${region}:${accountId}:project/${project}`; + + let batchGetProjects = helpers.addSource(cache, source, + ['codebuild', 'batchGetProjects', region, project]); + + if (!batchGetProjects || batchGetProjects.err || !batchGetProjects.data || + !batchGetProjects.data.projects || !batchGetProjects.data.projects.length) { + helpers.addResult(results, 3, + `Unable to query CodeBuild project: ${helpers.addError(batchGetProjects)}`, region, resource); + } else { + + var found = (batchGetProjects.data.projects[0] && + batchGetProjects.data.projects[0].logsConfig && + Object.values(batchGetProjects.data.projects[0].logsConfig).some(log => log.status === 'ENABLED')) || false; + + if (found) { + helpers.addResult(results, 0, + 'CodeBuild project has logging enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'CodeBuild project does not have logging enabled', region, resource); + } + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codebuild/codebuildProjectLoggingEnabled.spec.js b/plugins/aws/codebuild/codebuildProjectLoggingEnabled.spec.js new file mode 100644 index 000000000..8a4f59270 --- /dev/null +++ b/plugins/aws/codebuild/codebuildProjectLoggingEnabled.spec.js @@ -0,0 +1,163 @@ +var expect = require('chai').expect; +const codebuildProjectLoggingEnabled = require('./codebuildProjectLoggingEnabled'); + +const listProjects = [ + 'test-project' +]; + + +const batchGetProjects = [ + { + "projects": [ + { + "name": "test-project", + "arn": "arn:aws:codebuild:us-east-1:111122223333:project/test-project", + "environment": { + "type": "ARM_CONTAINER", + "image": "aws/codebuild/amazonlinux2-aarch64-standard:2.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + "privilegedMode": true, + "imagePullCredentialsType": "CODEBUILD" + }, + "logsConfig": { + "cloudWatchLogs": { + "status": "DISABLED", + }, + "s3Logs": { + "status": "DISABLED", + "encryptionDisabled": false, + }, + } + } + ], + }, + { + "projects": [ + { + "name": "test-project", + "arn": "arn:aws:codebuild:us-east-1:111122223333:project/test-project", + "environment": { + "type": "ARM_CONTAINER", + "image": "aws/codebuild/amazonlinux2-aarch64-standard:2.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + "privilegedMode": false, + "imagePullCredentialsType": "CODEBUILD" + }, + "logsConfig": { + "cloudWatchLogs": { + "status": "ENABLED", + }, + "s3Logs": { + "status": "DISABLED", + "encryptionDisabled": false, + }, + } + } + ], + } +] + +const createCache = (listProjects, batchGetProjects, listProjectsErr, batchGetProjectsErr) => { + let project = (listProjects && listProjects.length) ? listProjects[0] : null; + return { + codebuild: { + listProjects: { + 'us-east-1': { + data: listProjects, + err: listProjectsErr + } + }, + batchGetProjects: { + 'us-east-1': { + [project]: { + data: batchGetProjects, + err: batchGetProjectsErr + } + } + } + } + } +}; + +const createNullCache = () => { + return { + codebuild: { + listProjects: { + 'us-east-1': { 'err': 'Error listing batchProjects' }, + }, + }, + }; +}; + +describe('codebuildProjectLoggingEnabled', function () { + describe('run', function () { + + it('should PASS if no CodeBuild projects found', function (done) { + const cache = createCache([]); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No CodeBuild projects found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list codebuild project', function (done) { + const cache = createNullCache(); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list CodeBuild projects:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get CodeBuild project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeBuild project' }); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CodeBuild project:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get CodeBuild project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeBuild project' }); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CodeBuild project:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CodeBuild project does not have logging enabled', function (done) { + const cache = createCache(listProjects, batchGetProjects[0], null, null); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CodeBuild project does not have logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if CodeBuild project has logging enabled', function (done) { + const cache = createCache(listProjects, batchGetProjects[1], null, null); + codebuildProjectLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CodeBuild project has logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/codebuild/codebuildValidSourceProviders.js b/plugins/aws/codebuild/codebuildValidSourceProviders.js new file mode 100644 index 000000000..270f389ca --- /dev/null +++ b/plugins/aws/codebuild/codebuildValidSourceProviders.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CodeBuild Valid Source Providers', + category: 'CodeBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that CodeBuild projects are using only valid source providers.', + more_info: 'CodeBuild should use only desired source providers in order to follow your organizations\'s security and compliance requirements.', + link: 'https://docs.aws.amazon.com/codebuild/latest/APIReference/API_ProjectSource.html', + recommended_action: 'Edit CodeBuild project source provider information and remove disallowed source providers', + apis: ['CodeBuild:listProjects', 'CodeBuild:batchGetProjects', 'STS:getCallerIdentity'], + settings: { + codebuild_disallowed_source_providers: { + name: 'CodeBuild Disallowed Source Providers', + description: 'A comma-separated list of source providers which should not be used', + regex: '^((bitbucket|codecommit|codepipeline|github|github_enterprise|s3|),? ?){1,5}$', + default: '' + } + }, + realtime_triggers: ['codebuild:CreateProject', 'codebuild:UpdateProject','codebuild:DeleteProject'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + codebuild_disallowed_source_providers: settings.codebuild_disallowed_source_providers || this.settings.codebuild_disallowed_source_providers.default + }; + + if (!config.codebuild_disallowed_source_providers.length) return callback(null, results, source); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.codebuild, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, ['codebuild', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, `Unable to query CodeBuild projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, 'No CodeBuild projects found', region); + return rcb(); + } + + async.each(listProjects.data, function(project, cb) { + var resource = `arn:${awsOrGov}:codebuild:${region}:${accountId}:project/${project}`; + + var batchGetProjects = helpers.addSource(cache, source, ['codebuild', 'batchGetProjects', region, project]); + + if (!batchGetProjects || batchGetProjects.err || + !batchGetProjects.data || !batchGetProjects.data.projects || !batchGetProjects.data.projects.length) { + helpers.addResult(results, 3, + `Unable to query CodeBuild project: ${helpers.addError(batchGetProjects)}`, region, resource); + return cb(); + } + + var invalidSources = []; + if (batchGetProjects.data.projects[0].source && + batchGetProjects.data.projects[0].source.type && + config.codebuild_disallowed_source_providers.includes(batchGetProjects.data.projects[0].source.type.toLowerCase())) + invalidSources.push(batchGetProjects.data.projects[0].source.type.toLowerCase()); + + if (batchGetProjects.data.projects[0].secondarySources && + batchGetProjects.data.projects[0].secondarySources.length) { + for (let source of batchGetProjects.data.projects[0].secondarySources) { + var sourceLower = source.type.toLowerCase(); + if (config.codebuild_disallowed_source_providers.includes(sourceLower) && !invalidSources.includes(sourceLower)) invalidSources.push(sourceLower); + } + } + + if (invalidSources.length) { + helpers.addResult(results, 2, + `CodeBuild project is using these disallowed source providers: ${invalidSources.join(', ')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'CodeBuild project is using allowed source providers', region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codebuild/codebuildValidSourceProviders.spec.js b/plugins/aws/codebuild/codebuildValidSourceProviders.spec.js new file mode 100644 index 000000000..1e509942b --- /dev/null +++ b/plugins/aws/codebuild/codebuildValidSourceProviders.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +const codebuildValidSourceProviders = require('./codebuildValidSourceProviders'); + +const listProjects = [ + 'test-project' +]; + + +const batchGetProjects ={ + "projects": [ + { + "name": "test-project", + "arn": "arn:aws:codebuild:us-east-1:111122223333:project/test-project", + "source": { + "type": "GITHUB", + "location": "https://github.com/cloudsplit/scans", + "gitCloneDepth": 1, + "gitSubmodulesConfig": { + "fetchSubmodules": false + }, + "reportBuildStatus": false, + "insecureSsl": false + }, + "secondarySources": [ + { + "type": "S3", + "location": "my-khulnasoft-bucket/data", + "insecureSsl": false, + "sourceIdentifier": "s3_source" + } + ] + } + ], +}; + +const createCache = (listProjects, batchGetProjects, listProjectsErr, batchGetProjectsErr) => { + let project = (listProjects && listProjects.length) ? listProjects[0] : null; + return { + codebuild: { + listProjects: { + 'us-east-1': { + data: listProjects, + err: listProjectsErr + } + }, + batchGetProjects: { + 'us-east-1': { + [project]: { + data: batchGetProjects, + err: batchGetProjectsErr + } + } + } + } + } +}; + +const createNullCache = () => { + return { + codebuild: { + listProjects: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('codebuildValidSourceProviders', function () { + describe('run', function () { + it('should PASS if CodeBuild project is using allowed source providers', function (done) { + const cache = createCache(listProjects, batchGetProjects); + codebuildValidSourceProviders.run(cache, { codebuild_disallowed_source_providers: 'bitbucket'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CodeBuild project is using disallowed source providers', function (done) { + const cache = createCache(listProjects, batchGetProjects); + codebuildValidSourceProviders.run(cache, { codebuild_disallowed_source_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no CodeBuild projects found', function (done) { + const cache = createCache([]); + codebuildValidSourceProviders.run(cache, { codebuild_disallowed_source_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query CodeBuild projects', function (done) { + const cache = createCache(listProjects, { message: 'Unable to query CodeBuild projects' }); + codebuildValidSourceProviders.run(cache, { codebuild_disallowed_source_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query CodeBuild project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeBuild project' }); + codebuildValidSourceProviders.run(cache, { codebuild_disallowed_source_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/codebuild/projectArtifactsEncrypted.js b/plugins/aws/codebuild/projectArtifactsEncrypted.js new file mode 100644 index 000000000..7cc111417 --- /dev/null +++ b/plugins/aws/codebuild/projectArtifactsEncrypted.js @@ -0,0 +1,121 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Project Artifacts Encrypted', + category: 'CodeBuild', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that your AWS CodeBuild project artifacts are encrypted with desired encryption level.', + more_info: 'AWS CodeBuild encrypts artifacts such as a cache, logs, exported raw test report data files, and build results '+ + 'by default using AWS managed keys. Use customer-managed key instead, in order to to gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt them using customer-managed keys to gain more control over data encryption and decryption process.', + link: 'https://docs.aws.amazon.com/codebuild/latest/userguide/security-encryption.html', + apis: ['CodeBuild:listProjects', 'CodeBuild:batchGetProjects', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + project_artifacts_desired_encryption_level: { + name: 'Project Artifacts Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['codebuild:CreateProject', 'codebuild:UpdateProject','codebuild:DeleteProject'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.project_artifacts_desired_encryption_level || this.settings.project_artifacts_desired_encryption_level.default + }; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.codebuild, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, + ['codebuild', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, + `Unable to list CodeBuild projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, + 'No CodeBuild projects found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let project of listProjects.data) { + var resource = `arn:${awsOrGov}:codebuild:${region}:${accountId}:project/${project}`; + + let batchGetProjects = helpers.addSource(cache, source, + ['codebuild', 'batchGetProjects', region, project]); + + if (!batchGetProjects || batchGetProjects.err || !batchGetProjects.data || + !batchGetProjects.data.projects || !batchGetProjects.data.projects.length) { + helpers.addResult(results, 3, + `Unable to query CodeBuild project: ${helpers.addError(batchGetProjects)}`, region, resource); + continue; + } + + if (batchGetProjects.data.projects[0].encryptionKey && batchGetProjects.data.projects[0].encryptionKey.includes('alias/aws/s3')) { + currentEncryptionLevel = 2; //awskms + } else if (batchGetProjects.data.projects[0].encryptionKey) { + let kmsKeyArn = batchGetProjects.data.projects[0].encryptionKey; + var kmsKeyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `CodeBuild project artifacts are encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `CodeBuild project artifacts are encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codebuild/projectArtifactsEncrypted.spec.js b/plugins/aws/codebuild/projectArtifactsEncrypted.spec.js new file mode 100644 index 000000000..080bfa327 --- /dev/null +++ b/plugins/aws/codebuild/projectArtifactsEncrypted.spec.js @@ -0,0 +1,201 @@ +var expect = require('chai').expect; +var projectArtifactsEncrypted = require('./projectArtifactsEncrypted'); + +const listProjects = [ + "testproj" +]; + +const batchGetProjects = [ + { + "projects": [ + { + 'name': 'testproj', + 'arn': 'arn:aws:codebuild:us-east-1:000011112222:project/testproj', + 'source': [Object], + 'secondarySources': [], + 'secondarySourceVersions': [], + 'artifacts': [Object], + 'secondaryArtifacts': [], + 'cache': [Object], + 'environment': [Object], + 'serviceRole': 'arn:aws:iam::000011112222:role/service-role/codebuild-testproj-service-role', + 'timeoutInMinutes': '60', + 'queuedTimeoutInMinutes': '480', + 'encryptionKey': 'arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250', + 'tags': [], + 'created': '2021-11-09T10:57:02.957Z', + 'lastModified': '2021-11-09T10:57:02.957Z', + 'badge': [Object], + 'logsConfig': [Object], + 'projectVisibility': 'PRIVATE' + }, + ] + }, + { + "projects": [ + { + 'name': 'testproj', + 'arn': 'arn:aws:codebuild:us-east-1:000011112222:project/testproj', + 'source': [Object], + 'secondarySources': [], + 'secondarySourceVersions': [], + 'artifacts': [Object], + 'secondaryArtifacts': [], + 'cache': [Object], + 'environment': [Object], + 'serviceRole': 'arn:aws:iam::000011112222:role/service-role/codebuild-testproj-service-role', + 'timeoutInMinutes': '60', + 'queuedTimeoutInMinutes': '480', + 'encryptionKey': 'alias/aws/s3', + 'tags': [], + 'created': '2021-11-09T10:57:02.957Z', + 'lastModified': '2021-11-09T10:57:02.957Z', + 'badge': [Object], + 'logsConfig': [Object], + 'projectVisibility': 'PRIVATE' + }, + ] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (listProjects, keys, batchGetProjects, describeKey, listProjectsErr, keysErr, describeKeyErr, batchGetProjectsErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + var projectName = (listProjects && listProjects.length) ? listProjects[0]: null; + return { + codebuild: { + listProjects: { + 'us-east-1': { + err: listProjectsErr, + data: listProjects + }, + }, + batchGetProjects: { + 'us-east-1': { + [projectName]: { + data: batchGetProjects, + err: batchGetProjectsErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('projectArtifactsEncrypted', function () { + describe('run', function () { + it('should PASS if CodeBuild project artifact is encrypted with desired encryption level', function (done) { + const cache = createCache(listProjects, listKeys, batchGetProjects[0], describeKey[0]); + projectArtifactsEncrypted.run(cache, { projects_artifact_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CodeBuild project artifacts are encrypted with awscmk'); + done(); + }); + }); + + it('should FAIL if CodeBuild project artifact is not encrypted with desired encryption level', function (done) { + const cache = createCache(listProjects, listKeys, batchGetProjects[1], describeKey[1]); + projectArtifactsEncrypted.run(cache, { project_artifacts_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CodeBuild project artifacts are encrypted with awskms'); + done(); + }); + }); + + it('should PASS if No CodeBuild projects artifact found', function (done) { + const cache = createCache([]); + projectArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No CodeBuild projects found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list project artifacts', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list project artifacts" }); + projectArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listProjects, null, null, null, { message: "Unable to list KMS keys" }); + projectArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/codepipeline/pipelineArtifactsEncrypted.js b/plugins/aws/codepipeline/pipelineArtifactsEncrypted.js new file mode 100644 index 000000000..c757e6302 --- /dev/null +++ b/plugins/aws/codepipeline/pipelineArtifactsEncrypted.js @@ -0,0 +1,150 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Pipeline Artifacts Encrypted', + category: 'CodePipeline', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that AWS CodePipeline is using desired encryption level to encrypt pipeline artifacts being stored in S3.', + more_info: 'CodePipeline creates an S3 artifact bucket and default AWS managed key when you create a pipeline.'+ + 'By default, these artifacts are encrypted using default AWS-managed S3 key. Use customer-managed key for encryption in order to to gain more granular control over encryption/decryption process.', + recommended_action: 'Ensure customer-manager keys (CMKs) are being used for CodePipeline pipeline artifacts.', + link: 'https://docs.aws.amazon.com/codepipeline/latest/userguide/S3-artifact-encryption.html', + apis: ['CodePipeline:listPipelines','CodePipeline:getPipeline', 'KMS:describeKey', + 'KMS:listKeys', 'STS:getCallerIdentity', 'KMS:listAliases'], + settings: { + pipeline_artifacts_desired_encryption_level: { + name: 'Pipeline Artifacts Desired Encrypted Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['codepipeline:CreatePipeline','codepipeline:DeletePipeline'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.pipeline_artifacts_desired_encryption_level || this.settings.pipeline_artifacts_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.codepipeline, function(region, rcb){ + var listPipelines = helpers.addSource(cache, source, + ['codepipeline', 'listPipelines', region]); + + if (!listPipelines) return rcb(); + + if (listPipelines.err || !listPipelines.data) { + helpers.addResult(results, 3, + 'Unable to query Pipeline Artifacts: ' + helpers.addError(listPipelines), region); + return rcb(); + } + + if (!listPipelines.data.length) { + helpers.addResult(results, 0, 'No Pipeline Artifacts found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasArn] = keyArn; + }); + + for (let pipeline of listPipelines.data) { + let resource = `arn:${awsOrGov}:codepipeline:${region}:${accountId}:${pipeline.name}`; + + var getPipeline = helpers.addSource(cache, source, + ['codepipeline', 'getPipeline', region, pipeline.name]); + + if (!getPipeline || getPipeline.err || !getPipeline.data ) { + helpers.addResult(results, 3, + `Unable to get Pipeline Artifacts description: ${helpers.addError(getPipeline)}`, + region, resource); + continue; + } + + if (getPipeline.data.pipeline && + getPipeline.data.pipeline.artifactStore && + getPipeline.data.pipeline.artifactStore.encryptionKey && + getPipeline.data.pipeline.artifactStore.encryptionKey.id) { + var kmsKey = getPipeline.data.pipeline.artifactStore.encryptionKey.id; + var kmsKeyArn = (kmsAliasArnMap[kmsKey]) ? kmsAliasArnMap[kmsKey] : null; + + if (!kmsKeyArn) { + helpers.addResult(results, 3, + 'Unable to get Key Id for KMS Key Arn', + region, kmsKey); + continue; + } + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel=2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Pipeline artifacts are encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Pipeline artifacts are encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/codepipeline/pipelineArtifactsEncrypted.spec.js b/plugins/aws/codepipeline/pipelineArtifactsEncrypted.spec.js new file mode 100644 index 000000000..9d4f18ffb --- /dev/null +++ b/plugins/aws/codepipeline/pipelineArtifactsEncrypted.spec.js @@ -0,0 +1,186 @@ +var expect = require('chai').expect; +var pipelineArtifactsEncrypted = require('./pipelineArtifactsEncrypted'); + +const listPipelines = [ + { + "name": "sad", + "version": 1, + "created": "2021-11-22T21:03:15.001000+05:00", + "updated": "2021-11-22T21:03:15.001000+05:00" + }, +]; + + +const getPipeline = [ + { + "pipeline": { + "name": "sad", + "roleArn": "arn:aws:iam::000111222333:role/service-role/AWSCodePipelineServiceRole-us-east-1-sad", + "artifactStore": { + "type": "S3", + "location": "codepipeline-us-east-1-347340132483", + "encryptionKey": { + "id": "arn:aws:kms:us-east-1:000111222333:alias/sadeed-k1", + "type": "KMS" + } + }, + } + } + +]; + +const listAliases = [ + { + "AliasName": "alias/sadeed-k1", + "AliasArn": "arn:aws:kms:us-east-1:000111222333:alias/sadeed-k1", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2021-11-15T17:05:31.308000+05:00", + "LastUpdatedDate": "2021-11-15T17:05:31.308000+05:00" + }, +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (pipelines, keys, kmsAliases, getPipeline, describeKey, pipelinesErr, kmsAliasesErr, keysErr, describeKeyErr, getPipelineErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var name = (pipelines && pipelines.length) ? pipelines[0].name: null; + return { + codepipeline: { + listPipelines: { + 'us-east-1': { + err: pipelinesErr, + data: pipelines + }, + }, + getPipeline: { + 'us-east-1': { + [name]: { + data: getPipeline, + err: getPipelineErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('pipelineArtifactsEncrypted', function () { + describe('run', function () { + it('should PASS if Pipeline Artifacts is encrypted with desired encryption level', function (done) { + const cache = createCache([listPipelines[0]], listKeys, listAliases, getPipeline[0], describeKey[0]); + pipelineArtifactsEncrypted.run(cache, { pipeline_artifacts_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Pipeline Artifacts not encrypted with desired encryption level', function (done) { + const cache = createCache([listPipelines[0]], listKeys, listAliases, getPipeline[0], describeKey[1]); + pipelineArtifactsEncrypted.run(cache, { pipeline_artifacts_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Pipeline Artifacts found', function (done) { + const cache = createCache([]); + pipelineArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Pipeline Artifacts', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Pipeline Artifacts" }); + pipelineArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listPipelines, null, null, null, { message: "Unable to list KMS keys" }); + pipelineArtifactsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/codestar/codestarHasTags.js b/plugins/aws/codestar/codestarHasTags.js new file mode 100644 index 000000000..e4c78ce9c --- /dev/null +++ b/plugins/aws/codestar/codestarHasTags.js @@ -0,0 +1,48 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CodeStar Has Tags', + category: 'CodeStar', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures that CodeStar projects has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/codestar/latest/userguide/working-with-project-tags.html', + recommended_action: 'Modify CodeStar Project and add tags.', + apis: ['CodeStar:listProjects','ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['codestar:CreateProject','codestar:DeleteProject', 'codestar:tagresource', 'codestar:untagresource'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.codestar, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, ['codestar', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, `Unable to query CodeStar projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, 'No CodeStar projects found', region); + return rcb(); + } + + const arnList = []; + for (let project of listProjects.data){ + arnList.push(project.projectArn); + } + + helpers.checkTags(cache, 'CodeStar', arnList, region, results, settings); + return rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/codestar/codestarHasTags.spec.js b/plugins/aws/codestar/codestarHasTags.spec.js new file mode 100644 index 000000000..14f47e999 --- /dev/null +++ b/plugins/aws/codestar/codestarHasTags.spec.js @@ -0,0 +1,112 @@ +var expect = require('chai').expect; +const codestarHasTags = require('./codestarHasTags'); + +const listProjects = [ + { + "projectId": "khulnasoft-project", + "projectArn": "arn:aws:codestar:us-east-1:111222333444:project/khulnasoft-project" + } +]; + +const getResources = [ + { + "ResourceARN": "arn:aws:codestar:us-east-1:111222333444:project/khulnasoft-project", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:codestar:us-east-1:111222333444:project/khulnasoft-project", + "Tags": [{key: 'value'}], + } +] + + +const createCache = (listProjects, rgData) => { + return { + codestar: { + listProjects: { + 'us-east-1': { + err: null, + data: listProjects + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + +const createNullCache = () => { + return { + codestar: { + listProjects: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('codestarHasTags', function () { + describe('run', function () { + it('should PASS if CodeStar project has tags', function (done) { + const cache = createCache([listProjects[0]], [getResources[1]]); + codestarHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CodeStar has tags') + done(); + }); + }); + + it('should FAIL if CodeStar project doesnot have tags', function (done) { + const cache = createCache([listProjects[0]], [getResources[0]]); + codestarHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('CodeStar does not have any tags') + done(); + }); + }); + + it('should PASS if no CodeStar projects found', function (done) { + const cache = createCache([]); + codestarHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No CodeStar projects found') + done(); + }); + }); + + it('should UNKNOWN if unable to query CodeStar project', function (done) { + const cache = createCache(null, null); + codestarHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query CodeStar projects: ') + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listProjects[0]],null); + codestarHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/codestar/codestarValidRepoProviders.js b/plugins/aws/codestar/codestarValidRepoProviders.js new file mode 100644 index 000000000..0af8a6b63 --- /dev/null +++ b/plugins/aws/codestar/codestarValidRepoProviders.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'CodeStar Valid Repository Providers', + category: 'CodeStar', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that CodeStar projects are not using undesired repository providers.', + more_info: 'CodeStar should use only allowed repository providers in order to follow your organizations\'s security and compliance requirements.', + link: 'https://docs.aws.amazon.com/codestar/latest/userguide/getting-started.html#getting-started-create', + recommended_action: 'Ensure diallowed repository providers are not being used for CodeStar projects', + apis: ['CodeStar:listProjects', 'CodeStar:describeProject'], + settings: { + codestar_disallowed_repo_providers: { + name: 'CodeStar Disallowed Repository Providers', + description: 'A comma-separated list of repository providers which should not be used', + regex: '^((codecommit|github|),? ?){1,2}$', + default: '' + } + }, + realtime_triggers: ['codestar:CreateProject','codestar:DeleteProject'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + codestar_disallowed_repo_providers: settings.codestar_disallowed_repo_providers || this.settings.codestar_disallowed_repo_providers.default + }; + + if (!config.codestar_disallowed_repo_providers.length) return callback(null, results, source); + + async.each(regions.codestar, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, ['codestar', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, `Unable to query CodeStar projects: ${helpers.addError(listProjects)}`, region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, 'No CodeStar projects found', region); + return rcb(); + } + + async.each(listProjects.data, function(project, cb) { + if (!project.projectId) return cb(); + + var describeProject = helpers.addSource(cache, source, ['codestar', 'describeProject', region, project.projectId]); + + if (!describeProject || describeProject.err || !describeProject.data || !describeProject.data.projectTemplateId) { + helpers.addResult(results, 3, + `Unable to query CodeStar project: ${helpers.addError(describeProject)}`, region, project.projectId); + return cb(); + } + + let repoProvider = (describeProject.data.projectTemplateId.split('/').length > 1) ? + describeProject.data.projectTemplateId.split('/')[1] : ''; + + if (config.codestar_disallowed_repo_providers.includes(repoProvider)) { + helpers.addResult(results, 2, + `CodeStar project is using ${repoProvider} as repository provider which should not be used`, + region, project.projectArn); + } else { + helpers.addResult(results, 0, + `CodeStar project is using ${repoProvider} as repository provider`, + region, project.projectArn); + } + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/codestar/codestarValidRepoProviders.spec.js b/plugins/aws/codestar/codestarValidRepoProviders.spec.js new file mode 100644 index 000000000..3dcac27d1 --- /dev/null +++ b/plugins/aws/codestar/codestarValidRepoProviders.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +const codestarValidRepoProviders = require('./codestarValidRepoProviders'); + +const listProjects = [ + { + "projectId": "khulnasoft-project", + "projectArn": "arn:aws:codestar:us-east-1:111222333444:project/khulnasoft-project" + } +]; + + +const describeProject = { + "name": "khulnasoft-project", + "id": "khulnasoft-project", + "arn": "arn:aws:codestar:us-east-1:000111222333:project/khulnasoft-project", + "description": "AWS CodeStar created project", + "createdTimeStamp": 1617967855.647, + "projectTemplateId": "arn:aws:codestar:us-east-1::project-template/github/webapp-nodeweb-lambda", +}; + +const createCache = (listProjects, describeProject, listProjectsErr, describeProjectErr) => { + let project = (listProjects && listProjects.length) ? listProjects[0].projectId : null; + return { + codestar: { + listProjects: { + 'us-east-1': { + data: listProjects, + err: listProjectsErr + } + }, + describeProject: { + 'us-east-1': { + [project]: { + data: describeProject, + err: describeProjectErr + } + } + } + } + } +}; + +const createNullCache = () => { + return { + codestar: { + listProjects: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('codestarValidRepoProviders', function () { + describe('run', function () { + it('should PASS if CodeStar project is using allowed repo providers', function (done) { + const cache = createCache(listProjects, describeProject); + codestarValidRepoProviders.run(cache, { codestar_disallowed_repo_providers: 'codecommit'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CodeStar project is not using allowed source providers', function (done) { + const cache = createCache(listProjects, describeProject); + codestarValidRepoProviders.run(cache, { codestar_disallowed_repo_providers: 'github' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no CodeStar projects found', function (done) { + const cache = createCache([]); + codestarValidRepoProviders.run(cache, { codestar_disallowed_repo_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query CodeStar projects', function (done) { + const cache = createCache(listProjects, { message: 'Unable to query CodeStar projects' }); + codestarValidRepoProviders.run(cache, { codestar_disallowed_repo_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query CodeStar project', function (done) { + const cache = createCache(listProjects, null, null, { message: 'Unable to query CodeStar project' }); + codestarValidRepoProviders.run(cache, { codestar_disallowed_repo_providers: 's3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/cognito/cognitoHasWafEnabled.js b/plugins/aws/cognito/cognitoHasWafEnabled.js new file mode 100644 index 000000000..0f255175b --- /dev/null +++ b/plugins/aws/cognito/cognitoHasWafEnabled.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Cognito User Pool WAF Enabled', + category: 'Cognito', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that Cognito User Pool has WAF enabled.', + more_info: 'Enabling WAF allows control over unwanted requests to your hosted UI and Amazon Cognito API service endpoints, allowing or denying traffic based off rules in the Web ACL.', + link: 'https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-waf.html', + recommended_action: '1. Enter the Cognito service. 2. Enter user pools and enable WAF from properties.', + apis: ['CognitoIdentityServiceProvider:listUserPools', 'WAFV2:getWebACLForCognitoUserPool', 'STS:getCallerIdentity'], + realtime_triggers: ['cognitoidentityserviceprovider:CreateUserPool','cognitoidentityserviceprovider:DeleteUserPool','wafv2:AssociateWebACL','wafv2:DisassociateWebACL'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.cognitoidentityserviceprovider, function(region, rcb) { + var userPools = helpers.addSource(cache, source, + ['cognitoidentityserviceprovider', 'listUserPools', region]); + + if (!userPools) return rcb(); + + if (userPools.err || !userPools.data){ + helpers.addResult(results, 3, 'Unable to query Cognito user pools: ' + helpers.addError(userPools), region); + return rcb(); + } + + if (!userPools.data.length){ + helpers.addResult(results, 0, 'No Cognito user pools found', region); + return rcb(); + } + + for (let userPool of userPools.data) { + if (!userPool.Id) continue; + + var arn = 'arn:' + awsOrGov + ':cognito-idp:' + region + ':' + accountId + ':userpool/' + userPool.Id; + + var webACLResource = helpers.addSource(cache, source, + ['wafv2', 'getWebACLForCognitoUserPool', region, userPool.Id]); + + if (!webACLResource || webACLResource.err || !webACLResource.data){ + helpers.addResult(results, 3, + 'Unable to get WebACL resource for cognito user pool: ' + helpers.addError(webACLResource), region, arn); + continue; + } + if (webACLResource.data.WebACL){ + helpers.addResult(results, 0, 'User pool has WAFV2 enabled', region, arn); + } else { + helpers.addResult(results, 2, 'User pool does not have WAFV2 enabled', region, arn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cognito/cognitoHasWafEnabled.spec.js b/plugins/aws/cognito/cognitoHasWafEnabled.spec.js new file mode 100644 index 000000000..50e7f79dc --- /dev/null +++ b/plugins/aws/cognito/cognitoHasWafEnabled.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var cognitoHasWafEnabled = require('./cognitoHasWafEnabled'); + +const listUserPools = [ + { + Id: 'us-east-1_cbDh8sCFGH', + Name: 'test', + LambdaConfig: {} + } +]; + +const createCache = (poolList, waf) => { + return { + cognitoidentityserviceprovider: { + listUserPools: { + 'us-east-1': { + err: null, + data: poolList + } + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '11111222222' + } + } + }, + wafv2: { + getWebACLForCognitoUserPool: { + 'us-east-1':{ + 'us-east-1_cbDh8sCFGH': { + err: null, + data: waf + } + } + } + } + + } +}; + +describe('cognitoHasWafEnabled', function () { + describe('run', function () { + it('should give unknown result if unable to list user pools', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query Cognito user pools:'); + done() + }; + + const cache = createCache(null, null); + cognitoHasWafEnabled.run(cache, {}, callback); + }); + + it('should give passing result if User pool not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Cognito user pools found'); + done(); + }; + const cache = createCache([], null); + cognitoHasWafEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query WAFV2 getWebACLForResource api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get WebACL resource for cognito user pool'); + done(); + }; + + const cache = createCache([listUserPools[0]], null); + cognitoHasWafEnabled.run(cache, {}, callback); + }); + + it('should give passing result if User pool has WAF enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('User pool has WAFV2 enabled'); + done(); + }; + + const cache = createCache([listUserPools[0]], { WebACL: {'Name': 'abc'}}); + cognitoHasWafEnabled.run(cache, {}, callback); + }); + + it('should give failing result if User pool does not have WAF enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('User pool does not have WAFV2 enabled'); + done(); + }; + + const cache = createCache([listUserPools[0]], { WebACL: null}); + cognitoHasWafEnabled.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/cognito/cognitoMFAEnabled.js b/plugins/aws/cognito/cognitoMFAEnabled.js new file mode 100644 index 000000000..970650fe9 --- /dev/null +++ b/plugins/aws/cognito/cognitoMFAEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Cognito User Pool MFA enabled', + category: 'Cognito', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that Cognito user pool has MFA enabled.', + more_info: 'Enabling Multi-factor authentication (MFA) increases security for your app. You can choose SMS text messages or time-based one-time passwords (TOTP) as second factors to sign in your users.', + link: 'https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa.html', + recommended_action: '1. Enter the Cognito service. 2. Enter user pools and enable MFA from sign in experience.', + apis: ['CognitoIdentityServiceProvider:listUserPools', 'CognitoIdentityServiceProvider:describeUserPool', 'STS:getCallerIdentity'], + realtime_triggers: ['CognitoIdentityServiceProvider:CreateUserPool','CognitoIdentityServiceProvider:SetUserPoolMfaConfig','cognitoidentityserviceprovider:DeleteUserPool'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.cognitoidentityserviceprovider, function(region, rcb){ + var userPools = helpers.addSource(cache, source, + ['cognitoidentityserviceprovider', 'listUserPools', region]); + + if (!userPools) return rcb(); + + if (userPools.err || !userPools.data){ + helpers.addResult(results, 3, 'Unable to query Cognito user pools: ' + helpers.addError(userPools), region); + return rcb(); + } + + if (!userPools.data.length){ + helpers.addResult(results, 0, 'No Cognito user pools found', region); + return rcb(); + } + + for (let userPool of userPools.data) { + if (!userPool.Id) continue; + + const userPoolArn ='arn:' + awsOrGov + ':cognito-idp:' + region + ':' + accountId + ':userpool/' + userPool.Id; + + var describeUserPool = helpers.addSource(cache, source, + ['cognitoidentityserviceprovider', 'describeUserPool', region, userPool.Id]); + + if (!describeUserPool || describeUserPool.err || !describeUserPool.data || !describeUserPool.data.UserPool){ + helpers.addResult(results, 3, + 'Unable to describe Cognito user pool: ' + helpers.addError(describeUserPool), region, userPoolArn); + continue; + } + + if (describeUserPool.data.UserPool.MfaConfiguration && describeUserPool.data.UserPool.MfaConfiguration.toUpperCase() == 'ON'){ + helpers.addResult(results, 0, 'User pool has MFA enabled', region, userPoolArn); + } else { + helpers.addResult(results, 2, 'User pool does not have MFA enabled', region, userPoolArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/cognito/cognitoMFAEnabled.spec.js b/plugins/aws/cognito/cognitoMFAEnabled.spec.js new file mode 100644 index 000000000..0578e1839 --- /dev/null +++ b/plugins/aws/cognito/cognitoMFAEnabled.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var cognitoMFAEnabled = require('./cognitoMFAEnabled'); + +const listUserPools = [ + { + Id: 'us-east-1_cbDh8sCFGH', + Name: 'test', + LambdaConfig: {} + } +]; +const describeUserPool = [ + { + Id: 'us-east-1_cbDh8sCFGH', + Name: 'test', + LambdaConfig: {}, + MfaConfiguration: 'OFF', + EstimatedNumberOfUsers: 0, + UserPoolTags: {}, + Arn: 'arn:aws:cognito-idp:us-east-1:1111222222222:userpool/us-east-1_cbDh8sCFGH', + }, + { + Id: 'us-east-1_cbDh8sCFGH', + Name: 'test', + LambdaConfig: {}, + MfaConfiguration: 'ON', + EstimatedNumberOfUsers: 0, + UserPoolTags: {}, + Arn: 'arn:aws:cognito-idp:us-east-1:1111222222222:userpool/us-east-1_cbDh8sCFGH', + } +] + +const createCache = (poolList, describe) => { + return { + cognitoidentityserviceprovider: { + listUserPools: { + 'us-east-1': { + err: null, + data: poolList + } + }, + describeUserPool: { + 'us-east-1':{ + 'us-east-1_cbDh8sCFGH':{ + err: null, + data: { UserPool: describe } + } + } + } + }, + + } +}; + +describe('cognitoMFAEnabled', function () { + describe('run', function () { + it('should give unknown result if unable to list user pools', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query Cognito user pools:'); + done() + }; + + const cache = createCache(null, []); + cognitoMFAEnabled.run(cache, {}, callback); + }); + + it('should give passing result if User pool not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Cognito user pools found'); + done(); + }; + const cache = createCache([], null); + cognitoMFAEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query describe user pool api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to describe Cognito user pool'); + done(); + }; + + const cache = createCache([listUserPools[0]], null); + cognitoMFAEnabled.run(cache, {}, callback); + }); + + it('should give passing result if User pool has MFA enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('User pool has MFA enabled'); + done(); + }; + + const cache = createCache([listUserPools[0]], describeUserPool[1]); + cognitoMFAEnabled.run(cache, {}, callback); + }); + + it('should give failing result if User pool does not have MFA enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('User pool does not have MFA enabled'); + done(); + }; + + const cache = createCache([listUserPools[0]], describeUserPool[0]); + cognitoMFAEnabled.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/comprehend/flywheelInVpc.js b/plugins/aws/comprehend/flywheelInVpc.js new file mode 100644 index 000000000..8cbcbdb3b --- /dev/null +++ b/plugins/aws/comprehend/flywheelInVpc.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Amazon Comprehend Flywheel In VPC', + category: 'AI & ML', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that an Amazon Comprehend Flywheel is configured with a VPC.', + more_info: 'Configuring Amazon Comprehend flywheel within a VPC, establishes a secure environment that prevents unauthorized access to your data stored in job containers, minimizing the risk of internet exposure, data breaches and ensuring compliance with security standards.', + recommended_action: 'Update Comprehend Flywheel and configure it within VPC.', + link: 'https://docs.aws.amazon.com/comprehend/latest/dg/usingVPC.html', + apis: ['Comprehend:listFlywheels', 'Comprehend:describeFlywheel'], + realtime_triggers: ['comprehend:CreateFlywheel','comprehend:UpdateFlywheel','comprehend:DeleteFlywheel'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.comprehend, function(region, rcb){ + var listFlywheels = helpers.addSource(cache, source, + ['comprehend', 'listFlywheels', region]); + + if (!listFlywheels) return rcb(); + + if (listFlywheels.err || !listFlywheels.data) { + helpers.addResult(results, 3, + `Unable to query for Comprehend flywheels: ${helpers.addError(listFlywheels)}`, region); + return rcb(); + } + + if (!listFlywheels.data.length) { + helpers.addResult(results, 0, 'No existing Comprehend flywheels found', region); + return rcb(); + } + + for (let flywheel of listFlywheels.data) { + if (!flywheel.FlywheelArn) continue; + + let resource = flywheel.FlywheelArn; + + let describeFlywheel = helpers.addSource(cache, source, + ['comprehend', 'describeFlywheel', region, flywheel.FlywheelArn]); + + if (!describeFlywheel || describeFlywheel.err || !describeFlywheel.data || !describeFlywheel.data.FlywheelProperties) { + helpers.addResult(results, 3, `Unable to describe Comprehend flywheel: ${helpers.addError(describeFlywheel)}`, region, resource); + continue; + } + + if (describeFlywheel.data.FlywheelProperties.DataSecurityConfig && describeFlywheel.data.FlywheelProperties.DataSecurityConfig.VpcConfig) { + helpers.addResult(results, 0, + 'Comprehend flywheel is configured within a VPC', region, resource); + } else { + helpers.addResult(results, 2, + 'Comprehend flywheel is not configured within a VPC', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/comprehend/flywheelInVpc.spec.js b/plugins/aws/comprehend/flywheelInVpc.spec.js new file mode 100644 index 000000000..49b4938fe --- /dev/null +++ b/plugins/aws/comprehend/flywheelInVpc.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var flywheelInVpc = require('./flywheelInVpc') + +const listFlywheels = [ + { + "FlywheelArn": "arn:aws:comprehend:us-east-1:1234567:flywheel/test", + "DataLakeS3Uri": "s3://new-test-bucket/test/schemaVersion=1/20240325T065158Z/", + "Status": "ACTIVE", + "ModelType": "DOCUMENT_CLASSIFIER", + + }, + { + "FlywheelArn": "arn:aws:comprehend:us-east-1:1234567:flywheel/test2", + "DataLakeS3Uri": "s3://new-test-bucket2/test/schemaVersion=1/20240325T054365Z/", + "Status": "ACTIVE", + "ModelType": "DOCUMENT_CLASSIFIER", + } +]; + +const describeFlywheel = [ + { + "FlywheelProperties": { + "FlywheelArn": "arn:aws:comprehend:us-east-1:1234567:flywheel/test", + "DataAccessRoleArn": "arn:aws:iam::1234567:role/service-role/AmazonComprehendServiceRole-test", + "TaskConfig": { + "LanguageCode": "en", + "DocumentClassificationConfig": { + "Mode": "MULTI_CLASS", + "Labels":[ + "comedy" + ] + } + }, + "DataLakeS3Uri": "s3://new-test-bucket/test/schemaVersion=1/20240325T065158Z/", + "DataSecurityConfig": { + "VpcConfig": { + "SecurityGroupIds": [ + "sg-05d802ffebeec4ce9" + ], + "Subnets": [ + "subnet-090543c3cc7bee455" + ] + } + }, + "Status": "ACTIVE", + "ModelType": "DOCUMENT_CLASSIFIER" + } + }, + { + "FlywheelProperties": { + "FlywheelArn": "arn:aws:comprehend:us-east-1:1234567:flywheel/test", + "DataAccessRoleArn": "arn:aws:iam::1234567:role/service-role/AmazonComprehendServiceRole-test", + "TaskConfig": { + "LanguageCode": "en", + "DocumentClassificationConfig": { + "Mode": "MULTI_CLASS", + "Labels":[ + "comedy" + ] + } + }, + "DataLakeS3Uri": "s3://new-test-bucket/test/schemaVersion=1/20240325T065158Z/", + "DataSecurityConfig": {}, + "Status": "ACTIVE", + "ModelType": "DOCUMENT_CLASSIFIER" + } + } +]; + + +const createCache = (listFlywheels, describeFlywheel, listFlywheelsErr, getFlywheelErr) => { + var flywheelArn = (listFlywheels && listFlywheels.length) ? listFlywheels[0].FlywheelArn: null; + return { + comprehend: { + listFlywheels: { + 'us-east-1': { + err: listFlywheelsErr, + data: listFlywheels + }, + }, + describeFlywheel: { + 'us-east-1': { + [flywheelArn]: { + data: describeFlywheel, + err: getFlywheelErr + } + } + } + } + }; +}; + +describe('flywheelInVpc', function () { + describe('run', function () { + it('should PASS if Comprehend Flywheel has Vpc configured', function (done) { + const cache = createCache([listFlywheels[0]],describeFlywheel[0]); + flywheelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Comprehend flywheel is configured within a VPC'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Comprehend Flywheel have not Vpc configured', function (done) { + const cache = createCache([listFlywheels[1]], describeFlywheel[1]); + flywheelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Comprehend flywheel is not configured within a VPC'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Comprehend flywheel found', function (done) { + const cache = createCache([]); + flywheelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Comprehend flywheels found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Comprehend flywheell', function (done) { + const cache = createCache(null, null, null, { message: "Unable to describe Comprehend flywheel" }); + flywheelInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Comprehend flywheels:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/comprehend/outputResultEncryption.js b/plugins/aws/comprehend/outputResultEncryption.js new file mode 100644 index 000000000..8032e7514 --- /dev/null +++ b/plugins/aws/comprehend/outputResultEncryption.js @@ -0,0 +1,181 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Amazon Comprehend Output Result Encryption', + category: 'AI & ML', + domain: 'Compute', + severity: 'High', + description: 'Ensures the Comprehend service is using encryption for all result output.', + more_info: 'Comprehend supports using KMS keys to result output, which should be enabled.', + link: 'https://docs.aws.amazon.com/comprehend/latest/dg/kms-in-comprehend.html', + recommended_action: 'Enable output result encryption for the Comprehend job', + apis: ['Comprehend:listEntitiesDetectionJobs', 'Comprehend:listDominantLanguageDetectionJobs', 'Comprehend:listTopicsDetectionJobs', + 'Comprehend:listDocumentClassificationJobs', 'Comprehend:listKeyPhrasesDetectionJobs', 'Comprehend:listSentimentDetectionJobs'], + realtime_triggers: ['comprehend:StartEntitiesDetectionJob','comprehend:StartTopicsDetectionJob','comprehend:StartSentimentDetectionJob','comprehend:StartKeyPhrasesDetectionJob','comprehend:StartDominantLanguageDetectionJob'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + + async.each(regions.comprehend, function(region, rcb) { + async.parallel([ + function(lcb){ + var listEntitiesDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listEntitiesDetectionJobs', region]); + + if (!listEntitiesDetectionJobs) return lcb(); + + if (listEntitiesDetectionJobs.err || !listEntitiesDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for entities detections jobs', region); + return lcb(); + } + + if (!listEntitiesDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No entities detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listEntitiesDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listDocumentClassificationJobs = helpers.addSource(cache, source, + ['comprehend', 'listDocumentClassificationJobs', region]); + + if (!listDocumentClassificationJobs) return lcb(); + + if (listDocumentClassificationJobs.err || !listDocumentClassificationJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for document classification jobs', region); + return lcb(); + } + + if (!listDocumentClassificationJobs.data.length) { + helpers.addResult(results, 0, + 'No document classification jobs found', region); + return lcb(); + } + + loopJobsForResults(listDocumentClassificationJobs, results, region); + + lcb(); + }, + function(lcb){ + var listDominantLanguageDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listDominantLanguageDetectionJobs', region]); + + if (!listDominantLanguageDetectionJobs) return lcb(); + + if (listDominantLanguageDetectionJobs.err || !listDominantLanguageDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for dominant language detection jobs', region); + return lcb(); + } + + if (!listDominantLanguageDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No dominant language detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listDominantLanguageDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listTopicsDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listTopicsDetectionJobs', region]); + + if (!listTopicsDetectionJobs) return lcb(); + + if (listTopicsDetectionJobs.err || !listTopicsDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for topics detection jobs', region); + return lcb(); + } + + if (!listTopicsDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No topics detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listTopicsDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listKeyPhrasesDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listKeyPhrasesDetectionJobs', region]); + + if (!listKeyPhrasesDetectionJobs) return lcb(); + + if (listKeyPhrasesDetectionJobs.err || !listKeyPhrasesDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for key phrases detection jobs', region); + return lcb(); + } + + if (!listKeyPhrasesDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No key phrases detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listKeyPhrasesDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listSentimentDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listSentimentDetectionJobs', region]); + + if (!listSentimentDetectionJobs) return lcb(); + + if (listSentimentDetectionJobs.err || !listSentimentDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for sentiment detection jobs', region); + return lcb(); + } + + if (!listSentimentDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No sentiment detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listSentimentDetectionJobs, results, region); + + lcb(); + }, + ], function(){ + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; + +function loopJobsForResults(jobs, results, region) { + for (var j in jobs.data) { + var job = jobs.data[j]; + var resource = job.JobId; + + if (!job.OutputDataConfig || !job.OutputDataConfig.KmsKeyId) { + helpers.addResult(results, 2, + 'Output result encryption is not enabled for: ' + job.JobName + ' job', + region, resource); + } else { + helpers.addResult(results, 0, + 'Output result encryption is enabled for: ' + job.JobName + ' job', + region, resource); + } + } +} \ No newline at end of file diff --git a/plugins/aws/comprehend/outputResultEncryption.spec.js b/plugins/aws/comprehend/outputResultEncryption.spec.js new file mode 100644 index 000000000..f25bdef07 --- /dev/null +++ b/plugins/aws/comprehend/outputResultEncryption.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +const outputResultEncryption = require('./outputResultEncryption'); + +const jobs = [ + { + "JobId": "c494f137172cadfcd8e6dd9e38e81aca", + "JobName": "test-job", + "JobStatus": "FAILED", + "Message": "NO_WRITE_ACCESS_TO_OUTPUT: The provided data access role does not have write access to the output S3 URI or it does not have access to the output S3 KMS Key.", + "SubmitTime": "2020-08-15T13:52:31.427Z", + "EndTime": "2020-08-15T13:52:43.121Z", + "InputDataConfig": { + "S3Uri": "s3://public-sample-us-east-1/AsyncBatchJobs/", + "InputFormat": "ONE_DOC_PER_LINE" + }, + "OutputDataConfig": { + "S3Uri": "s3://testbucketplaintext1/12345654321-NER-c494f137172cadfcd8e6dd9e38e81aca/output/output.tar.gz", + "KmsKeyId": "arn:aws:kms:us-east-1:12345654321:key/5b65492d-1d8e-46fc-9095-08812a975fd1" + }, + "LanguageCode": "en", + "DataAccessRoleArn": "arn:aws:iam::12345654321:role/service-role/AmazonComprehendServiceRole-test-comprehend-role" + }, + { + "JobId": "83625cfc598649b886ae893d1c19d787", + "JobName": "test-114", + "JobStatus": "FAILED", + "Message": "NO_WRITE_ACCESS_TO_OUTPUT: The provided data access role does not have write access to the output S3 URI.", + "SubmitTime": "2020-08-24T19:14:31.108Z", + "EndTime": "2020-08-24T19:14:43.044Z", + "InputDataConfig": { + "S3Uri": "s3://public-sample-us-east-1/AsyncBatchJobs/", + "InputFormat": "ONE_DOC_PER_LINE" + }, + "OutputDataConfig": { + "S3Uri": "s3://ajkbasjhg215/entities-detection-output/12345654321-NER-83625cfc598649b886ae893d1c19d787/output/output.tar.gz" + }, + "LanguageCode": "en", + "DataAccessRoleArn": "arn:aws:iam::12345654321:role/service-role/AmazonComprehendServiceRole-test-comprehend-role", + "VolumeKmsKeyId": "arn:aws:kms:us-east-1:12345654321:key/5b65492d-1d8e-46fc-9095-08812a975fd1" + } +] + +const createCache = (entitiesDetectionJobs, keyPhrasesDetectionJobs, dominantLanguageDetectionJobs, topicDetectionJobs, documentClassificationJobs, sentimentDetectionJobs) => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': { + data: entitiesDetectionJobs + }, + }, + listKeyPhrasesDetectionJobs: { + 'us-east-1': { + data: keyPhrasesDetectionJobs + }, + }, + listDominantLanguageDetectionJobs: { + 'us-east-1': { + data: dominantLanguageDetectionJobs + }, + }, + listTopicsDetectionJobs: { + 'us-east-1': { + data: topicDetectionJobs + }, + }, + listDocumentClassificationJobs: { + 'us-east-1': { + data: documentClassificationJobs + }, + }, + listSentimentDetectionJobs: { + 'us-east-1': { + data: sentimentDetectionJobs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': { + err: { + message: 'error describing entites detection jobs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('outputResultEncryption', function () { + describe('run', function () { + it('should PASS if ouput result encryption is enabled for comprehend job', function (done) { + const cache = createCache([jobs[0]]); + outputResultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ouput result encryption is not enabled for comprehend job', function (done) { + const cache = createCache([jobs[1]]); + outputResultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no comprehend jobs found', function (done) { + const cache = createCache([]); + outputResultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was error listing comprehend jobs', function (done) { + const cache = createErrorCache(); + outputResultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for comprehend jobs', function (done) { + const cache = createNullCache(); + outputResultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/comprehend/volumeEncryption.js b/plugins/aws/comprehend/volumeEncryption.js new file mode 100644 index 000000000..07d1e87fa --- /dev/null +++ b/plugins/aws/comprehend/volumeEncryption.js @@ -0,0 +1,182 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Amazon Comprehend Volume Encryption', + category: 'AI & ML', + domain: 'Compute', + severity: 'High', + description: 'Ensures the Comprehend service is using encryption for all volumes storing data at rest.', + more_info: 'Comprehend supports using KMS keys to encrypt data at rest, which should be enabled.', + link: 'https://docs.aws.amazon.com/comprehend/latest/dg/kms-in-comprehend.html', + recommended_action: 'Enable volume encryption for the Comprehend job', + apis: ['Comprehend:listEntitiesDetectionJobs', 'Comprehend:listDominantLanguageDetectionJobs', 'Comprehend:listTopicsDetectionJobs', + 'Comprehend:listDocumentClassificationJobs', 'Comprehend:listKeyPhrasesDetectionJobs', 'Comprehend:listSentimentDetectionJobs'], + realtime_triggers: ['comprehend:StartEntitiesDetectionJob','comprehend:StartTopicsDetectionJob','comprehend:StartSentimentDetectionJob','comprehend:StartKeyPhrasesDetectionJob','comprehend:StartDominantLanguageDetectionJob'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + + async.each(regions.comprehend, function(region, rcb) { + async.parallel([ + function(lcb){ + var listEntitiesDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listEntitiesDetectionJobs', region]); + + if (!listEntitiesDetectionJobs) return lcb(); + + if (listEntitiesDetectionJobs.err || !listEntitiesDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for entities detections jobs', region); + return lcb(); + } + + if (!listEntitiesDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No entities detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listEntitiesDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listDocumentClassificationJobs = helpers.addSource(cache, source, + ['comprehend', 'listDocumentClassificationJobs', region]); + + if (!listDocumentClassificationJobs) return lcb(); + + if (listDocumentClassificationJobs.err || !listDocumentClassificationJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for document classification jobs', region); + return lcb(); + } + + if (!listDocumentClassificationJobs.data.length) { + helpers.addResult(results, 0, + 'No document classification jobs found', region); + return lcb(); + } + + loopJobsForResults(listDocumentClassificationJobs, results, region); + + lcb(); + }, + function(lcb){ + var listDominantLanguageDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listDominantLanguageDetectionJobs', region]); + + if (!listDominantLanguageDetectionJobs) return lcb(); + + if (listDominantLanguageDetectionJobs.err || !listDominantLanguageDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for dominant language detection jobs', region); + return lcb(); + } + + if (!listDominantLanguageDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No dominant language detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listDominantLanguageDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listTopicsDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listTopicsDetectionJobs', region]); + + if (!listTopicsDetectionJobs) return lcb(); + + if (listTopicsDetectionJobs.err || !listTopicsDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for topics detection jobs', region); + return lcb(); + } + + if (!listTopicsDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No topics detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listTopicsDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listKeyPhrasesDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listKeyPhrasesDetectionJobs', region]); + + if (!listKeyPhrasesDetectionJobs) return lcb(); + + if (listKeyPhrasesDetectionJobs.err || !listKeyPhrasesDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for key phrases detection jobs', region); + return lcb(); + } + + if (!listKeyPhrasesDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No key phrases detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listKeyPhrasesDetectionJobs, results, region); + + lcb(); + }, + function(lcb){ + var listSentimentDetectionJobs = helpers.addSource(cache, source, + ['comprehend', 'listSentimentDetectionJobs', region]); + + if (!listSentimentDetectionJobs) return lcb(); + + if (listSentimentDetectionJobs.err || !listSentimentDetectionJobs.data) { + helpers.addResult(results, 3, + 'Unable to query for sentiment detection jobs', region); + return lcb(); + } + + if (!listSentimentDetectionJobs.data.length) { + helpers.addResult(results, 0, + 'No sentiment detection jobs found', region); + return lcb(); + } + + loopJobsForResults(listSentimentDetectionJobs, results, region); + + lcb(); + }, + ], function(){ + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; + +function loopJobsForResults(jobs, results, region) { + for (var j in jobs.data) { + var job = jobs.data[j]; + var resource = job.JobId; + + if (!job.VolumeKmsKeyId) { + helpers.addResult(results, 2, + 'Volume encryption is not enabled for: ' + job.JobName + ' job', + region, resource); + } else { + helpers.addResult(results, 0, + 'Volume encryption is enabled for: ' + job.JobName + ' job', + region, resource); + } + } +} \ No newline at end of file diff --git a/plugins/aws/comprehend/volumeEncryption.spec.js b/plugins/aws/comprehend/volumeEncryption.spec.js new file mode 100644 index 000000000..decc25d31 --- /dev/null +++ b/plugins/aws/comprehend/volumeEncryption.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +const volumeEncryption = require('./volumeEncryption'); + +const jobs = [ + { + "JobId": "c494f137172cadfcd8e6dd9e38e81aca", + "JobName": "test-job", + "JobStatus": "FAILED", + "Message": "NO_WRITE_ACCESS_TO_OUTPUT: The provided data access role does not have write access to the output S3 URI or it does not have access to the output S3 KMS Key.", + "SubmitTime": "2020-08-15T13:52:31.427Z", + "EndTime": "2020-08-15T13:52:43.121Z", + "InputDataConfig": { + "S3Uri": "s3://public-sample-us-east-1/AsyncBatchJobs/", + "InputFormat": "ONE_DOC_PER_LINE" + }, + "OutputDataConfig": { + "S3Uri": "s3://testbucketplaintext1/12345654321-NER-c494f137172cadfcd8e6dd9e38e81aca/output/output.tar.gz", + "KmsKeyId": "arn:aws:kms:us-east-1:12345654321:key/5b65492d-1d8e-46fc-9095-08812a975fd1" + }, + "LanguageCode": "en", + "DataAccessRoleArn": "arn:aws:iam::12345654321:role/service-role/AmazonComprehendServiceRole-test-comprehend-role" + }, + { + "JobId": "83625cfc598649b886ae893d1c19d787", + "JobName": "test-114", + "JobStatus": "FAILED", + "Message": "NO_WRITE_ACCESS_TO_OUTPUT: The provided data access role does not have write access to the output S3 URI.", + "SubmitTime": "2020-08-24T19:14:31.108Z", + "EndTime": "2020-08-24T19:14:43.044Z", + "InputDataConfig": { + "S3Uri": "s3://public-sample-us-east-1/AsyncBatchJobs/", + "InputFormat": "ONE_DOC_PER_LINE" + }, + "OutputDataConfig": { + "S3Uri": "s3://ajkbasjhg215/entities-detection-output/12345654321-NER-83625cfc598649b886ae893d1c19d787/output/output.tar.gz" + }, + "LanguageCode": "en", + "DataAccessRoleArn": "arn:aws:iam::12345654321:role/service-role/AmazonComprehendServiceRole-test-comprehend-role", + "VolumeKmsKeyId": "arn:aws:kms:us-east-1:12345654321:key/5b65492d-1d8e-46fc-9095-08812a975fd1" + } +] + +const createCache = (entitiesDetectionJobs, keyPhrasesDetectionJobs, dominantLanguageDetectionJobs, topicDetectionJobs, documentClassificationJobs, sentimentDetectionJobs) => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': { + data: entitiesDetectionJobs + }, + }, + listKeyPhrasesDetectionJobs: { + 'us-east-1': { + data: keyPhrasesDetectionJobs + }, + }, + listDominantLanguageDetectionJobs: { + 'us-east-1': { + data: dominantLanguageDetectionJobs + }, + }, + listTopicsDetectionJobs: { + 'us-east-1': { + data: topicDetectionJobs + }, + }, + listDocumentClassificationJobs: { + 'us-east-1': { + data: documentClassificationJobs + }, + }, + listSentimentDetectionJobs: { + 'us-east-1': { + data: sentimentDetectionJobs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': { + err: { + message: 'error describing entites detection jobs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + comprehend: { + listEntitiesDetectionJobs: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('volumeEncryption', function () { + describe('run', function () { + it('should PASS if volume encryption is enabled for comprehend job', function (done) { + const cache = createCache([jobs[1]]); + volumeEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if volume encryption is not enabled for comprehend job', function (done) { + const cache = createCache([jobs[0]]); + volumeEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no comprehend jobs found', function (done) { + const cache = createCache([]); + volumeEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(6); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was error listing comprehend jobs', function (done) { + const cache = createErrorCache(); + volumeEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for comprehend jobs', function (done) { + const cache = createNullCache(); + volumeEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/computeoptimizer/asgOptimized.js b/plugins/aws/computeoptimizer/asgOptimized.js new file mode 100644 index 000000000..8656b3a2c --- /dev/null +++ b/plugins/aws/computeoptimizer/asgOptimized.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Auto Scaling Group Optimized', + category: 'Compute Optimizer', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Compute Optimizer does not have active recommendation summaries for unoptimized Auto Scaling groups.', + more_info: 'An Auto Scaling group is considered optimized when Compute Optimizer determines that the group is correctly provisioned to run your workload, based on the chosen instance type. For optimized Auto Scaling groups, Compute Optimizer might sometimes recommend a new generation instance type.', + link: 'https://docs.aws.amazon.com/compute-optimizer/latest/ug/view-asg-recommendations.html', + recommended_action: 'Resolve Compute Optimizer recommendations for Auto Scaling groups.', + apis: ['ComputeOptimizer:getRecommendationSummaries'], + realtime_triggers: ['ComputeOptimizer:UpdateEnrollmentStatus','autoscaling:CreateAutoScalingGroup','autoscaling:UpdateAutoScalingGroup','autoscaling:StartInstanceRefresh','autoscaling:DeleteAutoScalingGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.computeoptimizer, function(region, rcb){ + var getRecommendationSummaries = helpers.addSource(cache, source, + ['computeoptimizer', 'getRecommendationSummaries', region]); + + if (!getRecommendationSummaries) return rcb(); + + if (getRecommendationSummaries && getRecommendationSummaries.err && + getRecommendationSummaries.err.code && + getRecommendationSummaries.err.code.toUpperCase() === 'OPTINREQUIREDEXCEPTION'){ + helpers.addResult(results, 0, + 'Compute Optimizer is not enabled', region); + return rcb(); + } + + if (getRecommendationSummaries.err || !getRecommendationSummaries.data) { + helpers.addResult(results, 3, + 'Unable to get recommendation summaries: ' + helpers.addError(getRecommendationSummaries), region); + return rcb(); + } + + if (!getRecommendationSummaries.data.length) { + helpers.addResult(results, 0, + 'No Compute Optimizer recommendation summaries found', region); + return rcb(); + } + + let findings = getRecommendationSummaries.data.find(resourceType => resourceType.recommendationResourceType && + resourceType.recommendationResourceType.toUpperCase() === 'AUTOSCALINGGROUP'); + if (findings) { + + let notOptimized = findings.summaries.find(notOpt => notOpt.name && notOpt.name.toUpperCase() === 'NOT_OPTIMIZED'); + let Optimized = findings.summaries.find(opt => opt.name && opt.name.toUpperCase() === 'OPTIMIZED'); + + if (!notOptimized.value && !Optimized.value){ + helpers.addResult(results, 0, + 'No recommendations found for Auto Scaling groups', region); + } else if (notOptimized.value){ + helpers.addResult(results, 2, + `Found ${notOptimized.value} unoptimized Auto Scaling groups`, region); + } else { + helpers.addResult(results, 0, + 'All Auto Scaling groups are optimized', region); + } + } else { + helpers.addResult(results, 2, + 'Recommendation summaries are not configured for Auto Scaling groups', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/computeoptimizer/asgOptimized.spec.js b/plugins/aws/computeoptimizer/asgOptimized.spec.js new file mode 100644 index 000000000..eb83ec28b --- /dev/null +++ b/plugins/aws/computeoptimizer/asgOptimized.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var asgOptimized = require('./asgOptimized'); + +const getRecommendationSummaries = [ + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 1.0 + }, + { + "name": "NOT_OPTIMIZED", + "value": 0.0 + } + ], + "recommendationResourceType": "AutoScalingGroup", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 0.0 + }, + { + "name": "NOT_OPTIMIZED", + "value": 1.0 + } + ], + "recommendationResourceType": "AutoScalingGroup", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 0.0 + }, + { + "name": "NOT_OPTIMIZED", + "value": 0.0 + } + ], + "recommendationResourceType": "AutoScalingGroup", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, +]; + + +const createCache = (recommendation, recommendationErr) => { + return { + computeoptimizer: { + getRecommendationSummaries: { + 'us-east-1': { + err: recommendationErr, + data: recommendation + }, + }, + }, + }; +}; + +describe('asgOptimized', function () { + describe('run', function () { + it('should PASS if All Auto Scalling Groups are optimized', function (done) { + const cache = createCache([getRecommendationSummaries[0]]); + asgOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('All Auto Scaling groups are optimized'); + done(); + }); + }); + + it('should FAIL if Auto Scalling Groups are not optimized', function (done) { + const cache = createCache([getRecommendationSummaries[1]]); + asgOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('unoptimized Auto Scaling groups'); + done(); + }); + }); + + it('should PASS if no recommendations found for Auto Scaling groups', function (done) { + const cache = createCache([getRecommendationSummaries[2]]); + asgOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No recommendations found for Auto Scaling groups'); + done(); + }); + }); + + it('should PASS if no Compute Optimizer recommendation summaries found', function (done) { + const cache = createCache([]); + asgOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Compute Optimizer recommendation summaries found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to get recommendation summaries', function (done) { + const cache = createCache(null, { message: "Unable to get recommendation summaries" }); + asgOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get recommendation summaries'); + done(); + }); + }); + + it('should not return anything if get recommendation summaries status response is not found', () => { + asgOptimized.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/ebsVolumesOptimized.js b/plugins/aws/computeoptimizer/ebsVolumesOptimized.js new file mode 100644 index 000000000..acb148e07 --- /dev/null +++ b/plugins/aws/computeoptimizer/ebsVolumesOptimized.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Volumes Optimized', + category: 'Compute Optimizer', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Compute Optimizer does not have active recommendation summaries for unoptimized EBS Volumes.', + more_info: 'An EBS volume is considered optimized when Compute Optimizer determines that the volume is correctly provisioned to run your workload, based on the chosen volume type, volume size, and IOPS specification. For optimized resources, Compute Optimizer might sometimes recommend a new generation volume type.', + link: 'https://docs.aws.amazon.com/compute-optimizer/latest/ug/view-ebs-recommendations.html', + recommended_action: 'Resolve Compute Optimizer recommendations for EBS volumes.', + apis: ['ComputeOptimizer:getRecommendationSummaries'], + realtime_triggers: ['ComputeOptimizer:UpdateEnrollmentStatus','ec2:CreateVolume','ec2:ModifyVolume','ec2:DeleteVolume'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.computeoptimizer, function(region, rcb){ + var getRecommendationSummaries = helpers.addSource(cache, source, + ['computeoptimizer', 'getRecommendationSummaries', region]); + + if (!getRecommendationSummaries) return rcb(); + + if (getRecommendationSummaries && getRecommendationSummaries.err && + getRecommendationSummaries.err.code && + getRecommendationSummaries.err.code.toUpperCase() === 'OPTINREQUIREDEXCEPTION'){ + helpers.addResult(results, 0, + 'Compute Optimizer is not enabled', region); + return rcb(); + } + + if (getRecommendationSummaries.err || !getRecommendationSummaries.data) { + helpers.addResult(results, 3, + 'Unable to get recommendation summaries: ' + helpers.addError(getRecommendationSummaries), region); + return rcb(); + } + + if (!getRecommendationSummaries.data.length) { + helpers.addResult(results, 0, + 'No Compute Optimizer recommendation summaries found', region); + return rcb(); + } + + let findings = getRecommendationSummaries.data.find(resourceType => resourceType.recommendationResourceType === 'EbsVolume'); + if (findings) { + + let notOptimized = findings.summaries.find(notOpt => notOpt.name === 'NotOptimized'); + let optimized = findings.summaries.find(opt => opt.name === 'Optimized'); + + if (!notOptimized.value && !optimized.value){ + helpers.addResult(results, 0, + 'No recommendations found for EBS volumes', region); + } else if (notOptimized.value){ + helpers.addResult(results, 2, + `Found ${notOptimized.value} unoptimized EBS volumes`, region); + } else { + helpers.addResult(results, 0, + 'All EBS volumes are optimized', region); + } + } else { + helpers.addResult(results, 2, + 'Recommendation summaries are not configured for EBS Volumes', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/ebsVolumesOptimized.spec.js b/plugins/aws/computeoptimizer/ebsVolumesOptimized.spec.js new file mode 100644 index 000000000..faab93b04 --- /dev/null +++ b/plugins/aws/computeoptimizer/ebsVolumesOptimized.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var ebsVolumesOptimized = require('./ebsVolumesOptimized'); + +const getRecommendationSummaries = [ + { + "summaries": [ + { + "name": "Optimized", + "value": 1.0 + }, + { + "name": "NotOptimized", + "value": 0.0 + } + ], + "recommendationResourceType": "EbsVolume", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "Optimized", + "value": 0.0 + }, + { + "name": "NotOptimized", + "value": 1.0 + } + ], + "recommendationResourceType": "EbsVolume", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "Optimized", + "value": 0.0 + }, + { + "name": "NotOptimized", + "value": 0.0 + } + ], + "recommendationResourceType": "EbsVolume", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, +]; + + +const createCache = (recommendation, recommendationErr) => { + return { + computeoptimizer: { + getRecommendationSummaries: { + 'us-east-1': { + err: recommendationErr, + data: recommendation + }, + }, + }, + }; +}; + +describe('ebsVolumesOptimized', function () { + describe('run', function () { + it('should PASS if All EBS volumes are optimized', function (done) { + const cache = createCache([getRecommendationSummaries[0]]); + ebsVolumesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('All EBS volumes are optimized'); + done(); + }); + }); + + it('should FAIL if EBS volumes are not optimized', function (done) { + const cache = createCache([getRecommendationSummaries[1]]); + ebsVolumesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('unoptimized EBS volumes'); + done(); + }); + }); + + it('should PASS if EBS volumes have no recommendations found for EBS volumes', function (done) { + const cache = createCache([getRecommendationSummaries[2]]); + ebsVolumesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No recommendations found for EBS volumes'); + done(); + }); + }); + + it('should PASS if No Compute Optimizer recommendation summaries found', function (done) { + const cache = createCache([]); + ebsVolumesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Compute Optimizer recommendation summaries found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to get recommendation summaries', function (done) { + const cache = createCache(null, { message: "Unable to get recommendation summaries" }); + ebsVolumesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get recommendation summaries'); + done(); + }); + }); + + it('should not return anything if get recommendation summaries status response is not found', () => { + ebsVolumesOptimized.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/ec2InstancesOptimized.js b/plugins/aws/computeoptimizer/ec2InstancesOptimized.js new file mode 100644 index 000000000..10d183ba8 --- /dev/null +++ b/plugins/aws/computeoptimizer/ec2InstancesOptimized.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EC2 Instances Optimized', + category: 'Compute Optimizer', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Compute Optimizer does not have active recommendation summaries for over-provisioned or under-provisioned EC2 instances.', + more_info: 'An EC2 instance is considered optimized when all specifications of an instance, such as CPU, memory, and network, meet the performance requirements of your workload, and the instance is not over-provisioned. For optimized instances, Compute Optimizer might sometimes recommend a new generation instance type.', + link: 'https://docs.aws.amazon.com/compute-optimizer/latest/ug/view-ec2-recommendations.html', + recommended_action: 'Resolve Compute Optimizer recommendations for EC2 instances.', + apis: ['ComputeOptimizer:getRecommendationSummaries'], + realtime_triggers: ['ComputeOptimizer:UpdateEnrollmentStatus','ec2:RunInstances','ec2:TerminateInstances','ec2:ModifyInstanceAttribute','ec2:StartInstances','ec2:StopInstances', 'ec2:StartInstance', 'ec2:StopInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.computeoptimizer, function(region, rcb){ + var getRecommendationSummaries = helpers.addSource(cache, source, + ['computeoptimizer', 'getRecommendationSummaries', region]); + + if (!getRecommendationSummaries) return rcb(); + + if (getRecommendationSummaries && getRecommendationSummaries.err && + getRecommendationSummaries.err.code && + getRecommendationSummaries.err.code.toUpperCase() === 'OPTINREQUIREDEXCEPTION'){ + helpers.addResult(results, 0, + 'Compute Optimizer is not enabled', region); + return rcb(); + } + + if (getRecommendationSummaries.err || !getRecommendationSummaries.data) { + helpers.addResult(results, 3, + 'Unable to get recommendation summaries: ' + helpers.addError(getRecommendationSummaries), region); + return rcb(); + } + + if (!getRecommendationSummaries.data.length) { + helpers.addResult(results, 0, + 'No Compute Optimizer recommendation summaries found', region); + return rcb(); + } + + let findings = getRecommendationSummaries.data.find(resourceType => resourceType.recommendationResourceType && + resourceType.recommendationResourceType.toUpperCase() === 'EC2INSTANCE'); + if (findings) { + let underProvisioned = findings.summaries.find(underProv => underProv.name === 'UNDER_PROVISIONED' ); + let optimized = findings.summaries.find(opt => opt.name === 'OPTIMIZED' ); + let overProvisioned = findings.summaries.find(overProv => overProv.name === 'OVER_PROVISIONED' ); + + if (!underProvisioned.value && !overProvisioned.value && !optimized.value){ + helpers.addResult(results, 0, + 'EC2 instances have no recommendations enabled', region); + } else if (underProvisioned.value || overProvisioned.value){ + helpers.addResult(results, 2, + `Found ${underProvisioned.value} under-provisioned and ${overProvisioned.value} over-provisioned EC2 instances`, region); + } else { + helpers.addResult(results, 0, + 'All EC2 instances are optimized', region); + } + } else { + helpers.addResult(results, 2, + 'Recommendation summaries are not configured for EC2 instances', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/computeoptimizer/ec2InstancesOptimized.spec.js b/plugins/aws/computeoptimizer/ec2InstancesOptimized.spec.js new file mode 100644 index 000000000..359cf73b6 --- /dev/null +++ b/plugins/aws/computeoptimizer/ec2InstancesOptimized.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +var ec2InstancesOptimized = require('./ec2InstancesOptimized'); + +const getRecommendationSummaries = [ + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 1.0 + }, + { + "name": "UNDER_PROVISIONED", + "value": 0.0 + }, + { + "name": "OVER_PROVISIONED", + "value": 0.0 + } + ], + "recommendationResourceType": "Ec2Instance", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 0.0 + }, + { + "name": "UNDER_PROVISIONED", + "value": 0.0 + }, + { + "name": "OVER_PROVISIONED", + "value": 1.0 + } + ], + "recommendationResourceType": "Ec2Instance", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "OPTIMIZED", + "value": 0.0 + }, + { + "name": "UNDER_PROVISIONED", + "value": 0.0 + }, + { + "name": "OVER_PROVISIONED", + "value": 0.0 + } + ], + "recommendationResourceType": "Ec2Instance", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, +]; + + +const createCache = (recommendation, recommendationErr) => { + return { + computeoptimizer: { + getRecommendationSummaries: { + 'us-east-1': { + err: recommendationErr, + data: recommendation + }, + }, + }, + }; +}; + +describe('ec2InstancesOptimized', function () { + describe('run', function () { + it('should PASS if All EC2 instances are optimized', function (done) { + const cache = createCache([getRecommendationSummaries[0]]); + ec2InstancesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('All EC2 instances are optimized'); + done(); + }); + }); + + it('should FAIL if EC2 instance is not optimized', function (done) { + const cache = createCache([getRecommendationSummaries[1]]); + ec2InstancesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('over-provisioned EC2 instances'); + done(); + }); + }); + + it('should PASS if EC2 instances have no recommendations enabled', function (done) { + const cache = createCache([getRecommendationSummaries[2]]); + ec2InstancesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EC2 instances have no recommendations enabled'); + done(); + }); + }); + + it('should PASS if no Compute Optimizer recommendation summaries found', function (done) { + const cache = createCache([]); + ec2InstancesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Compute Optimizer recommendation summaries found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to get recommendation summaries', function (done) { + const cache = createCache(null, { message: "Unable to get recommendation summaries" }); + ec2InstancesOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get recommendation summaries'); + done(); + }); + }); + + it('should not return anything if get recommendation summaries status response is not found', () => { + ec2InstancesOptimized.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.js b/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.js new file mode 100644 index 000000000..85e80063a --- /dev/null +++ b/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Function Optimized', + category: 'Compute Optimizer', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Compute Optimizer does not have active recommendation summaries for unoptimized Lambda Functions.', + more_info: 'AWS Compute Optimizer generates memory size recommendations for AWS Lambda functions. A Lambda function is considered optimized when Compute Optimizer determines that its configured memory or CPU power (which is proportional to the configured memory) is correctly provisioned to run your workload.', + link: 'https://docs.aws.amazon.com/compute-optimizer/latest/ug/view-lambda-recommendations.html', + recommended_action: 'Resolve Compute Optimizer recommendations for Lambda functions.', + apis: ['ComputeOptimizer:getRecommendationSummaries'], + realtime_triggers: ['ComputeOptimizer:UpdateEnrollmentStatus','lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.computeoptimizer, function(region, rcb){ + var getRecommendationSummaries = helpers.addSource(cache, source, + ['computeoptimizer', 'getRecommendationSummaries', region]); + + if (!getRecommendationSummaries) return rcb(); + + if (getRecommendationSummaries && getRecommendationSummaries.err && + getRecommendationSummaries.err.code && + getRecommendationSummaries.err.code.toUpperCase() === 'OPTINREQUIREDEXCEPTION'){ + helpers.addResult(results, 0, + 'Compute Optimizer is not enabled', region); + return rcb(); + } + + if (getRecommendationSummaries.err || !getRecommendationSummaries.data) { + helpers.addResult(results, 3, + 'Unable to get recommendation summaries: ' + helpers.addError(getRecommendationSummaries), region); + return rcb(); + } + + if (!getRecommendationSummaries.data.length) { + helpers.addResult(results, 0, + 'No Compute Optimizer recommendation summaries found', region); + return rcb(); + } + + let findings = getRecommendationSummaries.data.find(resourceType => resourceType.recommendationResourceType && + resourceType.recommendationResourceType.toUpperCase() === 'LAMBDAFUNCTION'); + if (findings) { + let notOptimized = findings.summaries.find(notOpt => notOpt.name === 'NotOptimized'); + let Optimized = findings.summaries.find(Opt => Opt.name === 'Optimized'); + if (!notOptimized.value && !Optimized.value){ + helpers.addResult(results, 0, + 'No recommendations found for Lambda functions', region); + } else if (notOptimized.value){ + helpers.addResult(results, 2, + `Found ${notOptimized.value} unoptimized Lambda functions`, region); + } else { + helpers.addResult(results, 0, + 'All Lambda Functions are optimized', region); + } + } else { + helpers.addResult(results, 2, + 'Recommendation summaries are not configured for Lambda functions', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.spec.js b/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.spec.js new file mode 100644 index 000000000..6a75bc295 --- /dev/null +++ b/plugins/aws/computeoptimizer/lambdaFunctionsOptimized.spec.js @@ -0,0 +1,174 @@ +var expect = require('chai').expect; +var lambdaFunctionsOptimized = require('./lambdaFunctionsOptimized'); + +const getRecommendationSummaries = [ + { + "summaries": [ + { + "name": "Optimized", + "value": 1.0 + }, + { + "name": "NotOptimized", + "value": 0.0, + "reasonCodeSummaries": [ + { + "name": "MemoryOverprovisioned", + "value": 0.0 + }, + { + "name": "MemoryUnderprovisioned", + "value": 0.0 + } + ] + } + ], + "recommendationResourceType": "LambdaFunction", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "Optimized", + "value": 0.0 + }, + { + "name": "NotOptimized", + "value": 1.0, + "reasonCodeSummaries": [ + { + "name": "MemoryOverprovisioned", + "value": 1.0 + }, + { + "name": "MemoryUnderprovisioned", + "value": 0.0 + } + ] + } + ], + "recommendationResourceType": "LambdaFunction", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, + { + "summaries": [ + { + "name": "Optimized", + "value": 0.0 + }, + { + "name": "NotOptimized", + "value": 0.0, + "reasonCodeSummaries": [ + { + "name": "MemoryOverprovisioned", + "value": 0.0 + }, + { + "name": "MemoryUnderprovisioned", + "value": 0.0 + } + ] + } + ], + "recommendationResourceType": "LambdaFunction", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + }, +]; + + +const createCache = (recommendation, recommendationErr) => { + return { + computeoptimizer: { + getRecommendationSummaries: { + 'us-east-1': { + err: recommendationErr, + data: recommendation + }, + }, + }, + }; +}; + +describe('lambdaFunctionsOptimized', function () { + describe('run', function () { + it('should PASS if All Lambda Functions are optimized', function (done) { + const cache = createCache([getRecommendationSummaries[0]]); + lambdaFunctionsOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('All Lambda Functions are optimized'); + done(); + }); + }); + + it('should FAIL if Lambda Functions are not optimized', function (done) { + const cache = createCache([getRecommendationSummaries[1]]); + lambdaFunctionsOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('unoptimized Lambda functions'); + done(); + }); + }); + + it('should PASS if no recommendations found for Lambda functions', function (done) { + const cache = createCache([getRecommendationSummaries[2]]); + lambdaFunctionsOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No recommendations found for Lambda functions'); + done(); + }); + }); + + it('should PASS if no Compute Optimizer recommendation summaries found', function (done) { + const cache = createCache([]); + lambdaFunctionsOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Compute Optimizer recommendation summaries found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to get recommendation summaries', function (done) { + const cache = createCache(null, { message: "Unable to get recommendation summaries" }); + lambdaFunctionsOptimized.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get recommendation summaries'); + done(); + }); + }); + + it('should not return anything if get recommendation summaries status response is not found', () => { + lambdaFunctionsOptimized.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.js b/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.js new file mode 100644 index 000000000..fe0799e4d --- /dev/null +++ b/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.js @@ -0,0 +1,46 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Compute Optimizer Recommendations Enabled', + category: 'Compute Optimizer', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that Compute Optimizer is enabled for your AWS account.', + more_info: 'AWS Compute Optimizer is a service that analyzes the configuration and utilization metrics of your AWS resources. It reports whether your resources are optimal, and generates optimization recommendations to reduce the cost and improve the performance of your workloads.', + link: 'https://docs.aws.amazon.com/compute-optimizer/latest/ug/what-is-compute-optimizer.html', + recommended_action: 'Enable Compute Optimizer Opt In options for current of all AWS account in your organization.', + apis: ['ComputeOptimizer:getRecommendationSummaries'], + realtime_triggers: ['computeoptimizer:UpdateEnrollmentStatus'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.computeoptimizer, function(region, rcb){ + var getRecommendationSummaries = helpers.addSource(cache, source, + ['computeoptimizer', 'getRecommendationSummaries', region]); + + if (!getRecommendationSummaries) return rcb(); + + if (getRecommendationSummaries && getRecommendationSummaries.err && getRecommendationSummaries.err.code && + getRecommendationSummaries.err.code.toUpperCase() === 'OPTINREQUIREDEXCEPTION'){ + helpers.addResult(results, 2, + 'Compute Optimizer is not enabled', region); + } else if (getRecommendationSummaries.err || !getRecommendationSummaries.data || + !getRecommendationSummaries.data.length) { + helpers.addResult(results, 3, + 'Unable to get Compute Optimizer recommendation summaries: ' + helpers.addError(getRecommendationSummaries), region); + } else { + helpers.addResult(results, 0, + 'Compute Optimizer is Enabled', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.spec.js b/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.spec.js new file mode 100644 index 000000000..59e1b679e --- /dev/null +++ b/plugins/aws/computeoptimizer/optimizerRecommendationsEnabled.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var optimizerRecommendationsEnabled = require('./optimizerRecommendationsEnabled'); + +const getRecommendationSummaries = [ + { + "summaries": [ + { + "name": "Optimized", + "value": 0.0 + }, + { + "name": "NotOptimized", + "value": 1.0, + "reasonCodeSummaries": [ + { + "name": "MemoryOverprovisioned", + "value": 1.0 + }, + { + "name": "MemoryUnderprovisioned", + "value": 0.0 + } + ] + } + ], + "recommendationResourceType": "LambdaFunction", + "accountId": "000011112222", + "currentPerformanceRiskRatings": { + "high": 0, + "medium": 0, + "low": 0, + "veryLow": 0 + } + } +]; + + +const createCache = (recommendation, recommendationErr) => { + return { + computeoptimizer: { + getRecommendationSummaries: { + 'us-east-1': { + err: recommendationErr, + data: recommendation + }, + }, + }, + }; +}; + +describe('optimizerRecommendationsEnabled', function () { + describe('run', function () { + it('should PASS if Compute Optimizer is Enabled', function (done) { + const cache = createCache([getRecommendationSummaries[0]]); + optimizerRecommendationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Compute Optimizer is Enabled'); + done(); + }); + }); + + it('should FAIL if Compute Optimizer is not enabled', function (done) { + const cache = createCache(null, { message: 'Aws account is not registered for recommendation.', code: 'OptInRequiredException' }); + optimizerRecommendationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Compute Optimizer is not enabled'); + + done(); + }); + }); + + it('should UNKNOWN if Unable to get recommendation summaries', function (done) { + const cache = createCache(null, { message: "Unable to get recommendation summaries" }); + optimizerRecommendationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get Compute Optimizer recommendation summaries'); + done(); + }); + }); + + it('should not return anything if get recommendation summaries status response is not found', () => { + optimizerRecommendationsEnabled.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/configservice/configComplaintRules.js b/plugins/aws/configservice/configComplaintRules.js new file mode 100644 index 000000000..22c98e476 --- /dev/null +++ b/plugins/aws/configservice/configComplaintRules.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Config Complaint Rules', + category: 'ConfigService', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that all the evaluation results returned from the Amazon Config rules created within your AWS account are compliant.', + more_info: 'AWS Config provides AWS managed rules, which are predefined customizable rules that AWS Config uses to evaluate whether your AWS resources comply with common best practices.', + recommended_action: 'Enable the AWS Config Service rules for compliance checks and close security gaps.', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/evaluate-config_develop-rules.html', + apis: ['ConfigService:describeConfigRules', 'ConfigService:getComplianceDetailsByConfigRule'], + realtime_triggers: ['configservice:PutConfigurationRecorder','configservice:PutConfigRule','configservice:DeleteConfigRule'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.configservice, function(region, rcb){ + var describeConfigRules = helpers.addSource(cache, source, + ['configservice', 'describeConfigRules', region]); + + if (!describeConfigRules) return rcb(); + + if (describeConfigRules.err || !describeConfigRules.data) { + helpers.addResult(results, 3, + 'Unable to query Config Rules: ' + helpers.addError(describeConfigRules), region); + return rcb(); + } + + if (!describeConfigRules.data.length) { + helpers.addResult(results, 0, 'No Config Rules found', region); + return rcb(); + } + + for (let rule of describeConfigRules.data) { + if (!rule.ConfigRuleArn) continue; + + let resource = rule.ConfigRuleArn; + var getComplianceDetailsByConfigRule = helpers.addSource(cache, source, + ['configservice', 'getComplianceDetailsByConfigRule', region, rule.ConfigRuleName]); + + if (!getComplianceDetailsByConfigRule || getComplianceDetailsByConfigRule.err || !getComplianceDetailsByConfigRule.data) { + helpers.addResult(results, 3, + `Unable to get Evaluation Results: ${helpers.addError(getComplianceDetailsByConfigRule)}`, + region, resource); + continue; + } + + if (!getComplianceDetailsByConfigRule.data.EvaluationResults || + !getComplianceDetailsByConfigRule.data.EvaluationResults.length){ + helpers.addResult(results, 0, 'Amazon Config rule returns compliant evaluation results', + region, resource); + } else { + helpers.addResult(results, 2, 'Amazon Config rule returns noncompliant evaluation results', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/configservice/configComplaintRules.spec.js b/plugins/aws/configservice/configComplaintRules.spec.js new file mode 100644 index 000000000..dc5128681 --- /dev/null +++ b/plugins/aws/configservice/configComplaintRules.spec.js @@ -0,0 +1,132 @@ +const expect = require('chai').expect; +var configComplaintRules = require('./configComplaintRules'); + +const describeConfigRules = [ + { + "ConfigRuleName": "restricted-ssh", + "ConfigRuleArn": "arn:aws:config:us-east-1:000011112222:config-rule/config-rule-vhwbj2", + "ConfigRuleId": "config-rule-vhwbj2", + "Description": "Checks whether security groups that are in use disallow unrestricted incoming SSH traffic.", "Scope": { + "ComplianceResourceTypes": [ + "AWS::EC2::SecurityGroup" + ] + }, + "Source": { + "Owner": "AWS", + "SourceIdentifier": "INCOMING_SSH_DISABLED" + }, + "InputParameters": "{}", + "ConfigRuleState": "ACTIVE" + } +]; + +const getComplianceDetailsByConfigRule = [ + { + "EvaluationResults": [ + { + "EvaluationResultIdentifier": { + "EvaluationResultQualifier": { + "ConfigRuleName": "restricted-ssh", + "ResourceType": "AWS::EC2::SecurityGroup", + "ResourceId": "sg-008a9126e4f284b6c" + }, + "OrderingTimestamp": "2022-02-15T15:17:41.028000+05:00" + }, + "ComplianceType": "COMPLIANT", + "ResultRecordedTime": "2022-03-10T19:27:49.203000+05:00", + "ConfigRuleInvokedTime": "2022-03-10T19:27:48.910000+05:00" + }, + ] + }, + { + "EvaluationResults": [] + } +]; + +const createCache = (describeConfigRules, getComplianceDetailsByConfigRule, describeConfigRulesErr, getErr) => { + var ruleName = (describeConfigRules && describeConfigRules.length) ? describeConfigRules[0].ConfigRuleName : null; + return { + configservice: { + describeConfigRules: { + 'us-east-1': { + err: describeConfigRulesErr, + data: describeConfigRules + } + }, + getComplianceDetailsByConfigRule: { + 'us-east-1': { + [ruleName]: { + err: getErr, + data: getComplianceDetailsByConfigRule + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + configservice: { + describeConfigRules: { + 'us-east-1': null + } + } + }; +}; + +describe('configComplaintRules', function () { + describe('run', function () { + it('should PASS if Amazon Config rule returns compliant evaluation results', function (done) { + const cache = createCache(describeConfigRules, getComplianceDetailsByConfigRule[1]); + configComplaintRules.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon Config rule returns compliant evaluation results') + done(); + }); + }); + + it('should FAIL if Amazon Config rule returns noncompliant evaluation results', function (done) { + const cache = createCache(describeConfigRules, getComplianceDetailsByConfigRule[0]); + configComplaintRules.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Amazon Config rule returns noncompliant evaluation results') + done(); + }); + }); + + it('should PASS if No Config Rules found', function (done) { + const cache = createCache([]); + configComplaintRules.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Config Rules found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query Config Rules', function (done) { + const cache = createCache(describeConfigRules, getComplianceDetailsByConfigRule[0], { message: 'error to query for Config Rules'}); + configComplaintRules.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query Config Rules') + done(); + }); + }); + + it('should not return anything if query Config Rules not found', function (done) { + const cache = createNullCache(); + configComplaintRules.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/configservice/configDeliveryFailing.js b/plugins/aws/configservice/configDeliveryFailing.js new file mode 100644 index 000000000..7ad22b0d2 --- /dev/null +++ b/plugins/aws/configservice/configDeliveryFailing.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Config Delivery Failing', + category: 'ConfigService', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that the AWS Config log files are delivered to the S3 bucket in order to store logging data for auditing purposes without any failures.', + more_info: 'Amazon Config keep record of the changes within the configuration of your AWS resources and it regularly stores this data to log files that are send to an S3 bucket specified by you.', + recommended_action: 'Configure AWS Config log files to be delivered without any failures to designated S3 bucket.', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/select-resources.html', + apis: ['ConfigService:describeConfigurationRecorderStatus'], + realtime_triggers: ['configservice:PutConfigurationRecorder'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.configservice, function(region, rcb){ + var describeConfigurationRecorderStatus = helpers.addSource(cache, source, + ['configservice', 'describeConfigurationRecorderStatus', region]); + + if (!describeConfigurationRecorderStatus) return rcb(); + + if (describeConfigurationRecorderStatus.err || !describeConfigurationRecorderStatus.data) { + helpers.addResult(results, 3, + 'Unable to query for Config Service configuration recorder statuses: ' + helpers.addError(describeConfigurationRecorderStatus), region); + return rcb(); + } + + if (!describeConfigurationRecorderStatus.data.length) { + helpers.addResult(results, 0, + 'No Config Service configuration recorder statuses found', region); + return rcb(); + } + + if (describeConfigurationRecorderStatus.data[0].lastStatus && + describeConfigurationRecorderStatus.data[0].lastStatus.toUpperCase() === 'SUCCESS') { + helpers.addResult(results, 0, + 'AWS Config service is delivering log files to the designated recipient successfully', + region); + } else { + helpers.addResult(results, 2, + 'AWS Config service is not delivering log files to the designated recipient successfully', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/configservice/configDeliveryFailing.spec.js b/plugins/aws/configservice/configDeliveryFailing.spec.js new file mode 100644 index 000000000..35e010406 --- /dev/null +++ b/plugins/aws/configservice/configDeliveryFailing.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect;; +var configDeliveryFailing = require('./configDeliveryFailing'); + +const describeConfigurationRecorderStatus = [ + { + "name": "default", + "lastStartTime": "2022-01-18T17:15:22.529000+05:00", + "recording": true, + "lastStatus": "SUCCESS", + "lastStatusChangeTime": "2022-01-19T12:19:56.700000+05:00" + }, + { + "name": "default", + "lastStartTime": "2022-01-18T17:15:22.529000+05:00", + "recording": true, + "lastStatus": "FAILURE", + "lastStatusChangeTime": "2022-01-19T12:19:56.700000+05:00" + } +]; + +const createCache = (status) => { + return { + configservice: { + describeConfigurationRecorderStatus: { + "us-east-1": { + data: status + }, + } + } + } +} + +const createNullCache = () => { + return { + configservice: { + describeConfigurationRecorderStatus: { + "us-east-1": { + data: null + } + } + } + } +} + +describe('configDeliveryFailing', () => { + describe('run', () => { + it('should PASS if AWS Config service is delivering log files to the designated recipient successfully', () => { + const cache = createCache([describeConfigurationRecorderStatus[0]]); + configDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AWS Config service is delivering log files to the designated recipient successfully'); + }) + }); + it('should FAIL if AWS Config service is not delivering log files to the designated recipient successfully', () => { + const cache = createCache([describeConfigurationRecorderStatus[1]]); + configDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AWS Config service is not delivering log files to the designated recipient successfully'); + }) + }); + it('should PASS if no Config Service configuration recorder statuses found', function (done) { + const cache = createCache([]); + configDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Config Service configuration recorder statuses found'); + done(); + }); + }); + it('should UNKNOWN if unable to query for Config Service configuration recorder statuses', () => { + const cache = createNullCache(); + configDeliveryFailing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Config Service configuration recorder statuses'); + }) + }); + it('should not return anything if list config services status response is not found', () => { + configDeliveryFailing.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/configservice/configServiceEnabled.js b/plugins/aws/configservice/configServiceEnabled.js index 46847b725..bbd237ba8 100644 --- a/plugins/aws/configservice/configServiceEnabled.js +++ b/plugins/aws/configservice/configServiceEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Config Service Enabled', category: 'ConfigService', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures the AWS Config Service is enabled to detect changes to account resources', more_info: 'The AWS Config Service tracks changes to a number of resources in an AWS account and is invaluable in determining how account changes affect other resources and in recovery in the event of an account intrusion or accidental configuration change.', recommended_action: 'Enable the AWS Config Service for all regions and resources in an account. Ensure that it is properly recording and delivering logs.', @@ -16,6 +18,7 @@ module.exports = { 'could introduce security risks.', cis1: '2.5 Ensure AWS Config is enabled in all regions' }, + realtime_triggers: ['configservice:PutConfigurationRecorder','configservice:StartConfigurationRecorder','configservice:StopConfigurationRecorder'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/configservice/configServiceEnabled.spec.js b/plugins/aws/configservice/configServiceEnabled.spec.js new file mode 100644 index 000000000..8ec5cb6fa --- /dev/null +++ b/plugins/aws/configservice/configServiceEnabled.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +const configServiceEnabled = require('./configServiceEnabled'); + +const describeConfigurationRecorders = [ + { + "name": "default", + "roleARN": "arn:aws:iam::111111111111:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig", + "recordingGroup": { + "allSupported": true, + "includeGlobalResourceTypes": true, + "resourceTypes": [] + } + }, + { // global service monitoring disabled + "name": "default", + "roleARN": "arn:aws:iam::111111111111:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig", + "recordingGroup": { + "allSupported": true, + "includeGlobalResourceTypes": false, + "resourceTypes": [] + } + } +] + +const describeConfigurationRecorderStatus =[ + { + "name": "default", + "lastStartTime": "2021-01-13T22:49:49.468Z", + "lastStopTime": "2021-01-14T03:42:24.188Z", + "recording": true, + "lastStatus": "SUCCESS", + "lastStatusChangeTime": "2021-01-14T02:50:03.295Z" + }, + { // recorders configured but not recording + "name": "default", + "lastStartTime": "2021-01-13T22:49:49.468Z", + "lastStopTime": "2021-01-14T03:42:24.188Z", + "recording": false, + "lastStatus": "SUCCESS", + "lastStatusChangeTime": "2021-01-14T02:50:03.295Z" + }, + { // configured recorders have not delivered till now + "name": "default", + "lastStartTime": "2021-01-13T22:49:49.468Z", + "lastStopTime": "2021-01-14T03:42:24.188Z", + "recording": true, + "lastStatus": "", + "lastStatusChangeTime": "2021-01-14T02:50:03.295Z" + }, + { // configured recorders does not have last status property + "name": "default", + "lastStartTime": "2021-01-13T22:49:49.468Z", + "lastStopTime": "2021-01-14T03:42:24.188Z", + "recording": true, + "lastStatusChangeTime": "2021-01-14T02:50:03.295Z" + } +] + +const createCache = (recorders, recordersStatus) => { + const records = (recorders && recorders.length) ? recorders: null; + const recordStatus = (recordersStatus && recordersStatus.length) ? recordersStatus: null; + return { + configservice: { + describeConfigurationRecorders: { + 'us-east-1': { + data: records + }, + }, + describeConfigurationRecorderStatus: { + 'us-east-1': { + data: recordStatus + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + configservice: { + describeConfigurationRecorders: { + 'us-east-1': { + err: { + message: 'error while getting recorders' + }, + }, + }, + describeConfigurationRecorderStatus: { + 'us-east-1': { + err: { + message: 'error while getting recorder status' + }, + }, + }, + }, + }; +}; + +const createRecorderStatusErrorCache = (recorders) => { + return { + configservice: { + describeConfigurationRecorders: { + 'us-east-1': { + data: recorders + }, + }, + describeConfigurationRecorderStatus: { + 'us-east-1': { + err: { + message: 'error while getting recorder status' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + configservice: { + describeConfigurationRecorders: { + 'us-east-1': null, + }, + describeConfigurationRecorderStatus: { + 'us-east-1': null, + }, + } + } +}; + +describe('configServiceEnabled', () => { + describe('run', () => { + + it('should PASS if configuration recorders are recording and delivering', (done) => { + const cache = createCache([describeConfigurationRecorders[0]], [describeConfigurationRecorderStatus[0]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if configuration recorders are monitoring global services', (done) => { + const cache = createCache([describeConfigurationRecorders[0]], [describeConfigurationRecorderStatus[0]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if configuration recorders are not monitoring global services', (done) => { + const cache = createCache([describeConfigurationRecorders[1]], [describeConfigurationRecorderStatus[0]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + it('should FAIL if configuration recorders does not have last status property', (done) => { + const cache = createCache([describeConfigurationRecorders[0]], [describeConfigurationRecorderStatus[3]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(1); + done(); + }); + }); + it('should FAIL if configuration recorders have not delivered', (done) => { + const cache = createCache([describeConfigurationRecorders[0]], [describeConfigurationRecorderStatus[2]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if configuration recorders are not recording', (done) => { + const cache = createCache([describeConfigurationRecorders[0]], [describeConfigurationRecorderStatus[1]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if configuration recorders are not found', (done) => { + const cache = createNullCache(); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get configuration recorders', function (done) { + const cache = createErrorCache(); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get configuration recorder status', (done) => { + const cache = createRecorderStatusErrorCache([describeConfigurationRecorders[0]]); + configServiceEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }) + +}) diff --git a/plugins/aws/configservice/configServiceMissingBucket.js b/plugins/aws/configservice/configServiceMissingBucket.js new file mode 100644 index 000000000..df3d5c603 --- /dev/null +++ b/plugins/aws/configservice/configServiceMissingBucket.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Config Service Missing Bucket', + category: 'ConfigService', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that Amazon Config service is pointing an S3 bucket that is active in your account in order to save configuration information', + more_info: 'Amazon Config tracks changes within the configuration of your AWS resources and it regularly sends updated configuration details to an S3 bucket that you specify. '+ + 'When AWS Config is not referencing an active S3 bucket, the service is unable to send the recorded information to the designated bucket, therefore you lose the ability to audit later the configuration changes made within your AWS account.', + recommended_action: 'Ensure that Amazon Config service is referencing an active S3 bucket in order to save configuration information.', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/s3-bucket-policy.html', + apis: ['S3:listBuckets', 'ConfigService:describeDeliveryChannels', 'S3:headBucket'], + realtime_triggers: ['configservice:PutConfigurationRecorder','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + var defaultRegion = helpers.defaultRegion(settings); + + async.each(regions.configservice, function(region, rcb) { + var describeDeliveryChannels = helpers.addSource(cache, source, + ['configservice', 'describeDeliveryChannels', region]); + + if (!describeDeliveryChannels) return rcb(); + + if (describeDeliveryChannels.err || !describeDeliveryChannels.data) { + helpers.addResult(results, 3, + 'Unable to query Config delivery channels: ' + helpers.addError(describeDeliveryChannels), region); + return rcb(); + } + + if (!describeDeliveryChannels.data.length) { + helpers.addResult(results, 0, 'No Config delivery channels found', region); + return rcb(); + } + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + let deletedBuckets = []; + for (let record of describeDeliveryChannels.data) { + if (!record.s3BucketName) continue; + + var headBucket = helpers.addSource(cache, source, + ['s3', 'headBucket', defaultRegion, record.s3BucketName]); + + var bucketFound = listBuckets && listBuckets.data && listBuckets.data.length + ? listBuckets.data.some(bucket => bucket.Name === record.s3BucketName) : false; + + if (!bucketFound || (headBucket && headBucket.err && headBucket.err.message && + headBucket.err.message.toLowerCase().includes('not found'))) { + deletedBuckets.push(record.s3BucketName); + } else if (!headBucket || headBucket.err) { + helpers.addResult(results, 3, + 'Unable to query S3 bucket: ' + helpers.addError(headBucket), region, `arn:${awsOrGov}:s3:::` + record.s3BucketName); + continue; + } + } + + if (deletedBuckets.length) { + helpers.addResult(results, 2, + `Config Service is referencing these deleted buckets: ${deletedBuckets.join(', ')}`, + region); + + } else { + helpers.addResult(results, 0, + 'Config Service is not referencing any deleted bucket', + region); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/configservice/configServiceMissingBucket.spec.js b/plugins/aws/configservice/configServiceMissingBucket.spec.js new file mode 100644 index 000000000..98520e376 --- /dev/null +++ b/plugins/aws/configservice/configServiceMissingBucket.spec.js @@ -0,0 +1,106 @@ +var expect = require('chai').expect; +const configServiceMissingBucket = require('./configServiceMissingBucket'); + +const describeDeliveryChannels = [ + { + "name": "default", + "s3BucketName": "config-bucket-000011112222", + "s3KeyPrefix": "mine" + }, + { + "name": "default", + "s3BucketName": "amazon-connect-e39f272cf1f0", + "s3KeyPrefix": "mine" + } +]; + +const createCache = (records, headBucket, recordsErr, headBucketErr, buckets) => { + var name = (records && records.length) ? records[0].s3BucketName : null; + return { + configservice: { + describeDeliveryChannels: { + 'us-east-1': { + err: recordsErr, + data: records + }, + }, + }, + s3: { + listBuckets: { + 'us-east-1': { + err: 'err', + data: buckets + } + }, + headBucket: { + 'us-east-1': { + [name]: { + data: headBucket, + err: headBucketErr + } + } + } + + }, + } +} + +describe('configServiceMissingBucket', function () { + describe('run', function () { + it('should PASS if Config Service is not referencing any deleted bucket', function (done) { + const cache = createCache([describeDeliveryChannels[1]], null, null, null,[{ + "Name": "amazon-connect-e39f272cf1f0", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }]); + configServiceMissingBucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Config Service is not referencing any deleted bucket') + done(); + }); + }); + + it('should FAIL if Config Service is referencing these deleted buckets', function (done) { + const cache = createCache([describeDeliveryChannels[0]],null, null, { message: "A client error (404) occurred when calling the HeadBucket operation: Not Found.", code: 'NotFound' }); + configServiceMissingBucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Config Service is referencing these deleted buckets') + done(); + }); + }); + + it('should PASS if no Config delivery channels found', function (done) { + const cache = createCache([]); + configServiceMissingBucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Config delivery channels found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query S3 bucket', function (done) { + const cache = createCache(null, null, null, { message: "Unable to query S3 bucket" }); + configServiceMissingBucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query Config delivery channels', function (done) { + const cache = createCache(null, null, { message: "Unable to query Config delivery channels" }); + configServiceMissingBucket.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/configservice/servicesInUse.js b/plugins/aws/configservice/servicesInUse.js new file mode 100644 index 000000000..8fffa26d9 --- /dev/null +++ b/plugins/aws/configservice/servicesInUse.js @@ -0,0 +1,221 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Services In Use', + category: 'ConfigService', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that only permitted services are being used in you AWS cloud account.', + more_info: 'Use only permitted AWS services in your cloud account in order to meet security and compliance requirements within your organization.', + recommended_action: 'Delete resources from unpermitted services within your AWS cloud account.', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/how-does-config-work.html', + apis: ['ConfigService:describeConfigurationRecorderStatus', 'ConfigService:getDiscoveredResourceCounts'], + settings: { + permitted_services_list: { + name: 'Permitted Service List', + description: 'Comma separated list of permitted services such as ec2,iam,s3. Choose only one setting at a time.', + regex: '^.*$', + default:'' + }, + unpermitted_services_list: { + name: 'Unpermitted Service List', + description: 'Comma separated list of unpermitted services such as ec2,iam,s3. Choose only one setting at a time.', + regex: '^.*$', + default:'' + }, + }, + realtime_triggers: ['configservice:PutConfigurationRecorder','configservice:StartConfigurationRecorder','configservice:StopConfigurationRecorder'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + permitted_services_list:(settings.permitted_services_list || this.settings.permitted_services_list.default), + unpermitted_services_list:(settings.unpermitted_services_list || this.settings.unpermitted_services_list.default) + }; + + config.permitted_services_list = config.permitted_services_list.replace(/\s/g, ''); + config.unpermitted_services_list = config.unpermitted_services_list.replace(/\s/g, ''); + + if (!config.permitted_services_list.length && !config.unpermitted_services_list.length) return callback(null, results, source); + + var checkPermitted = (config.permitted_services_list.length > 0); + + config.permitted_services_list = config.permitted_services_list.toLowerCase().split(','); + + const allServices = { + 'accessanalyzer': 'aa', + 'appflow': 'af', + 'appmesh': 'am', + 'apprunner': 'ar', + 'athena': 'athena', + 'auditmanager': 'auditmngr', + 'apigateway': 'agway' , + 'cloudfront': 'cfn', + 'dynamodb': 'dynamo', + 'documentdb': 'docdb', + 'ec2': 'ec2', + 'ecr': 'ecr', + 'ecs': 'ecs', + 'efs': 'efs', + 'eks': 'eks', + 'emr': 'emr', + 'elasticache': 'ec', + 'elastictranscoder': 'et', + 'eventbridge': 'eb', + 'finspace': 'finspace', + 'firehose': 'firehose', + 'forecast': 'forecast', + 'frauddetector': 'fd', + 'fsx': 'fsx', + 'glue': 'glue', + 'healthake': 'hlake', + 'imagebuilder': 'ib', + 'kendra': 'kendra', + 'lex': 'lex', + 'location': 'location', + 'managedblockchain': 'mbc', + 'memorydb': 'memdb', + 'mq': 'mq', + 'msk': 'msk', + 'mwaa': 'mwaa', + 'neptune': 'neptune', + 'guardduty': 'gd', + 'elasticsearch': 'es', + 'opensearch': 'opensearch', + 'organizations': 'orgs', + 'proton': 'proton', + 'route53': 'r53', + 'qldb': 'qldb', + 'kinesis': 'kinesis', + 'redshift': 'redshift', + 'rds': 'rds', + 'sagemaker': 'sagemaker', + 's3': 's3', + 'autoscaling': 'as', + 'backup': 'backup', + 'acm': 'acm', + 'cloudformation': 'cfn', + 'cloudwatch': 'cw', + 'cloudwatchlogs': 'cwl', + 'codeartifact': 'ca', + 'codestar': 'cs', + 'comprehend': 'comprehend', + 'computeoptimizer': 'co', + 'dms': 'dms', + 'cloudtrail': 'ct', + 'codebuild': 'cb', + 'codedeploy': 'cd', + 'codepipeline': 'cp', + 'config': 'config', + 'connect': 'connect', + 'devopsguru': 'dog', + 'elasticbeanstalk': 'ebs', + 'iam': 'iam', + 'kms': 'kms', + 'lambda': 'lambda', + 'networkfirewall': 'nf', + 'secretsmanager': 'sm', + 'servicecatalog': 'sc', + 'shield': 'shield', + 'sns': 'sns', + 'sqs': 'sqs', + 'stepfunctions': 'sf', + 'ssm': 'ssm', + 'waf': 'waf', + 'timestreamwrite':'tsw', + 'transfer': 'transfer', + 'translate': 'translate', + 'workspaces': 'workspaces', + 'wafv2': 'wafv2', + 'xray': 'xray', + 'elasticloadbalancing': 'elb', + 'elasticloadbalancingv2': 'elbv2' + }; + + async.each(regions.configservice, function(region, rcb){ + var configRecorderStatus = helpers.addSource(cache, source, + ['configservice', 'describeConfigurationRecorderStatus', region]); + + if (!configRecorderStatus) { + return rcb(); + } + + if (configRecorderStatus.err || !configRecorderStatus.data) { + helpers.addResult(results, 3, + 'Unable to query config service: ' + helpers.addError(configRecorderStatus), region); + return rcb(); + } + + if (!configRecorderStatus.data.length) { + helpers.addResult(results, 2, + 'Config service is not enabled', region); + return rcb(); + } + + if (!configRecorderStatus.data[0].recording) { + helpers.addResult(results, 2, + 'Config service is not recording', region); + return rcb(); + } + + if (!configRecorderStatus.data[0].lastStatus || + (configRecorderStatus.data[0].lastStatus.toUpperCase() !== 'SUCCESS' && + configRecorderStatus.data[0].lastStatus.toUpperCase() !== 'PENDING')) { + helpers.addResult(results, 2, + 'Config Service is configured, and recording, but not delivering properly', region); + return rcb(); + } + + var discoveredResources = helpers.addSource(cache, source, + ['configservice', 'getDiscoveredResourceCounts', region]); + + if (discoveredResources.err || !discoveredResources.data) { + helpers.addResult(results, 3, + 'Unable to query for Discovered Resources: ' + helpers.addError(discoveredResources)); + return rcb(); + } + + let usedServices = []; + for (let resource of discoveredResources.data){ + if (resource.resourceType){ + let newResource = resource.resourceType.split('::'); + if (newResource.length > 1 && !usedServices.includes(newResource[1].toLowerCase())) usedServices.push(newResource[1].toLowerCase()); + } + } + + let usedServicesShorthand = []; + for (let value of usedServices){ + if (allServices[value]) usedServicesShorthand.push(allServices[value]); + } + + if (checkPermitted) { + usedServicesShorthand = usedServicesShorthand.filter(service => !config.permitted_services_list.includes(service)); + if (usedServicesShorthand.length){ + helpers.addResult(results, 2, + 'These unpermitted services are being used: ' + usedServicesShorthand.join(','), region); + } else { + helpers.addResult(results, 0, + 'Only allowed services are being used', region); + } + } else { + usedServicesShorthand = usedServicesShorthand.filter(service => config.unpermitted_services_list.includes(service)); + if (usedServicesShorthand.length){ + helpers.addResult(results, 2, + 'These unpermitted services are being used: ' + usedServicesShorthand.join(','), region); + } else { + helpers.addResult(results, 0, + 'Only allowed services are being used', region); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/configservice/servicesInUse.spec.js b/plugins/aws/configservice/servicesInUse.spec.js new file mode 100644 index 000000000..bcb279d4a --- /dev/null +++ b/plugins/aws/configservice/servicesInUse.spec.js @@ -0,0 +1,312 @@ +var expect = require('chai').expect; +const servicesInUse = require('./servicesInUse'); + +const describeConfigurationRecorderStatus = [ + { + "name": "default", + "lastStartTime": "2022-09-01T15:01:18.132000+05:00", + "lastStopTime": "2022-06-10T18:14:00.397000+05:00", + "recording": true, + "lastStatus": "SUCCESS", + "lastStatusChangeTime": "2022-09-01T15:01:30.271000+05:00" + }, + +] + +const getDiscoveredResourceCounts =[ + { + "resourceCounts": [ + { + "resourceType": "AWS::IAM::Role", + "count": 100 + }, + { + "resourceType": "AWS::IAM::Policy", + "count": 48 + }, + { + "resourceType": "AWS::KMS::Key", + "count": 28 + }, + { + "resourceType": "AWS::S3::Bucket", + "count": 24 + }, + { + "resourceType": "AWS::EC2::SecurityGroup", + "count": 17 + }, + { + "resourceType": "AWS::CodeDeploy::DeploymentConfig", + "count": 17 + }, + { + "resourceType": "AWS::IAM::User", + "count": 11 + }, + { + "resourceType": "AWS::EC2::Subnet", + "count": 8 + }, + { + "resourceType": "AWS::CloudWatch::Alarm", + "count": 5 + }, + { + "resourceType": "AWS::EC2::RouteTable", + "count": 4 + }, + { + "resourceType": "AWS::EC2::VPC", + "count": 3 + }, + { + "resourceType": "AWS::EC2::NetworkInterface", + "count": 3 + }, + { + "resourceType": "AWS::EC2::NetworkAcl", + "count": 3 + }, + { + "resourceType": "AWS::SNS::Topic", + "count": 3 + }, + { + "resourceType": "AWS::Route53Resolver::ResolverRuleAssociation", + "count": 3 + }, + { + "resourceType": "AWS::EC2::InternetGateway", + "count": 2 + }, + { + "resourceType": "AWS::AccessAnalyzer::Analyzer", + "count": 2 + }, + { + "resourceType": "AWS::DMS::ReplicationSubnetGroup", + "count": 2 + }, + { + "resourceType": "AWS::CloudFormation::Stack", + "count": 2 + }, + { + "resourceType": "AWS::CloudTrail::Trail", + "count": 2 + }, + { + "resourceType": "AWS::ApiGatewayV2::Stage", + "count": 2 + }, + { + "resourceType": "AWS::Backup::BackupVault", + "count": 2 + }, + { + "resourceType": "AWS::SQS::Queue", + "count": 1 + }, + { + "resourceType": "AWS::EC2::Instance", + "count": 1 + }, + { + "resourceType": "AWS::Route53Resolver::ResolverRule", + "count": 1 + }, + { + "resourceType": "AWS::RDS::DBSubnetGroup", + "count": 1 + }, + { + "resourceType": "AWS::Redshift::ClusterParameterGroup", + "count": 1 + }, + { + "resourceType": "AWS::S3::AccountPublicAccessBlock", + "count": 1 + }, + { + "resourceType": "AWS::ApiGateway::Stage", + "count": 1 + }, + { + "resourceType": "AWS::RDS::DBSnapshot", + "count": 1 + }, + { + "resourceType": "AWS::SSM::AssociationCompliance", + "count": 1 + }, + { + "resourceType": "AWS::EC2::LaunchTemplate", + "count": 1 + }, + { + "resourceType": "AWS::Lambda::Function", + "count": 1 + }, + { + "resourceType": "AWS::Redshift::ClusterSubnetGroup", + "count": 1 + }, + { + "resourceType": "AWS::RDS::DBSecurityGroup", + "count": 1 + }, + { + "resourceType": "AWS::CodeBuild::Project", + "count": 1 + }, + { + "resourceType": "AWS::ApiGatewayV2::Api", + "count": 1 + }, + { + "resourceType": "AWS::EC2::Volume", + "count": 1 + }, + { + "resourceType": "AWS::ECR::Repository", + "count": 1 + }, + { + "resourceType": "AWS::IAM::Group", + "count": 1 + }, + { + "resourceType": "AWS::SSM::ManagedInstanceInventory", + "count": 1 + }, + { + "resourceType": "AWS::ApiGateway::RestApi", + "count": 1 + } + ] + }, + { + "totalDiscoveredResources": 311, + "resourceCounts": [ + { + "resourceType": "AWS::IAM::Role", + "count": 100 + }, + { + "resourceType": "AWS::IAM::Policy", + "count": 48 + }, + { + "resourceType": "AWS::EC2::SecurityGroup", + "count": 17 + }, + { + "resourceType": "AWS::IAM::User", + "count": 11 + }, + { + "resourceType": "AWS::EC2::Subnet", + "count": 8 + }, + { + "resourceType": "AWS::CloudWatch::Alarm", + "count": 5 + }, + { + "resourceType": "AWS::EC2::RouteTable", + "count": 4 + }, + { + "resourceType": "AWS::EC2::VPC", + "count": 3 + }, + { + "resourceType": "AWS::EC2::NetworkInterface", + "count": 3 + }, + { + "resourceType": "AWS::EC2::NetworkAcl", + "count": 3 + }, + { + "resourceType": "AWS::EC2::Volume", + "count": 1 + }, + { + "resourceType": "AWS::IAM::Group", + "count": 1 + }, + + ] + } +] + +const createCache = (recorderStatus, resourcesCount, recordStatusErr, resourcesCountErr) => { + return { + configservice: { + describeConfigurationRecorderStatus: { + 'us-east-1': { + data: recorderStatus, + err: recordStatusErr + }, + }, + getDiscoveredResourceCounts: { + 'us-east-1': { + data: resourcesCount, + err: resourcesCountErr + }, + }, + }, + }; +}; + +describe('servicesInUse', () => { + describe('run', () => { + it('should PASS if only allowed services are being usedd', (done) => { + const cache = createCache([describeConfigurationRecorderStatus[0]], getDiscoveredResourceCounts[1]["resourceCounts"]); + servicesInUse.run(cache, {permitted_services_list: 'iam, ec2, cw, cfn'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Only allowed services are being used'); + done(); + }); + }); + + it('should FAIL if unpermitted services are being used', (done) => { + const cache = createCache([describeConfigurationRecorderStatus[0]], getDiscoveredResourceCounts[0]["resourceCounts"]); + servicesInUse.run(cache, {unpermitted_services_list: 'iam'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('These unpermitted services are being used'); + done(); + }); + }); + + it('should FAIL if Config service is not enabled', function (done) { + const cache = createCache([]); + servicesInUse.run(cache, {permitted_services_list: 'iam, ec2, cw, cfn'}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Config service is not enabled'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query config service', (done) => { + const cache = createCache(null, null, null); + servicesInUse.run(cache, {permitted_services_list: 'iam, ec2, cw, cfn'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query config service'); + done(); + }); + }); + + }) + +}) diff --git a/plugins/aws/connect/customerProfilesDomainEncrypted.js b/plugins/aws/connect/customerProfilesDomainEncrypted.js new file mode 100644 index 000000000..ca53ba859 --- /dev/null +++ b/plugins/aws/connect/customerProfilesDomainEncrypted.js @@ -0,0 +1,135 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Customer Profiles Domain Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS Connect Customer Profiles domains are using desired encryption level.', + more_info: 'Customer profiles domain is a container for all data, such as customer profiles, object types, profile keys, and encryption keys. To encrypt this data, use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + recommended_action: 'Enabled data encryption feature for Connect Customer Profiles', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/enable-customer-profiles.html', + apis: ['CustomerProfiles:listDomains', 'CustomerProfiles:getDomain', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + customer_profiles_desired_encryption_level: { + name: 'Connect Customer Profiles Encrypted', + description: 'In order (lowest to highest) awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['customerprofiles:CreateDomain', 'customerprofiles:UpdateDomain', 'customerprofile:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.customer_profiles_desired_encryption_level || this.settings.customer_profiles_desired_encryption_level.default + }; + + // Skip encryption check if default awskms is set + var skipEncryptionCheck = config.desiredEncryptionLevelString === 'awskms'; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.customerprofiles, function(region, rcb){ + var listDomains = helpers.addSource(cache, source, + ['customerprofiles', 'listDomains', region]); + + if (!listDomains) return rcb(); + + if (listDomains.err || !listDomains.data) { + helpers.addResult(results, 3, + 'Unable to query customerprofiles domain: ' + helpers.addError(listDomains), region); + return rcb(); + } + + if (!listDomains.data.length) { + helpers.addResult(results, 0, 'No customerprofiles domain found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let domain of listDomains.data) { + if (!domain.DomainName) continue; + + let resource = `arn:${awsOrGov}:profile:${region}:${accountId}:domain/${domain.DomainName}`; + + var getDomain = helpers.addSource(cache, source, + ['customerprofiles', 'getDomain', region, domain.DomainName]); + + if (!getDomain || getDomain.err || !getDomain.data) { + helpers.addResult(results, 3, + `Unable to get customerprofiles domain description: ${helpers.addError(getDomain)}`, + region, resource); + continue; + } + if (skipEncryptionCheck) { + helpers.addResult(results, 0, + 'Customer Profile domain is encrypted with desired encryption level.', + region, resource); + } else { + if (getDomain.data.DefaultEncryptionKey) { + let DefaultEncryptionKey = getDomain.data.DefaultEncryptionKey; + var keyId = DefaultEncryptionKey.split('/')[1] ? DefaultEncryptionKey.split('/')[1] : DefaultEncryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, DefaultEncryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else if (domain.DomainName.startsWith('amazon-connect') && getDomain.data.DefaultEncryptionKey == null) { + helpers.addResult(results, 2, + `Customer Profile domain is encrypted with awskms \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + continue; + } else { + helpers.addResult(results, 3, + 'Unable to find Customer Profile domain encryption key', region, resource); + continue; + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/connect/customerProfilesDomainEncrypted.spec.js b/plugins/aws/connect/customerProfilesDomainEncrypted.spec.js new file mode 100644 index 000000000..b11e38cf0 --- /dev/null +++ b/plugins/aws/connect/customerProfilesDomainEncrypted.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var customerProfilesDomainEncrypted = require('./customerProfilesDomainEncrypted'); + +const listDomains = [ + { + "DomainName": "mine2", + "CreatedAt": "2021-11-19T14:43:41.751000+05:00", + "LastUpdatedAt": "2021-11-19T14:43:41.751000+05:00", + "Tags": {} + } +]; + +const getDomain = [ + { + "DomainName": "mine2", + "DefaultExpirationDays": 366, + "DefaultEncryptionKey": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "Stats": { + "ProfileCount": 0, + "MeteringProfileCount": 0, + "ObjectCount": 0, + "TotalSize": 0 + }, + "CreatedAt": "2021-11-19T14:43:41.751000+05:00", + "LastUpdatedAt": "2021-11-19T14:43:41.751000+05:00", + "Tags": {} + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (domains, keys, getDomain, describeKey, domainsErr, keysErr, describeKeyErr, getDomainErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var DomainName = (domains && domains.length) ? domains[0].DomainName: null; + return { + customerprofiles: { + listDomains: { + 'us-east-1': { + err: domainsErr, + data: domains + }, + }, + getDomain: { + 'us-east-1': { + [DomainName]: { + data: getDomain, + err: getDomainErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('customerProfilesDomainEncrypted', function () { + describe('run', function () { + it('should PASS if CustomerProfiles domain is encrypted with desired encryption level', function (done) { + const cache = createCache(listDomains, listKeys, getDomain[0], describeKey[0]); + customerProfilesDomainEncrypted.run(cache, { customer_profiles_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if CustomerProfiles domain is not encrypted with desired encryption level', function (done) { + const cache = createCache(listDomains,listKeys, getDomain[0], describeKey[1]); + customerProfilesDomainEncrypted.run(cache, { customer_profiles_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no CustomerProfiles domain found', function (done) { + const cache = createCache([]); + customerProfilesDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list CustomerProfiles domain', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list CustomerProfiles domain" }); + customerProfilesDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDomains, null, null, null, { message: "Unable to list KMS keys" }); + customerProfilesDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/connect/instanceAttachmentsEncrypted.js b/plugins/aws/connect/instanceAttachmentsEncrypted.js new file mode 100644 index 000000000..cc6abdb1f --- /dev/null +++ b/plugins/aws/connect/instanceAttachmentsEncrypted.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Instance Attachments Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Connect instances have encryption enabled for attachments being saved on S3.', + more_info: 'You can configure Amazon Connect instance to save attachments on S3. When you save ' + + 'such data on S3, enable encryption for the data and use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/set-up-recordings.html', + recommended_action: 'Modify Connect instance data storage configuration and enable encryption for attachments', + apis: ['Connect:listInstances', 'Connect:instanceAttachmentStorageConfigs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + connect_attachments_encryption_level: { + name: 'Connect Attachments Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['connect:CreateInstance', 'connect:AssociateInstanceStorageConfig', 'connect:UpdateInstanceStorageConfig', 'connect:DeleteInstance', 'connect:DisassociateInstanceStorageConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.connect_attachments_encryption_level || this.settings.connect_attachments_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listInstances = helpers.addSource(cache, source, + ['connect', 'listInstances', region]); + + if (!listInstances) return rcb(); + + if (listInstances.err || !listInstances.data) { + helpers.addResult(results, 3, + `Unable to query Connect instances: ${helpers.addError(listInstances)}`, region); + return rcb(); + } + + if (!listInstances.data.length) { + helpers.addResult(results, 0, 'No Connect instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let instance of listInstances.data) { + if (!instance.Arn) continue; + + var resource = instance.Arn; + + var instanceAttachmentStorageConfigs = helpers.addSource(cache, source, + ['connect', 'instanceAttachmentStorageConfigs', region, instance.Id]); + + if (!instanceAttachmentStorageConfigs || instanceAttachmentStorageConfigs.err || !instanceAttachmentStorageConfigs.data || + !instanceAttachmentStorageConfigs.data.StorageConfigs) { + helpers.addResult(results, 3, + `Unable to describe Connect instance Attachments storage config: ${helpers.addError(instanceAttachmentStorageConfigs)}`, + region, resource); + continue; + } + + if (!instanceAttachmentStorageConfigs.data.StorageConfigs.length) { + helpers.addResult(results, 0, + 'Connect instance does not have any storage config for attachments', + region, resource); + continue; + } + + let storageConfig = instanceAttachmentStorageConfigs.data.StorageConfigs[0]; + + if (storageConfig.S3Config) { + if (storageConfig.S3Config.EncryptionConfig && + storageConfig.S3Config.EncryptionConfig.KeyId) { + let kmsKeyArn = storageConfig.S3Config.EncryptionConfig.KeyId; + let keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Connect instance is using ${currentEncryptionLevelString} for Attachments encryption\ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Connect instance is using ${currentEncryptionLevelString} for Attachments encryption\ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Connect instance does not have encryption enabled for attachments', + region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find Connect instance attachments S3 config', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/instanceAttachmentsEncrypted.spec.js b/plugins/aws/connect/instanceAttachmentsEncrypted.spec.js new file mode 100644 index 000000000..2f9e9965f --- /dev/null +++ b/plugins/aws/connect/instanceAttachmentsEncrypted.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const instanceAttachmentsEncrypted = require('./instanceAttachmentsEncrypted'); + +const listInstances = [ + { + "Id": "9da26b8e-8f9e-4af2-8717-6f79913f2439", + "Arn": "arn:aws:connect:us-east-1:000111222333:instance/9da26b8e-8f9e-4af2-8717-6f79913f2439", + "IdentityManagementType": "CONNECT_MANAGED", + "InstanceAlias": "akhtar", + "CreatedTime": "2021-11-24T17:07:05+05:00", + "ServiceRole": "arn:aws:iam::000111222333:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_XYUbWO6kTpYfc9uUNk5i", + "InstanceStatus": "ACTIVE", + "InboundCallsEnabled": true, + "OutboundCallsEnabled": false + } +]; + +const instanceAttachmentStorageConfigs = [ + { + "StorageConfigs": [ + { + "AssociationId": "6b42beeca11d96190f6a95c11cb21b7db7d9ed59866e7db3d5f200b2eed3cc54", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-6b9ee809614c", + "BucketPrefix": "connect/akhtar/Attachments", + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + } + ] + }, + { + "StorageConfigs": [ + { + "AssociationId": "6b42beeca11d96190f6a95c11cb21b7db7d9ed59866e7db3d5f200b2eed3cc54", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-6b9ee809614c", + "BucketPrefix": "connect/akhtar/Attachments", + } + } + ] + }, + { + "StorageConfigs": [] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (instances, storageConfig, keys, describeKey, instancesErr, keysErr) => { + var instanceId = (instances && instances.length) ? instances[0].Id : null; + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + connect: { + listInstances: { + 'us-east-1': { + data: instances, + err: instancesErr + }, + }, + instanceAttachmentStorageConfigs: { + 'us-east-1': { + [instanceId]: { + data: storageConfig + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + } + } + }; +}; + +describe('instanceAttachmentsEncrypted', function () { + describe('run', function () { + it('should FAIL if Connect instance is not using desired encryption level', function (done) { + const cache = createCache(listInstances, instanceAttachmentStorageConfigs[0], listKeys, describeKey[1]); + instanceAttachmentsEncrypted.run(cache, {connect_attachments_encryption_level : 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Connect instance does not have encryption enabled for attachments', function (done) { + const cache = createCache(listInstances, instanceAttachmentStorageConfigs[1], listKeys, describeKey[1]); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Connect instance is using desired encryption level', function (done) { + const cache = createCache(listInstances, instanceAttachmentStorageConfigs[0], listKeys, describeKey[0]); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Connect instance does not have any storage config for attachments', function (done) { + const cache = createCache(listInstances, instanceAttachmentStorageConfigs[2], listKeys, describeKey[0]); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no Connect instances found', function (done) { + const cache = createCache([]); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN Unable to query Connect instances', function (done) { + const cache = createCache([], null, listKeys, describeKey[0], { message: 'Unable to find data' }); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN Unable to query KMS keys', function (done) { + const cache = createCache(listInstances, instanceAttachmentStorageConfigs[2], [], null, null, { message: 'Unable to find data' }); + instanceAttachmentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/connect/instanceCallRecordingEncrypted.js b/plugins/aws/connect/instanceCallRecordingEncrypted.js new file mode 100644 index 000000000..6311f158a --- /dev/null +++ b/plugins/aws/connect/instanceCallRecordingEncrypted.js @@ -0,0 +1,137 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Instance Call Recording Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Connect instances have encryption enabled for call recordings being saved on S3.', + more_info: 'You can configure Amazon Connect instance to save recordings for incoming call to be saved on S3. When you save ' + + 'such data on S3, enable encryption for the data and use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/encryption-at-rest.html', + recommended_action: 'Modify Connect instance data storage configuration and enable encryption for call recordings', + apis: ['Connect:listInstances', 'Connect:listInstanceCallRecordingStorageConfigs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + connect_call_recording_encryption_level: { + name: 'Connect Call Resording Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['connect:CreateInstance', 'connect:AssociateInstanceStorageConfig', 'connect:UpdateInstanceStorageConfig','connect:DeleteInstance', 'connect:DisassociateInstanceStorageConfig'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.connect_call_recording_encryption_level || this.settings.connect_call_recording_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listInstances = helpers.addSource(cache, source, + ['connect', 'listInstances', region]); + + if (!listInstances) return rcb(); + + if (listInstances.err || !listInstances.data) { + helpers.addResult(results, 3, + `Unable to query Connect instances: ${helpers.addError(listInstances)}`, region); + return rcb(); + } + + if (!listInstances.data.length) { + helpers.addResult(results, 0, 'No Connect instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let instance of listInstances.data) { + if (!instance.Arn) continue; + + var resource = instance.Arn; + + var listInstanceCallRecordingStorageConfigs = helpers.addSource(cache, source, + ['connect', 'listInstanceCallRecordingStorageConfigs', region, instance.Id]); + + if (!listInstanceCallRecordingStorageConfigs || listInstanceCallRecordingStorageConfigs.err || !listInstanceCallRecordingStorageConfigs.data || + !listInstanceCallRecordingStorageConfigs.data.StorageConfigs) { + helpers.addResult(results, 3, + `Unable to describe Connect instance call recording storage config: ${helpers.addError(listInstanceCallRecordingStorageConfigs)}`, + region, resource); + continue; + } + + if (!listInstanceCallRecordingStorageConfigs.data.StorageConfigs.length) { + helpers.addResult(results, 0, + 'Connect instance does not have any storage config for call recordings', + region, resource); + continue; + } + + let storageConfig = listInstanceCallRecordingStorageConfigs.data.StorageConfigs[0]; + + if (storageConfig.S3Config) { + if (storageConfig.S3Config.EncryptionConfig && + storageConfig.S3Config.EncryptionConfig.KeyId) { + let kmsKeyArn = storageConfig.S3Config.EncryptionConfig.KeyId; + let keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Connect instance is using ${currentEncryptionLevelString} for call recordings encryption\ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Connect instance is using ${currentEncryptionLevelString} for call recordings encryption\ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Connect instance does not have encryption enabled for call recordings', + region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find Connect instance call recording S3 config', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/instanceCallRecordingEncrypted.spec.js b/plugins/aws/connect/instanceCallRecordingEncrypted.spec.js new file mode 100644 index 000000000..bb7b9b081 --- /dev/null +++ b/plugins/aws/connect/instanceCallRecordingEncrypted.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const instanceCallRecordingEncrypted = require('./instanceCallRecordingEncrypted'); + +const listInstances = [ + { + "Id": "9da26b8e-8f9e-4af2-8717-6f79913f2439", + "Arn": "arn:aws:connect:us-east-1:000111222333:instance/9da26b8e-8f9e-4af2-8717-6f79913f2439", + "IdentityManagementType": "CONNECT_MANAGED", + "InstanceAlias": "akhtar", + "CreatedTime": "2021-11-24T17:07:05+05:00", + "ServiceRole": "arn:aws:iam::000111222333:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_XYUbWO6kTpYfc9uUNk5i", + "InstanceStatus": "ACTIVE", + "InboundCallsEnabled": true, + "OutboundCallsEnabled": false + } +]; + +const listInstanceCallRecordingStorageConfigs = [ + { + "StorageConfigs": [ + { + "AssociationId": "519cada57abf190bef871bb031d6c2813a0f76ffd638e531d1d7dd77984bd9a2", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-a72b5bc76263", + "BucketPrefix": "connect/akhtar/CallRecordings", + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000111222333:key/2fe84bbe-30c3-4535-92b0-6d593085c84f" + } + } + } + ] + }, + { + "StorageConfigs": [ + { + "AssociationId": "519cada57abf190bef871bb031d6c2813a0f76ffd638e531d1d7dd77984bd9a2", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-a72b5bc76263", + "BucketPrefix": "connect/akhtar/CallRecordings", + } + } + ] + }, + { + "StorageConfigs": [] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/2fe84bbe-30c3-4535-92b0-6d593085c84f", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/2fe84bbe-30c3-4535-92b0-6d593085c84f", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "2fe84bbe-30c3-4535-92b0-6d593085c84f", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/2fe84bbe-30c3-4535-92b0-6d593085c84f" + } +]; + +const createCache = (instances, storageConfig, keys, describeKey, instancesErr, keysErr) => { + var instanceId = (instances && instances.length) ? instances[0].Id : null; + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + connect: { + listInstances: { + 'us-east-1': { + data: instances, + err: instancesErr + }, + }, + listInstanceCallRecordingStorageConfigs: { + 'us-east-1': { + [instanceId]: { + data: storageConfig + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + } + } + }; +}; + +describe('instanceCallRecordingEncrypted', function () { + describe('run', function () { + it('should FAIL if Connect instance is not using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceCallRecordingStorageConfigs[0], listKeys, describeKey[1]); + instanceCallRecordingEncrypted.run(cache, {connect_call_recording_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Connect instance does not have encryption enabled for call recordings', function (done) { + const cache = createCache(listInstances, listInstanceCallRecordingStorageConfigs[1], listKeys, describeKey[1]); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Connect instance is using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceCallRecordingStorageConfigs[0], listKeys, describeKey[0]); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Connect instance does not have any storage config for call recordings', function (done) { + const cache = createCache(listInstances, listInstanceCallRecordingStorageConfigs[2], listKeys, describeKey[0]); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no Connect instances found', function (done) { + const cache = createCache([]); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN Unable to query Connect instances', function (done) { + const cache = createCache([], null, listKeys, describeKey[0], { message: 'Unable to find data' }); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN Unable to query KMS keys', function (done) { + const cache = createCache(listInstances, listInstanceCallRecordingStorageConfigs[2], [], null, null, { message: 'Unable to find data' }); + instanceCallRecordingEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/connect/instanceMediaStreamsEncrypted.js b/plugins/aws/connect/instanceMediaStreamsEncrypted.js new file mode 100644 index 000000000..2592d9716 --- /dev/null +++ b/plugins/aws/connect/instanceMediaStreamsEncrypted.js @@ -0,0 +1,133 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Instance Media Streams Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Connect instances have encryption enabled for media streams being saved on Kinesis Video Stream.', + more_info: 'In Amazon Connect, you can capture customer audio during an interaction with your contact center by sending the audio to a Kinesis video stream. ' + + 'All data put into a Kinesis video stream is encrypted at rest using AWS-managed KMS keys. Use customer-managed keys instead, in order to meet regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/enable-live-media-streams.html', + recommended_action: 'Modify Connect instance data storage configuration and enable encryption for media streams', + apis: ['Connect:listInstances', 'Connect:listInstanceMediaStreamStorageConfigs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + connect_media_streams_encryption_level: { + name: 'Connect Media Streams Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['connect:CreateInstance', 'connect:AssociateInstanceStorageConfig', 'connect:UpdateInstanceStorageConfig','connect:DeleteInstance', 'connect:DisassociateInstanceStorageConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.connect_media_streams_encryption_level || this.settings.connect_media_streams_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listInstances = helpers.addSource(cache, source, + ['connect', 'listInstances', region]); + + if (!listInstances) return rcb(); + + if (listInstances.err || !listInstances.data) { + helpers.addResult(results, 3, + `Unable to query Connect instances: ${helpers.addError(listInstances)}`, region); + return rcb(); + } + + if (!listInstances.data.length) { + helpers.addResult(results, 0, 'No Connect instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let instance of listInstances.data) { + if (!instance.Arn) continue; + + var resource = instance.Arn; + + var listInstanceMediaStreamStorageConfigs = helpers.addSource(cache, source, + ['connect', 'listInstanceMediaStreamStorageConfigs', region, instance.Id]); + + if (!listInstanceMediaStreamStorageConfigs || listInstanceMediaStreamStorageConfigs.err || !listInstanceMediaStreamStorageConfigs.data || + !listInstanceMediaStreamStorageConfigs.data.StorageConfigs) { + helpers.addResult(results, 3, + `Unable to describe Connect instance media streams storage config: ${helpers.addError(listInstanceMediaStreamStorageConfigs)}`, + region, resource); + continue; + } + + if (!listInstanceMediaStreamStorageConfigs.data.StorageConfigs.length) { + helpers.addResult(results, 0, + 'Connect instance does not have any media streams enabled', + region, resource); + continue; + } + + let storageConfig = listInstanceMediaStreamStorageConfigs.data.StorageConfigs[0]; + + if (storageConfig.KinesisVideoStreamConfig) { + if (storageConfig.KinesisVideoStreamConfig.EncryptionConfig && + storageConfig.KinesisVideoStreamConfig.EncryptionConfig.KeyId) { + let kmsKeyArn = storageConfig.KinesisVideoStreamConfig.EncryptionConfig.KeyId; + let keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel= 2; //awskms + } + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Connect instance is using ${currentEncryptionLevelString} for media streams encryption\ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Connect instance is using ${currentEncryptionLevelString} for media streams encryption\ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + + } else { + helpers.addResult(results, 3, + 'Unable to find Connect instance media streams Config', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/instanceMediaStreamsEncrypted.spec.js b/plugins/aws/connect/instanceMediaStreamsEncrypted.spec.js new file mode 100644 index 000000000..8024d015d --- /dev/null +++ b/plugins/aws/connect/instanceMediaStreamsEncrypted.spec.js @@ -0,0 +1,197 @@ +var expect = require('chai').expect; +const instanceMediaStreamsEncrypted = require('./instanceMediaStreamsEncrypted'); + +const listInstances = [ + { + "Id": "9da26b8e-8f9e-4af2-8717-6f79913f2439", + "Arn": "arn:aws:connect:us-east-1:000111222333:instance/9da26b8e-8f9e-4af2-8717-6f79913f2439", + "IdentityManagementType": "CONNECT_MANAGED", + "InstanceAlias": "akhtar", + "CreatedTime": "2021-11-24T17:07:05+05:00", + "ServiceRole": "arn:aws:iam::000111222333:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_XYUbWO6kTpYfc9uUNk5i", + "InstanceStatus": "ACTIVE", + "InboundCallsEnabled": true, + "OutboundCallsEnabled": false + } +]; + +const listInstanceMediaStreamStorageConfigs = [ + { + "StorageConfigs": [ + { + "AssociationId": "d16cbba98243e2b092a214f262cb71ede51a8c94d2d58a02974d8590b43ddc8e", + "StorageType": "KINESIS_VIDEO_STREAM", + "KinesisVideoStreamConfig": { + "Prefix": "dedwed-connect-akhtar-contact-", + "RetentionPeriodHours": 1, + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + } + ] + }, + { + "StorageConfigs": [ + { + "AssociationId": "d16cbba98243e2b092a214f262cb71ede51a8c94d2d58a02974d8590b43ddc8e", + "StorageType": "KINESIS_VIDEO_STREAM", + "S3Config": { + "KinesisVideoStreamConfig": { + "Prefix": "dedwed-connect-akhtar-contact-", + "RetentionPeriodHours": 1, + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + } + } + ] + }, + { + "StorageConfigs": [] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (instances, storageConfig, keys, describeKey, instancesErr, keysErr) => { + var instanceId = (instances && instances.length) ? instances[0].Id : null; + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + connect: { + listInstances: { + 'us-east-1': { + data: instances, + err: instancesErr + }, + }, + listInstanceMediaStreamStorageConfigs: { + 'us-east-1': { + [instanceId]: { + data: storageConfig + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + } + } + }; +}; + +describe('instanceMediaStreamsEncrypted', function () { + describe('run', function () { + it('should FAIL if Connect instance is not using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceMediaStreamStorageConfigs[0], listKeys, describeKey[1]); + instanceMediaStreamsEncrypted.run(cache, {connect_media_streams_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Connect instance is using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceMediaStreamStorageConfigs[0], listKeys, describeKey[0]); + instanceMediaStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Connect instance does not have any storage config for media streams', function (done) { + const cache = createCache(listInstances, listInstanceMediaStreamStorageConfigs[2], listKeys, describeKey[0]); + instanceMediaStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no Connect instances found', function (done) { + const cache = createCache([]); + instanceMediaStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN Unable to query Connect instances', function (done) { + const cache = createCache([], null, listKeys, describeKey[0], { message: 'Unable to find data' }); + instanceMediaStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN Unable to query KMS keys', function (done) { + const cache = createCache(listInstances, listInstanceMediaStreamStorageConfigs[2], [], null, null, { message: 'Unable to find data' }); + instanceMediaStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/connect/instanceReportsEncrypted.js b/plugins/aws/connect/instanceReportsEncrypted.js new file mode 100644 index 000000000..d4cc6a1b5 --- /dev/null +++ b/plugins/aws/connect/instanceReportsEncrypted.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Instance Exported Reports Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Connect instances have encryption enabled for exported reports being saved on S3.', + more_info: 'You can configure Amazon Connect instance to save exported reports on S3. When you save ' + + 'such data on S3, enable encryption for the data and use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/encryption-at-rest.html', + recommended_action: 'Modify Connect instance data storage configuration and enable encryption for exported reports', + apis: ['Connect:listInstances', 'Connect:listInstanceExportedReportStorageConfigs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + connect_exported_reports_encryption_level: { + name: 'Connect Exported Reports Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['connect:CreateInstance', 'connect:AssociateInstanceStorageConfig', 'connect:UpdateInstanceStorageConfig','connect:DeleteInstance', 'connect:DisassociateInstanceStorageConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.connect_exported_reports_encryption_level || this.settings.connect_exported_reports_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listInstances = helpers.addSource(cache, source, + ['connect', 'listInstances', region]); + + if (!listInstances) return rcb(); + + if (listInstances.err || !listInstances.data) { + helpers.addResult(results, 3, + `Unable to query Connect instances: ${helpers.addError(listInstances)}`, region); + return rcb(); + } + + if (!listInstances.data.length) { + helpers.addResult(results, 0, 'No Connect instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let instance of listInstances.data) { + if (!instance.Arn) continue; + + var resource = instance.Arn; + + var listInstanceExportedReportStorageConfigs = helpers.addSource(cache, source, + ['connect', 'listInstanceExportedReportStorageConfigs', region, instance.Id]); + + if (!listInstanceExportedReportStorageConfigs || listInstanceExportedReportStorageConfigs.err || !listInstanceExportedReportStorageConfigs.data || + !listInstanceExportedReportStorageConfigs.data.StorageConfigs) { + helpers.addResult(results, 3, + `Unable to describe Connect instance exported reports storage config: ${helpers.addError(listInstanceExportedReportStorageConfigs)}`, + region, resource); + continue; + } + + if (!listInstanceExportedReportStorageConfigs.data.StorageConfigs.length) { + helpers.addResult(results, 0, + 'Connect instance does not have any storage config for exported reports', + region, resource); + continue; + } + + let storageConfig = listInstanceExportedReportStorageConfigs.data.StorageConfigs[0]; + + if (storageConfig.S3Config) { + if (storageConfig.S3Config.EncryptionConfig && + storageConfig.S3Config.EncryptionConfig.KeyId) { + let kmsKeyArn = storageConfig.S3Config.EncryptionConfig.KeyId; + let keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Connect instance is using ${currentEncryptionLevelString} for exported reports encryption \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Connect instance is using ${currentEncryptionLevelString} for exported reports encryption \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Connect instance does not have encryption enabled for exported reports', + region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find Connect instance exported reports S3 config', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/instanceReportsEncrypted.spec.js b/plugins/aws/connect/instanceReportsEncrypted.spec.js new file mode 100644 index 000000000..deb0aab8b --- /dev/null +++ b/plugins/aws/connect/instanceReportsEncrypted.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const instanceReportsEncrypted = require('./instanceReportsEncrypted'); + +const listInstances = [ + { + "Id": "9da26b8e-8f9e-4af2-8717-6f79913f2439", + "Arn": "arn:aws:connect:us-east-1:000111222333:instance/9da26b8e-8f9e-4af2-8717-6f79913f2439", + "IdentityManagementType": "CONNECT_MANAGED", + "InstanceAlias": "akhtar", + "CreatedTime": "2021-11-24T17:07:05+05:00", + "ServiceRole": "arn:aws:iam::000111222333:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_XYUbWO6kTpYfc9uUNk5i", + "InstanceStatus": "ACTIVE", + "InboundCallsEnabled": true, + "OutboundCallsEnabled": false + } +]; + +const listInstanceExportedReportStorageConfigs = [ + { + "StorageConfigs": [ + { + "AssociationId": "4c2f9f008d161c61b400662d134d58709c107ec601b39fbc873bb7236dd7c2d6", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-6b9ee809614c", + "BucketPrefix": "connect/akhtar/Reports", + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + } + ] + }, + { + "StorageConfigs": [ + { + "AssociationId": "4c2f9f008d161c61b400662d134d58709c107ec601b39fbc873bb7236dd7c2d6", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-6b9ee809614c", + "BucketPrefix": "connect/akhtar/Reports", + } + } + ] + }, + { + "StorageConfigs": [] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (instances, storageConfig, keys, describeKey, instancesErr, keysErr) => { + var instanceId = (instances && instances.length) ? instances[0].Id : null; + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + connect: { + listInstances: { + 'us-east-1': { + data: instances, + err: instancesErr + }, + }, + listInstanceExportedReportStorageConfigs: { + 'us-east-1': { + [instanceId]: { + data: storageConfig + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + } + } + }; +}; + +describe('instanceReportsEncrypted', function () { + describe('run', function () { + it('should FAIL if Connect instance is not using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceExportedReportStorageConfigs[0], listKeys, describeKey[1]); + instanceReportsEncrypted.run(cache, {connect_exported_reports_encryption_level : 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Connect instance does not have encryption enabled for exported reports', function (done) { + const cache = createCache(listInstances, listInstanceExportedReportStorageConfigs[1], listKeys, describeKey[1]); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Connect instance is using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceExportedReportStorageConfigs[0], listKeys, describeKey[0]); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Connect instance does not have any storage config for exported reports', function (done) { + const cache = createCache(listInstances, listInstanceExportedReportStorageConfigs[2], listKeys, describeKey[0]); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no Connect instances found', function (done) { + const cache = createCache([]); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN Unable to query Connect instances', function (done) { + const cache = createCache([], null, listKeys, describeKey[0], { message: 'Unable to find data' }); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN Unable to query KMS keys', function (done) { + const cache = createCache(listInstances, listInstanceExportedReportStorageConfigs[2], [], null, null, { message: 'Unable to find data' }); + instanceReportsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/connect/instanceTranscriptsEncrypted.js b/plugins/aws/connect/instanceTranscriptsEncrypted.js new file mode 100644 index 000000000..217ea2a6d --- /dev/null +++ b/plugins/aws/connect/instanceTranscriptsEncrypted.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Instance Chat Transcripts Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Connect instances have encryption enabled for chat transcripts being saved on S3.', + more_info: 'You can configure Amazon Connect instance to save transcripts for chats to be saved on S3. When you save ' + + 'such data on S3, enable encryption for the data and use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/encryption-at-rest.html', + recommended_action: 'Modify Connect instance data storage configuration and enable encryption for chat transcripts', + apis: ['Connect:listInstances', 'Connect:listInstanceChatTranscriptStorageConfigs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + connect_chat_transcripts_encryption_level: { + name: 'Connect Chat Transcripts Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['connect:CreateInstance', 'connect:AssociateInstanceStorageConfig', 'connect:UpdateInstanceStorageConfig','connect:DeleteInstance', 'connect:DisassociateInstanceStorageConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.connect_chat_transcripts_encryption_level || this.settings.connect_chat_transcripts_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listInstances = helpers.addSource(cache, source, + ['connect', 'listInstances', region]); + + if (!listInstances) return rcb(); + + if (listInstances.err || !listInstances.data) { + helpers.addResult(results, 3, + `Unable to query Connect instances: ${helpers.addError(listInstances)}`, region); + return rcb(); + } + + if (!listInstances.data.length) { + helpers.addResult(results, 0, 'No Connect instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let instance of listInstances.data) { + if (!instance.Arn) continue; + + var resource = instance.Arn; + + var listInstanceChatTranscriptStorageConfigs = helpers.addSource(cache, source, + ['connect', 'listInstanceChatTranscriptStorageConfigs', region, instance.Id]); + + if (!listInstanceChatTranscriptStorageConfigs || listInstanceChatTranscriptStorageConfigs.err || !listInstanceChatTranscriptStorageConfigs.data || + !listInstanceChatTranscriptStorageConfigs.data.StorageConfigs) { + helpers.addResult(results, 3, + `Unable to describe Connect instance chat transcripts storage config: ${helpers.addError(listInstanceChatTranscriptStorageConfigs)}`, + region, resource); + continue; + } + + if (!listInstanceChatTranscriptStorageConfigs.data.StorageConfigs.length) { + helpers.addResult(results, 0, + 'Connect instance does not have any storage config for chat transcripts', + region, resource); + continue; + } + + let storageConfig = listInstanceChatTranscriptStorageConfigs.data.StorageConfigs[0]; + + if (storageConfig.S3Config) { + if (storageConfig.S3Config.EncryptionConfig && + storageConfig.S3Config.EncryptionConfig.KeyId) { + let kmsKeyArn = storageConfig.S3Config.EncryptionConfig.KeyId; + let keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Connect instance is using ${currentEncryptionLevelString} for chat transcripts encryption \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Connect instance is using ${currentEncryptionLevelString} for chat transcripts encryption \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Connect instance does not have encryption enabled for chat transcripts', + region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find Connect instance chat transcripts S3 config', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/instanceTranscriptsEncrypted.spec.js b/plugins/aws/connect/instanceTranscriptsEncrypted.spec.js new file mode 100644 index 000000000..83b6ff6e6 --- /dev/null +++ b/plugins/aws/connect/instanceTranscriptsEncrypted.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const instanceTranscriptsEncrypted = require('./instanceTranscriptsEncrypted'); + +const listInstances = [ + { + "Id": "9da26b8e-8f9e-4af2-8717-6f79913f2439", + "Arn": "arn:aws:connect:us-east-1:000111222333:instance/9da26b8e-8f9e-4af2-8717-6f79913f2439", + "IdentityManagementType": "CONNECT_MANAGED", + "InstanceAlias": "akhtar", + "CreatedTime": "2021-11-24T17:07:05+05:00", + "ServiceRole": "arn:aws:iam::000111222333:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_XYUbWO6kTpYfc9uUNk5i", + "InstanceStatus": "ACTIVE", + "InboundCallsEnabled": true, + "OutboundCallsEnabled": false + } +]; + +const listInstanceChatTranscriptStorageConfigs = [ + { + "StorageConfigs": [ + { + "AssociationId": "190014ee5aa69dd33835164f250fb5938636b3ad8225f323bebc0ecd13da16a8", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-a72b5bc76263", + "BucketPrefix": "connect/akhtar/ChatTranscripts", + "EncryptionConfig": { + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + } + ] + }, + { + "StorageConfigs": [ + { + "AssociationId": "519cada57abf190bef871bb031d6c2813a0f76ffd638e531d1d7dd77984bd9a2", + "StorageType": "S3", + "S3Config": { + "BucketName": "amazon-connect-a72b5bc76263", + "BucketPrefix": "connect/akhtar/ChatTranscripts", + } + } + ] + }, + { + "StorageConfigs": [] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (instances, storageConfig, keys, describeKey, instancesErr, keysErr) => { + var instanceId = (instances && instances.length) ? instances[0].Id : null; + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + connect: { + listInstances: { + 'us-east-1': { + data: instances, + err: instancesErr + }, + }, + listInstanceChatTranscriptStorageConfigs: { + 'us-east-1': { + [instanceId]: { + data: storageConfig + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + } + } + }; +}; + +describe('instanceTranscriptsEncrypted', function () { + describe('run', function () { + it('should FAIL if Connect instance is not using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceChatTranscriptStorageConfigs[0], listKeys, describeKey[1]); + instanceTranscriptsEncrypted.run(cache, {connect_chat_transcripts_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Connect instance does not have encryption enabled for chat transcripts', function (done) { + const cache = createCache(listInstances, listInstanceChatTranscriptStorageConfigs[1], listKeys, describeKey[1]); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Connect instance is using desired encryption level', function (done) { + const cache = createCache(listInstances, listInstanceChatTranscriptStorageConfigs[0], listKeys, describeKey[0]); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Connect instance does not have any storage config for chat transcripts', function (done) { + const cache = createCache(listInstances, listInstanceChatTranscriptStorageConfigs[2], listKeys, describeKey[0]); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Connect instances found', function (done) { + const cache = createCache([]); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN Unable to query Connect instances', function (done) { + const cache = createCache([], null, listKeys, describeKey[0], { message: 'Unable to find data' }); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN Unable to query KMS keys', function (done) { + const cache = createCache(listInstances, listInstanceChatTranscriptStorageConfigs[2], [], null, null, { message: 'Unable to find data' }); + instanceTranscriptsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/connect/voiceIdDomainEncrypted.js b/plugins/aws/connect/voiceIdDomainEncrypted.js new file mode 100644 index 000000000..1018c4a2c --- /dev/null +++ b/plugins/aws/connect/voiceIdDomainEncrypted.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Voice ID Domain Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Voice domains created under Amazon Connect instances are using desired KMS encryption level.', + more_info: 'All user data stored in Amazon Connect Voice ID is encrypted at rest using encryption keys stored in AWS Key Management Service. Additionally, you can provide customer managed KMS keys in order to gain more control over encryption/decryption processes.', + recommended_action: 'Ensure that Amazon Voice ID domains have encryption enabled.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/encryption-at-rest.html', + apis: ['VoiceID:listDomains', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + voice_id_desired_encryption_level: { + name: 'Connect Voice ID Domain Target Encryption Level', + description: 'In order (lowest to highest) \ + awskms=AWS-managed KMS; \ + awscmk=Customer managed KMS; \ + externalcmk=Customer managed externally sourced KMS; \ + cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['voiceid:CreateDomain', 'voiceid:UpdateDomain', 'voiceid:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.voice_id_desired_encryption_level || this.settings.voice_id_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.voiceid, function(region, rcb){ + var listDomains = helpers.addSource(cache, source, + ['voiceid', 'listDomains', region]); + + if (!listDomains) return rcb(); + + if (listDomains.err || !listDomains.data) { + helpers.addResult(results, 3, + 'Unable to query Connect Voice ID domains: ' + helpers.addError(listDomains), region); + return rcb(); + } + + if (!listDomains.data.length) { + helpers.addResult(results, 0, 'No Connect Voice ID domains found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let domain of listDomains.data) { + let resource = domain.Arn; + + if (domain.ServerSideEncryptionConfiguration && domain.ServerSideEncryptionConfiguration.KmsKeyId) { + let encryptionKey = domain.ServerSideEncryptionConfiguration.KmsKeyId; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Voice ID domain is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Voice ID domain is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/voiceIdDomainEncrypted.spec.js b/plugins/aws/connect/voiceIdDomainEncrypted.spec.js new file mode 100644 index 000000000..c23cc1344 --- /dev/null +++ b/plugins/aws/connect/voiceIdDomainEncrypted.spec.js @@ -0,0 +1,169 @@ +var expect = require('chai').expect; +var voiceIdDomainEncrypted = require('./voiceIdDomainEncrypted'); + +const listDomains = [ + { + Arn: 'arn:aws:voiceid:us-east-1:111222333:domain/aabbccddeeff', + CreatedAt: '2021-11-29T14:00:40.764Z', + DomainId: 'aabbccddeeff', + DomainStatus: 'ACTIVE', + Name: 'akhtar-domain', + ServerSideEncryptionConfiguration: { + KmsKeyId: 'arn:aws:kms:us-east-1:112222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e' + }, + UpdatedAt: '2021-11-29T14:00:41.007Z' + }, + { + Arn: 'arn:aws:voiceid:us-east-1:111222333:domain/0asdfvv02ddff', + CreatedAt: '2021-12-05T09:11:34.154Z', + DomainId: '0asdfvv02ddff', + DomainStatus: 'ACTIVE', + Name: 'bdjasd', + ServerSideEncryptionConfiguration: { + KmsKeyId: 'arn:aws:kms:us-east-1:112233:key/ad013a33-b01d-4d88-ac97-127399c18b3e' + }, + UpdatedAt: '2021-12-05T09:11:34.465Z' + }, + { + Arn: 'arn:aws:voiceid:us-east-1:111222333:domain/aa112ccc33vv', + CreatedAt: '2021-12-12T20:48:49.447Z', + DomainId: 'aa112ccc33vv', + DomainStatus: 'ACTIVE', + Name: 'Test', + ServerSideEncryptionConfiguration: { + KmsKeyId: 'arn:aws:kms:us-east-1:1122233:key/ad013a33-b01d-4d88-ac97-127399c18b3e' + }, + UpdatedAt: '2021-12-12T20:48:49.666Z' + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (domains, keys, describeKey) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + voiceid: { + listDomains: { + 'us-east-1': { + err: null, + data: domains + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('voiceIdDomainEncrypted', function () { + describe('run', function () { + it('should PASS if VoiceID domain is encrypted with desired encryption level', function (done) { + const cache = createCache(listDomains, listKeys, describeKey[0]); + voiceIdDomainEncrypted.run(cache, { voice_id_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(3); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if VoiceID domain is not encrypted with desired encryption level', function (done) { + const cache = createCache(listDomains, listKeys, describeKey[1]); + voiceIdDomainEncrypted.run(cache, { voice_id_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(3); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no VoiceID domain is found', function (done) { + const cache = createCache([]); + voiceIdDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list VoiceID domains', function (done) { + const cache = createCache(null, null, null); + voiceIdDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDomains, null, null); + voiceIdDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/connect/wisdomDomainEncrypted.js b/plugins/aws/connect/wisdomDomainEncrypted.js new file mode 100644 index 000000000..a6270af10 --- /dev/null +++ b/plugins/aws/connect/wisdomDomainEncrypted.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Connect Wisdom Domain Encrypted', + category: 'Connect', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Wisdom domains created under Amazon Connect instances are using desired KMS encryption level.', + more_info: 'All user data stored in Amazon Connect Wisdom is encrypted at rest using encryption keys stored in AWS Key Management Service. Additionally, you can provide customer managed KMS keys in order to gain more control over encryption/decryption processes.', + recommended_action: 'Ensure that Amazon Connect Wisdom domains have encryption enabled.', + link: 'https://docs.aws.amazon.com/connect/latest/adminguide/encryption-at-rest.html', + apis: ['Wisdom:listAssistants', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + wisdom_desired_encryption_level: { + name: 'Connect Wisdom Domain Desired Encryption Level', + description: 'In order (lowest to highest) \ + awskms=AWS-managed KMS; \ + awscmk=Customer managed KMS; \ + externalcmk=Customer managed externally sourced KMS; \ + cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['wisdom:CreateAssistant', 'wisdom:DeleteAssistant'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.wisdom_desired_encryption_level || this.settings.wisdom_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.wisdom, function(region, rcb){ + var listDomains = helpers.addSource(cache, source, + ['wisdom', 'listAssistants', region]); + + if (!listDomains) return rcb(); + + if (listDomains.err || !listDomains.data) { + helpers.addResult(results, 3, + 'Unable to query Connect Wisdom domains: ' + helpers.addError(listDomains), region); + return rcb(); + } + + if (!listDomains.data.length) { + helpers.addResult(results, 0, 'No Connect Wisdom domains found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let domain of listDomains.data) { + let resource = domain.assistantArn; + + if (domain.serverSideEncryptionConfiguration && domain.serverSideEncryptionConfiguration.kmsKeyId) { + let encryptionKey = domain.serverSideEncryptionConfiguration.kmsKeyId; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Wisdom domain is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Wisdom domain is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/connect/wisdomDomainEncrypted.spec.js b/plugins/aws/connect/wisdomDomainEncrypted.spec.js new file mode 100644 index 000000000..6726b0367 --- /dev/null +++ b/plugins/aws/connect/wisdomDomainEncrypted.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var wisdomDomainEncrypted = require('./wisdomDomainEncrypted'); + +const listAssistants = [ + { + assistantArn: 'arn:aws:wisdom:us-east-1:111122223333:assistant/6abcbdde-4a11-42b1-a79c-18e1e7d554c3', + assistantId: '6abcbdde-4a11-42b1-a79c-18e1e7d554c3', + name: 'asdw', + serverSideEncryptionConfiguration: { + kmsKeyId: 'arn:aws:kms:us-east-1:111122223333:key/ad013a33-b01d-4d88-ac97-127399c18b3e' + }, + status: 'ACTIVE', + tags: {}, + type: 'AGENT' + }, + { + assistantArn: 'arn:aws:wisdom:us-east-1:111122223333:assistant/6abcbdde-4a11-42b1-a79c-18e1e7d554c3', + assistantId: '6abcbdde-4a11-42b1-a79c-18e1e7d554c3', + name: 'asdw', + serverSideEncryptionConfiguration: { + kmsKeyId: 'arn:aws:kms:us-east-1:111122223333:key/22322a33-b01d-4d88-ac97-127399c18b3e' + }, + status: 'ACTIVE', + tags: {}, + type: 'AGENT' + }, + { + assistantArn: 'arn:aws:wisdom:us-east-1:111122223333:assistant/e5b466a7-52e6-480d-a145-965ba1882c18', + assistantId: 'e5b466a7-52e6-480d-a145-965ba1882c18', + name: 'ddwe', + status: 'ACTIVE', + tags: {}, + type: 'AGENT' + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (domains, keys, describeKey) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + wisdom: { + listAssistants: { + 'us-east-1': { + err: null, + data: domains + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('wisdomDomainEncrypted', function () { + describe('run', function () { + it('should PASS if Wisdom domain is encrypted with desired encryption level', function (done) { + const cache = createCache([listAssistants[0]], listKeys, describeKey[0]); + wisdomDomainEncrypted.run(cache, { wisdom_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Wisdom domain is not encrypted with desired encryption level', function (done) { + const cache = createCache([listAssistants[0]], listKeys, describeKey[1]); + wisdomDomainEncrypted.run(cache, { wisdom_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Wisdom domain is found', function (done) { + const cache = createCache([]); + wisdomDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Wisdom domains', function (done) { + const cache = createCache(null, null, null); + wisdomDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to find Wisdom domain encryption key', function (done) { + const cache = createCache([listAssistants[2]], null, null); + wisdomDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listAssistants, null, null); + wisdomDomainEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.js b/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.js new file mode 100644 index 000000000..f314d602b --- /dev/null +++ b/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DevOps Guru Notifications Enabled', + category: 'AI & ML', + domain: 'Availability', + severity: 'Low', + description: 'Ensures SNS topic is set up for Amazon DevOps Guru.', + more_info: 'Amazon DevOps Guru uses an SNS topic to notify you about important DevOps Guru events.', + recommended_action: 'Add a notification channel to DevOps Guru', + link: 'https://docs.aws.amazon.com/devops-guru/latest/userguide/setting-up.html', + apis: ['DevOpsGuru:listNotificationChannels'], + realtime_triggers: ['devopsguru:AddNotificationChannel','devopsguru:RemoveNotificationChannel'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.devopsguru, function(region, rcb){ + var listNotificationChannels = helpers.addSource(cache, source, + ['devopsguru', 'listNotificationChannels', region]); + + if (!listNotificationChannels) return rcb(); + + if (listNotificationChannels.err || !listNotificationChannels.data) { + helpers.addResult(results, 3, + `Unable to list notification channels: ${helpers.addError(listNotificationChannels)}`, region); + return rcb(); + } + + if (listNotificationChannels.data.length) { + helpers.addResult(results, 0, 'SNS notification is configured for DevOps Guru', region); + } else { + helpers.addResult(results, 2, 'SNS notification is not configured for DevOps Guru', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.spec.js b/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.spec.js new file mode 100644 index 000000000..16ffc64cc --- /dev/null +++ b/plugins/aws/devopsguru/devOpsGuruNotificationEnabled.spec.js @@ -0,0 +1,78 @@ +var expect = require('chai').expect; +const devOpsGuruNotificationEnabled = require('./devOpsGuruNotificationEnabled'); + +const listNotificationChannels = [ + { + "Id": "fe73f5d8-e8ca-45d1-98e9-e2c0f6cc6d9f", + "Config": { + "Sns": { + "TopicArn": "arn:aws:sns:us-east-1:000011112222:devopsguru" + } + } + } +]; + +const createCache = (data, err) => { + return { + devopsguru: { + listNotificationChannels: { + 'us-east-1': { + err: err, + data: data + } + } + } + } +}; + +const createNullCache = () => { + return { + devopsguru: { + listNotificationChannels: { + 'us-east-1': null + } + } + } +}; + +describe('devOpsGuruNotificationEnabled', function () { + describe('run', function () { + it('should PASS if SNS notification is configured for DevOps Guru', function (done) { + const cache = createCache(listNotificationChannels); + devOpsGuruNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SNS notification is configured for DevOps Guru', function (done) { + const cache = createCache([]); + devOpsGuruNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list notification channels', function (done) { + const cache = createCache(listNotificationChannels, { message: 'unable to list notification channels' }); + devOpsGuruNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list notification channels response not found', function (done) { + const cache = createNullCache(); + devOpsGuruNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dms/autoMinorVersionUpgrade.js b/plugins/aws/dms/autoMinorVersionUpgrade.js new file mode 100644 index 000000000..d655e4544 --- /dev/null +++ b/plugins/aws/dms/autoMinorVersionUpgrade.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DMS Auto Minor Version Upgrade', + category: 'DMS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your Amazon Database Migration Service (DMS) replication instances have the Auto Minor Version Upgrade feature enabled', + more_info: 'AWS Database Migration Service (AWS DMS) helps you migrate databases to AWS quickly and securely. The DMS service releases engine version upgrades regularly to introduce new software features, bug fixes, security patches and performance improvements.', + recommended_action: 'Enable Auto Minor Version Upgrade feature in order to automatically receive minor engine upgrades for improved performance and security', + link: 'https://docs.aws.amazon.com/dms/latest/userguide/CHAP_ReplicationInstance.Modifying.html', + apis: ['DMS:describeReplicationInstances'], + realtime_triggers: ['dms:CreateReplicationInstance','dms:ModifyReplicationInstance','dms:DeleteReplicationInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.dms, function(region, rcb){ + var describeReplicationInstances = helpers.addSource(cache, source, + ['dms', 'describeReplicationInstances', region]); + + if (!describeReplicationInstances) return rcb(); + + if (describeReplicationInstances.err || !describeReplicationInstances.data) { + helpers.addResult(results, 3, + `Unable to list DMS replication instances: ${helpers.addError(describeReplicationInstances)}`, region); + return rcb(); + } + + if (!describeReplicationInstances.data.length) { + helpers.addResult(results, 0, + 'No DMS replication instances found', region); + return rcb(); + } + + for (let instance of describeReplicationInstances.data) { + if (!instance.ReplicationInstanceArn) continue; + + let resource = instance.ReplicationInstanceArn; + + if (instance.AutoMinorVersionUpgrade) { + helpers.addResult(results, 0, + 'Replication instance has auto minor version upgrade enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Replication instance does not have auto minor version upgrade enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/dms/autoMinorVersionUpgrade.spec.js b/plugins/aws/dms/autoMinorVersionUpgrade.spec.js new file mode 100644 index 000000000..9966c005c --- /dev/null +++ b/plugins/aws/dms/autoMinorVersionUpgrade.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var autoMinorVersionUpgrade = require('./autoMinorVersionUpgrade'); + +const describeReplicationInstances = [ + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "AutoMinorVersionUpgrade": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": true + }, + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "AutoMinorVersionUpgrade": false, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": true + } +]; + +const createCache = (instances, instancesErr) => { + return { + dms: { + describeReplicationInstances: { + 'us-east-1': { + err: instancesErr, + data: instances + }, + }, + } + }; +}; + +describe('autoMinorVersionUpgrade', function () { + describe('run', function () { + it('should PASS if Replication instances have auto minor version upgrade enabled.', function (done) { + const cache = createCache([describeReplicationInstances[0]]); + autoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Replication instance has auto minor version upgrade enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Replication instances does not have auto minor version upgrade enabled.', function (done) { + const cache = createCache([describeReplicationInstances[1]]); + autoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Replication instance does not have auto minor version upgrade enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DMS replication instances found', function (done) { + const cache = createCache([]); + autoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DMS replication instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DMS replication instances', function (done) { + const cache = createCache(null, { message: "Unable to list DMS replication instances" }); + autoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dms/dmsEncryptionEnabled.js b/plugins/aws/dms/dmsEncryptionEnabled.js index 704e2f0c4..d8be17b1a 100644 --- a/plugins/aws/dms/dmsEncryptionEnabled.js +++ b/plugins/aws/dms/dmsEncryptionEnabled.js @@ -4,9 +4,11 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'DMS Encryption Enabled', category: 'DMS', - description: 'Ensures DMS encryption is enabled', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures DMS encryption is enabled using a CMK', more_info: 'Data sent through the data migration service is encrypted using KMS. Encryption is enabled by default, but it is recommended to use customer managed keys.', - recommended_action: 'Enable encryption using KMS for all DMS replication instances.', + recommended_action: 'Enable encryption using KMS CMKs for all DMS replication instances.', link: 'https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Security.html', apis: ['DMS:describeReplicationInstances', 'KMS:listKeys', 'KMS:describeKey', 'KMS:listAliases'], compliance: { @@ -24,10 +26,11 @@ module.exports = { dms_encryption_kms_alias: { name: 'DMS Encryption KMS Alias', description: 'If set, DMS encryption must be configured using the KMS key alias specified. Be sure to include the alias/ prefix. Comma-delimited.', - regex: '^alias/[a-zA-Z0-9_/-,]{0,256}$', + regex: '^alias/[a-zA-Z0-9_\\/,\\-]{0,256}$', default: false } }, + realtime_triggers: ['dms:CreateReplicationInstance','dms:DeleteReplicationInstance'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/dms/dmsMultiAZFeatureEnabled.js b/plugins/aws/dms/dmsMultiAZFeatureEnabled.js new file mode 100644 index 000000000..78db3808d --- /dev/null +++ b/plugins/aws/dms/dmsMultiAZFeatureEnabled.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DMS Multi-AZ Feature Enabled', + category: 'DMS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your Amazon Database Migration Service (DMS) replication instances are using Multi-AZ deployment configurations.', + more_info: 'AWS Database Migration Service (AWS DMS) helps you migrate databases to AWS quickly and securely. In a Multi-AZ deployment, AWS DMS automatically provisions and maintains a synchronous standby replica of the replication instance in a different Availability Zone.', + recommended_action: 'Enable Multi-AZ deployment feature in order to get high availability and failover support', + link: 'https://docs.aws.amazon.com/dms/latest/userguide/CHAP_ReplicationInstance.html', + apis: ['DMS:describeReplicationInstances'], + realtime_triggers: ['dms:CreateReplicationInstance','dms:ModifyReplicationInstance','dms:DeleteReplicationInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.dms, function(region, rcb){ + var describeReplicationInstances = helpers.addSource(cache, source, + ['dms', 'describeReplicationInstances', region]); + + if (!describeReplicationInstances) return rcb(); + + if (describeReplicationInstances.err || !describeReplicationInstances.data) { + helpers.addResult(results, 3, + `Unable to list DMS replication instances: ${helpers.addError(describeReplicationInstances)}`, region); + return rcb(); + } + + if (!describeReplicationInstances.data.length) { + helpers.addResult(results, 0, + 'No DMS replication instances found', region); + return rcb(); + } + + for (let instance of describeReplicationInstances.data) { + if (!instance.ReplicationInstanceArn) continue; + + let resource = instance.ReplicationInstanceArn; + + if (instance.MultiAZ) { + helpers.addResult(results, 0, + 'DMS replication instance has Multi-AZ feature enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'DMS replication instance does not have Multi-AZ feature enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/dms/dmsMultiAZFeatureEnabled.spec.js b/plugins/aws/dms/dmsMultiAZFeatureEnabled.spec.js new file mode 100644 index 000000000..e51b870fc --- /dev/null +++ b/plugins/aws/dms/dmsMultiAZFeatureEnabled.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var dmsMultiAZFeatureEnabled = require('./dmsMultiAZFeatureEnabled'); + +const describeReplicationInstances = [ + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "MultiAZ": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": true + }, + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "MultiAZ": false, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": true + } +]; + +const createCache = (instances, instancesErr) => { + return { + dms: { + describeReplicationInstances: { + 'us-east-1': { + err: instancesErr, + data: instances + }, + }, + } + }; +}; + +describe('dmsMultiAZFeatureEnabled', function () { + describe('run', function () { + it('should PASS if DMS replication instances have Multi-AZ feature enabled.', function (done) { + const cache = createCache([describeReplicationInstances[0]]); + dmsMultiAZFeatureEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DMS replication instance has Multi-AZ feature enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DMS replication instances does not have Multi-AZ feature enabled.', function (done) { + const cache = createCache([describeReplicationInstances[1]]); + dmsMultiAZFeatureEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DMS replication instance does not have Multi-AZ feature enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DMS replication instances found', function (done) { + const cache = createCache([]); + dmsMultiAZFeatureEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DMS replication instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DMS replication instances', function (done) { + const cache = createCache(null, { message: "Unable to list DMS replication instances" }); + dmsMultiAZFeatureEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/dms/dmsPubliclyAccessibleInstances.js b/plugins/aws/dms/dmsPubliclyAccessibleInstances.js new file mode 100644 index 000000000..e7ca3e467 --- /dev/null +++ b/plugins/aws/dms/dmsPubliclyAccessibleInstances.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DMS Publicly Accessible Instances', + category: 'DMS', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Amazon Database Migration Service (DMS) instances are not publicly accessible.', + more_info: 'An AWS DMS replication instance can have one public IP address and one private IP address. If you uncheck (disable) the box for Publicly accessible, then the replication instance has only a private IP address. that prevents from exposure of data to other users', + recommended_action: 'Ensure that DMS replication instances have only private IP address and not public IP address', + link: 'https://docs.aws.amazon.com/dms/latest/userguide/CHAP_ReplicationInstance.PublicPrivate.html', + apis: ['DMS:describeReplicationInstances'], + realtime_triggers: ['dms:CreateReplicationInstance','dms:DeleteReplicationInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.dms, function(region, rcb){ + var describeReplicationInstances = helpers.addSource(cache, source, + ['dms', 'describeReplicationInstances', region]); + + if (!describeReplicationInstances) return rcb(); + + if (describeReplicationInstances.err || !describeReplicationInstances.data) { + helpers.addResult(results, 3, + `Unable to list DMS replication instances: ${helpers.addError(describeReplicationInstances)}`, region); + return rcb(); + } + + if (!describeReplicationInstances.data.length) { + helpers.addResult(results, 0, + 'No DMS replication instances found', region); + return rcb(); + } + + for (let instance of describeReplicationInstances.data) { + if (!instance.ReplicationInstanceArn) continue; + + let resource = instance.ReplicationInstanceArn; + + if (!instance.PubliclyAccessible) { + helpers.addResult(results, 0, + 'DMS replication instance is not publicly accessible.', + region, resource); + } else { + helpers.addResult(results, 2, + 'DMS replication instance is publicly accessible.', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dms/dmsPubliclyAccessibleInstances.spec.js b/plugins/aws/dms/dmsPubliclyAccessibleInstances.spec.js new file mode 100644 index 000000000..919323db3 --- /dev/null +++ b/plugins/aws/dms/dmsPubliclyAccessibleInstances.spec.js @@ -0,0 +1,84 @@ +var expect = require('chai').expect; +var dmsPubliclyAccessibleInstances = require('./dmsPubliclyAccessibleInstances'); + +const describeReplicationInstances = [ + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "MultiAZ": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": true + }, + { + "ReplicationInstanceIdentifier": "mine1", + "ReplicationInstanceClass": "dms.t3.micro", + "ReplicationInstanceStatus": "creating", + "MultiAZ": false, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/d691a16e-cc12-4611-8145-93f92d7d6eaf", + "ReplicationInstanceArn": "arn:aws:dms:us-east-1:000011112222:rep:XR6L5CYX66ALXX2GE3Y5E25G3LUW76KGZ5PUIEI", + "PubliclyAccessible": false + } +]; + +const createCache = (instances, instancesErr) => { + return { + dms: { + describeReplicationInstances: { + 'us-east-1': { + err: instancesErr, + data: instances + }, + }, + } + }; +}; + +describe('dmsPubliclyAccessibleInstances', function () { + describe('run', function () { + it('should PASS if DMS replication instance is not publicly accessible.', function (done) { + const cache = createCache([describeReplicationInstances[1]]); + dmsPubliclyAccessibleInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DMS replication instance is not publicly accessible.'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DMS replication instance is publicly accessible.', function (done) { + const cache = createCache([describeReplicationInstances[0]]); + dmsPubliclyAccessibleInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DMS replication instance is publicly accessible.'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DMS replication instances found', function (done) { + const cache = createCache([]); + dmsPubliclyAccessibleInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DMS replication instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DMS replication instances', function (done) { + const cache = createCache(null, { message: "Unable to list DMS replication instances" }); + dmsPubliclyAccessibleInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list DMS replication instances'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/documentDB/docDbHasTags.js b/plugins/aws/documentDB/docDbHasTags.js new file mode 100644 index 000000000..18f987a44 --- /dev/null +++ b/plugins/aws/documentDB/docDbHasTags.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Has Tags', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that AWS DocumentDB clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify DocumentDB cluster and add tags.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/tagging.html', + apis: ['DocDB:describeDBClusters', 'DocDB:listTagsForResource'], + realtime_triggers: ['docdb:CreateDBCluster','docdb:CreateDBInstance','docdb:DeleteDBCluster', 'docdb:ModifyDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + let resource = cluster.DBClusterArn; + + let getTags = helpers.addSource(cache, source, + ['docdb', 'listTagsForResource', region, resource]); + + if (!getTags || !getTags.data || getTags.err) { + helpers.addResult(results, 3, `Unable to get tags for DocumentDB cluster: ${helpers.addError(getTags)}`, region, resource); + continue; + } + + if (getTags.data.TagList && Object.entries(getTags.data.TagList).length > 0) { + helpers.addResult(results, 0, 'DocumentDB cluster has tags associated', region, resource); + } else { + helpers.addResult(results, 2, 'DocumentDB cluster does not have tags associated', region, resource); + } + } + + rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/documentDB/docDbHasTags.spec.js b/plugins/aws/documentDB/docDbHasTags.spec.js new file mode 100644 index 000000000..6b0ff38da --- /dev/null +++ b/plugins/aws/documentDB/docDbHasTags.spec.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +const documentDBHasTags = require('./docDbHasTags'); + +const createCache = (clusters, tags) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: null, + data: clusters + } + }, + listTagsForResource: { + 'us-east-1': { + 'arn:aws:rds:us-east-1:000011112222:cluster:test-cluster': { + err: null, + data: tags + } + } + } + } + }; +}; + +describe('DocumentDB Has Tags', function () { + describe('run', function () { + it('should return unknown result if unable to list DocumentDB clusters', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list DocumentDB clusters'); + done(); + }; + + const cache = createCache(null, null); + + documentDBHasTags.run(cache, {}, callback); + }); + + it('should return unknown result if unable to get tags information for DocumentDB cluster', function (done) { + const clusters = [ + { + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:test-cluster' + } + ]; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get tags for DocumentDB cluster:'); + done(); + }; + + const cache = createCache(clusters, null); + + documentDBHasTags.run(cache, {}, callback); + }); + + it('should return passing result if DocumentDB cluster has tags associated', function (done) { + const clusters = [ + { + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:test-cluster' + } + ]; + + const tags = { + TagList: [ + { Key: 'abc', Value: 'value' } ] + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('DocumentDB cluster has tags associated'); + done(); + }; + + const cache = createCache(clusters, tags); + + documentDBHasTags.run(cache, {}, callback); + }); + + it('should return failing result if DocumentDB cluster does not have tags associated', function (done) { + const clusters = [ + { + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:test-cluster' + } + ]; + + const tags = { + TagList: [] + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('DocumentDB cluster does not have tags associated'); + done(); + }; + + const cache = createCache(clusters, tags); + + documentDBHasTags.run(cache, {}, callback); + }); + + it('should return passing result if no DocumentDB clusters found', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No DocumentDB clusters found'); + done(); + }; + + const cache = createCache([], null); + + documentDBHasTags.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbAuditLoggingEnabled.js b/plugins/aws/documentDB/docdbAuditLoggingEnabled.js new file mode 100644 index 000000000..82d0d1914 --- /dev/null +++ b/plugins/aws/documentDB/docdbAuditLoggingEnabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Audit Logging Enabled', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that audit logging is enabled for DocumentDB clusters.', + more_info: 'Audit logging in Amazon DocumentDB provides visibility into authentication events, queries, and data changes. It helps detect unauthorized access, supports troubleshooting, and meets compliance requirements. Logs should be sent to CloudWatch or a SIEM for centralized monitoring and alerting.', + recommended_action: 'Modify DocumentDB cluster and enable audit logging feature.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html', + apis: ['DocDB:describeDBClusters'], + realtime_triggers: ['docdb:CreateDBCluster','docdb:ModifyDBCluster','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + if (cluster.EnabledCloudwatchLogsExports && + cluster.EnabledCloudwatchLogsExports.length && + cluster.EnabledCloudwatchLogsExports.includes('audit')) { + helpers.addResult(results, 0, 'DocumentDB cluster has audit logging enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'DocumentDB cluster does not have audit logging enabled', region, cluster.DBClusterArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js b/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js new file mode 100644 index 000000000..dbac13c12 --- /dev/null +++ b/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var docdbAuditLoggingEnabled = require('./docdbAuditLoggingEnabled'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 1, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: true, + EnabledCloudwatchLogsExports: [ "audit", "profiler"] + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false, + EnabledCloudwatchLogsExports: [ "profiler"] + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112224:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false, + EnabledCloudwatchLogsExports: [] + } +]; + +const createCache = (clusters, clustersErr) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('docdbAuditLoggingEnabled', function () { + describe('run', function () { + it('should PASS if DocumentDB Cluster has audit logging enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster has audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Cluster does not have audit logging enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Cluster has empty EnabledCloudwatchLogsExports', function (done) { + const cache = createCache([describeDBClusters[2]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters" }); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/documentDB/docdbCertificateRotated.js b/plugins/aws/documentDB/docdbCertificateRotated.js new file mode 100644 index 000000000..b284b0ad2 --- /dev/null +++ b/plugins/aws/documentDB/docdbCertificateRotated.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Cluster Instance Certificate Rotation', + category: 'DocumentDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that DocumentDB cluster instance certificates are rotated.', + more_info: 'AWS DocumentDB cluster certificate rotation ensures that cluster\'s TLS certificates are automatically rotated to maintain security and compliance standards. This feature helps in seamlessly updating certificates without downtime, ensuring continuous protection for data in transit within the DocumentDB cluster.', + recommended_action: 'Modify DocumentDB cluster instance and rotate the old server certificate.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/ca_cert_rotation.html', + apis: ['RDS:describeDBInstances'], + settings: { + docdb_certificate_rotation_limit: { + name: 'Certificate Rotation Limit', + description: 'Number of days before expiration date when certificate should be rotated', + regex: '^[0-9]*$', + default: '30', + } + }, + realtime_triggers: ['docdb:CreateDBInstance','docdb:DeleteDBInstance', 'docdb:ModifyDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var docdb_certificate_rotation_limit = parseInt(settings.docdb_certificate_rotation_limit || this.settings.docdb_certificate_rotation_limit.default); + + async.each(regions.rds, function(region, rcb){ + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB cluster instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB cluster instances found', region); + return rcb(); + } + + let docdbInstanceFound = false; + + for (let instance of describeDBInstances.data) { + if (!instance.DBInstanceArn) continue; + + if (!instance.Engine || instance.Engine.toLowerCase() != 'docdb') continue; + + let resource = instance.DBInstanceArn; + + docdbInstanceFound = true; + + if (instance.CertificateDetails && + instance.CertificateDetails.ValidTill) { + var then = new Date(instance.CertificateDetails.ValidTill); + var difference = Math.round((new Date(then).getTime() - new Date().getTime())/(24*60*60*1000)); + + if (difference > docdb_certificate_rotation_limit) { + helpers.addResult(results, 0, `DocumentDB cluster instance does not need certificate rotation as it expires in ${difference} days ` + + `of ${docdb_certificate_rotation_limit} days limit`, region, resource); + } else { + helpers.addResult(results, 2, `DocumentDB cluster instance needs certificate rotation as it expires in ${difference} days ` + + `of ${docdb_certificate_rotation_limit} days limit`, region, resource); + } + } + } + + if (!docdbInstanceFound) { + helpers.addResult(results, 0, 'No DocumentDB cluster instances found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/documentDB/docdbCertificateRotated.spec.js b/plugins/aws/documentDB/docdbCertificateRotated.spec.js new file mode 100644 index 000000000..a3c3fe555 --- /dev/null +++ b/plugins/aws/documentDB/docdbCertificateRotated.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var docdbInstanceCertificateRotated = require('./docdbCertificateRotated'); + +var certPass = new Date(); +certPass.setMonth(certPass.getMonth() + 2); +var certFail = new Date(); +certFail.setMonth(certFail.getMonth() - 1); + +const describeDBInstances = [ + { + DBInstanceIdentifier: "docdb-2024-06-12-19-45-21", + Engine: "docdb", + DBInstanceStatus: "available", + BackupRetentionPeriod: 1, + MultiAZ: false, + EngineVersion: "5.0.0", + AutoMinorVersionUpgrade: true, + ReadReplicaDBInstanceIdentifiers: [ + ], + ReadReplicaDBClusterIdentifiers: [ + ], + LicenseModel: "na", + OptionGroupMemberships: [ + { + OptionGroupName: "default:docdb-5-0", + Status: "in-sync", + }, + ], + PubliclyAccessible: false, + StatusInfos: [ + ], + StorageType: "standard", + DbInstancePort: 0, + DBClusterIdentifier: "docdb-2024-06-12-19-45-21", + StorageEncrypted: false, + DbiResourceId: "db-QNKFC3466G5XP6NLPXHSZDY5ZQ", + CACertificateIdentifier: "rds-ca-2019", + DomainMemberships: [ + ], + CopyTagsToSnapshot: false, + MonitoringInterval: 0, + PromotionTier: 1, + DBInstanceArn: "arn:aws:rds:us-east-1:1234123412:db:docdb-2024-06-12-19-45-21", + IAMDatabaseAuthenticationEnabled: false, + CertificateDetails: { + CAIdentifier: "rds-ca-2019", + ValidTill: certFail, + }, + DedicatedLogVolume: false, + }, + { + DBInstanceIdentifier: "docdb-2024-06-12-19-45-21", + Engine: "docdb", + DBInstanceStatus: "available", + BackupRetentionPeriod: 1, + MultiAZ: false, + EngineVersion: "5.0.0", + AutoMinorVersionUpgrade: true, + ReadReplicaDBInstanceIdentifiers: [ + ], + ReadReplicaDBClusterIdentifiers: [ + ], + LicenseModel: "na", + OptionGroupMemberships: [ + { + OptionGroupName: "default:docdb-5-0", + Status: "in-sync", + }, + ], + PubliclyAccessible: false, + StatusInfos: [ + ], + StorageType: "standard", + DbInstancePort: 0, + DBClusterIdentifier: "docdb-2024-06-12-19-45-21", + StorageEncrypted: false, + DbiResourceId: "db-QNKFC3466G5XP6NLPXHSZDY5ZQ", + CACertificateIdentifier: "rds-ca-2019", + DomainMemberships: [ + ], + CopyTagsToSnapshot: false, + MonitoringInterval: 0, + PromotionTier: 1, + DBInstanceArn: "arn:aws:rds:us-east-1:1234123412:db:docdb-2024-06-12-19-45-21", + IAMDatabaseAuthenticationEnabled: false, + CertificateDetails: { + CAIdentifier: "rds-ca-2019", + ValidTill: certPass, + }, + DedicatedLogVolume: false, + }, +]; + + +const createCache = (instances, instancesErr) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: instancesErr, + data: instances + }, + }, + }, + }; +}; + + + +describe('docdbInstanceCertificateRotated', function () { + describe('run', function () { + it('should PASS if DocumentDB cluster instance does not need certificate rotation', function (done) { + const cache = createCache([describeDBInstances[1]]); + docdbInstanceCertificateRotated.run(cache, { docdb_certificate_rotation_limit: 30 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should FAIL if DocumentDB cluster instance needs certificate rotation', function (done) { + const cache = createCache([describeDBInstances[0]]); + docdbInstanceCertificateRotated.run(cache, { docdb_certificate_rotation_limit: 30 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if no DocumentDB Clusters Instance found', function (done) { + const cache = createCache([]); + docdbInstanceCertificateRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters Instances', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list DocumentDB Clusters Instances" }); + docdbInstanceCertificateRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbClusterBackupRetention.js b/plugins/aws/documentDB/docdbClusterBackupRetention.js new file mode 100644 index 000000000..f441b5be2 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterBackupRetention.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Cluster Backup Retention', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that your Amazon DocumentDB clusters have set a minimum backup retention period.', + more_info: 'DocumentDB cluster provides feature to retain incremental backups between 1 and 35 allowing you to quickly restore to any point within the backup retention period. Ensure that you have sufficient backup retention period configured in order to restore your data in the event of failure.', + recommended_action: 'Modify DocumentDb cluster to configure sufficient backup retention period.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/db-cluster-modify.html', + apis: ['DocDB:describeDBClusters'], + settings: { + doc_db_backup_retention_threshold: { + name: 'DocDB Cluster Minimum Backup Retention Period', + description: 'Desired number of days for DocumentDB cluster backup retention period.', + regex: '^[1-35]*$', + default: 7 + } + }, + realtime_triggers: ['docdb:CreateDBCluster','docdb:ModifyDBCluster','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var doc_db_backup_retention_threshold = parseInt(settings.doc_db_backup_retention_threshold || this.settings.doc_db_backup_retention_threshold.default); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + let resource = cluster.DBClusterArn; + + if (cluster.BackupRetentionPeriod && cluster.BackupRetentionPeriod >= doc_db_backup_retention_threshold) { + helpers.addResult(results, 0, + `DocumentDB cluster has a backup retention period of ${cluster.BackupRetentionPeriod} of ${doc_db_backup_retention_threshold} days limit`, + region, resource); + } else { + helpers.addResult(results, 2, + `DocumentDB cluster has a backup retention period of ${cluster.BackupRetentionPeriod} of ${doc_db_backup_retention_threshold} days limit`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbClusterBackupRetention.spec.js b/plugins/aws/documentDB/docdbClusterBackupRetention.spec.js new file mode 100644 index 000000000..bc592bcd1 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterBackupRetention.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var docdbClusterBackupRetention = require('./docdbClusterBackupRetention'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 1, + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + EarliestRestorableTime: '2021-11-10T10:18:02.730Z', + Endpoint: 'docdb-2021-11-10-10-16-10.cluster-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + ReaderEndpoint: 'docdb-2021-11-10-10-16-10.cluster-ro-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + MultiAZ: false, + Engine: 'docdb', + EngineVersion: '4.0.0', + LatestRestorableTime: '2021-11-10T10:18:02.730Z', + Port: 27017, + MasterUsername: 'cloudexploit', + PreferredBackupWindow: '00:00-00:30', + PreferredMaintenanceWindow: 'thu:05:24-thu:05:54', + ReadReplicaIdentifiers: [], + DBClusterMembers: [], + VpcSecurityGroups: [], + HostedZoneId: 'ZNKXH85TT8WVW', + StorageEncrypted: true, + KmsKeyId: 'arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9', + DbClusterResourceId: 'cluster-TWDPR3PSXGUPMCESNBK6W55SH4', + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + AssociatedRoles: [], + ClusterCreateTime: '2021-11-10T10:16:49.359Z', + EnabledCloudwatchLogsExports: [], + DeletionProtection: true + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + EarliestRestorableTime: '2021-11-10T10:18:02.730Z', + Endpoint: 'docdb-2021-11-10-10-16-10.cluster-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + ReaderEndpoint: 'docdb-2021-11-10-10-16-10.cluster-ro-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + MultiAZ: false, + Engine: 'docdb', + EngineVersion: '4.0.0', + LatestRestorableTime: '2021-11-10T10:18:02.730Z', + Port: 27017, + MasterUsername: 'cloudexploit', + PreferredBackupWindow: '00:00-00:30', + PreferredMaintenanceWindow: 'thu:05:24-thu:05:54', + ReadReplicaIdentifiers: [], + DBClusterMembers: [], + VpcSecurityGroups: [], + HostedZoneId: 'ZNKXH85TT8WVW', + StorageEncrypted: true, + KmsKeyId: 'arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9', + DbClusterResourceId: 'cluster-TWDPR3PSXGUPMCESNBK6W55SH4', + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + AssociatedRoles: [], + ClusterCreateTime: '2021-11-10T10:16:49.359Z', + EnabledCloudwatchLogsExports: [], + DeletionProtection: true + } +]; + +const createCache = (clusters, clustersErr) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('docdbClusterBackupRetention', function () { + describe('run', function () { + it('should PASS if DocumentDb Cluster has the recommended backup retention period', function (done) { + const cache = createCache([describeDBClusters[1]]); + docdbClusterBackupRetention.run(cache, { doc_db_backup_retention_threshold: 7 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster has a backup retention period of 10'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Clusters do not have the recommended backup retention period', function (done) { + const cache = createCache([describeDBClusters[0]]); + docdbClusterBackupRetention.run(cache, { doc_db_backup_retention_threshold: 7 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster has a backup retention period of 1'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbClusterBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters encryption" }); + docdbClusterBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbClusterEncrypted.js b/plugins/aws/documentDB/docdbClusterEncrypted.js new file mode 100644 index 000000000..6eff45ad7 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterEncrypted.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Cluster Encrypted', + category: 'DocumentDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that data at-rest in encrypted in AWS DocumentDB clusters using desired encryption level.', + more_info: 'Amazon DocumentDB integrates with AWS KMS and uses a method known as envelope encryption to protect your data. ' + + 'This gives you an extra layer of data security and help meet security compliance and regulations within your organization.', + recommended_action: 'Modify DocumentDB cluster at-rest encryption configuration to use desired encryption key', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/encryption-at-rest.html', + apis: ['DocDB:describeDBClusters', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + documentdb_cluster_desired_encryption_level: { + name: 'DocumentDB Cluster Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['docdb:CreateDBCluster','docdb:CreateDBInstance','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.documentdb_cluster_desired_encryption_level || this.settings.documentdb_cluster_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + let resource = cluster.DBClusterArn; + + if (cluster.KmsKeyId) { + var kmsKeyId = cluster.KmsKeyId.split('/')[1] ? cluster.KmsKeyId.split('/')[1] : cluster.KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, cluster.KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `DocumentDB cluster is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `DocumentDB cluster is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'DynamoDB cluster does not have at-rest encryption enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/documentDB/docdbClusterEncrypted.spec.js b/plugins/aws/documentDB/docdbClusterEncrypted.spec.js new file mode 100644 index 000000000..f893744a4 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterEncrypted.spec.js @@ -0,0 +1,174 @@ +var expect = require('chai').expect; +var docdbClusterEncrypted = require('./docdbClusterEncrypted'); + + +const describeDBClusters = [ + { + AvailabilityZones: [Array], + BackupRetentionPeriod: 1, + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + EarliestRestorableTime: '2021-11-10T10:18:02.730Z', + Endpoint: 'docdb-2021-11-10-10-16-10.cluster-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + ReaderEndpoint: 'docdb-2021-11-10-10-16-10.cluster-ro-csumzsa0neyf.us-east-1.docdb.amazonaws.com', + MultiAZ: false, + Engine: 'docdb', + EngineVersion: '4.0.0', + LatestRestorableTime: '2021-11-10T10:18:02.730Z', + Port: 27017, + MasterUsername: 'cloudexploit', + PreferredBackupWindow: '00:00-00:30', + PreferredMaintenanceWindow: 'thu:05:24-thu:05:54', + ReadReplicaIdentifiers: [], + DBClusterMembers: [Array], + VpcSecurityGroups: [Array], + HostedZoneId: 'ZNKXH85TT8WVW', + StorageEncrypted: true, + KmsKeyId: 'arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9', + DbClusterResourceId: 'cluster-TWDPR3PSXGUPMCESNBK6W55SH4', + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + AssociatedRoles: [], + ClusterCreateTime: '2021-11-10T10:16:49.359Z', + EnabledCloudwatchLogsExports: [], + DeletionProtection: true + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (clusters, keys, describeKey, clustersErr, keysErr, describeKeyErr) => { + var keyId = (clusters && clusters.length && clusters[0].KmsKeyId) ? clusters[0].KmsKeyId.split('/')[1] : null; + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + +describe('docdbClusterEncrypted', function () { + describe('run', function () { + it('should PASS if DocumentDb Cluster is encrypted with desired encryption level', function (done) { + const cache = createCache(describeDBClusters, listKeys, describeKey[0]); + docdbClusterEncrypted.run(cache, { documentdb_cluster_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should FAIL if DocumentDB Clusters is not encrypted with desired encyption level', function (done) { + const cache = createCache(describeDBClusters, listKeys, describeKey[1]); + docdbClusterEncrypted.run(cache, { documentdb_cluster_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster is encrypted with awskms '); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list DocumentDB Clusters encryption" }); + docdbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + docdbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbClusterProfilerEnabled.js b/plugins/aws/documentDB/docdbClusterProfilerEnabled.js new file mode 100644 index 000000000..35d490c47 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterProfilerEnabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Cluster Profiler Enabled', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Amazon DocumentDB clusters have profiler feature enabled.', + more_info: 'Enabling the Profiler for your Amazon DocumentDB clusters helps to monitor and log database operations. This makes it easier to identify slowest operations on cluster and fix performance issues by analyzing detailed logs in Amazon CloudWatch.', + recommended_action: 'Modify DocumentDB cluster and enable profiler feature.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html', + apis: ['DocDB:describeDBClusters'], + realtime_triggers: ['docdb:CreateDBCluster','docdb:ModifyDBCluster','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + if (cluster.EnabledCloudwatchLogsExports && + cluster.EnabledCloudwatchLogsExports.length && + cluster.EnabledCloudwatchLogsExports.includes('profiler')) { + helpers.addResult(results, 0, 'DocumentDB cluster has profiler feature enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'DocumentDB cluster does not have profiler feature enabled', region, cluster.DBClusterArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbClusterProfilerEnabled.spec.js b/plugins/aws/documentDB/docdbClusterProfilerEnabled.spec.js new file mode 100644 index 000000000..415457b03 --- /dev/null +++ b/plugins/aws/documentDB/docdbClusterProfilerEnabled.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var docdbProfilerEnabled = require('./docdbClusterProfilerEnabled'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 1, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: true, + EnabledCloudwatchLogsExports: [ "audit", "profiler"] + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false, + EnabledCloudwatchLogsExports: [ "audit"] + } +]; + +const createCache = (clusters, clustersErr) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('docdbProfilerEnabled', function () { + describe('run', function () { + it('should PASS if DocumentDb Cluster has profiler feature enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + docdbProfilerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster has profiler feature enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Clusters does not have profiler feature enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + docdbProfilerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have profiler feature enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbProfilerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters encryption" }); + docdbProfilerEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbDeletionProtectionEnabled.js b/plugins/aws/documentDB/docdbDeletionProtectionEnabled.js new file mode 100644 index 000000000..2cb77f7b0 --- /dev/null +++ b/plugins/aws/documentDB/docdbDeletionProtectionEnabled.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Cluster Deletion Protection', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Amazon DocumentDB clusters have deletion protection feature enabled.', + more_info: 'Enabling deletion protection feature for Amazon DocumentDB clusters acts as a safety net, preventing accidental database deletions or deletion by an unauthorized user. It ensures that the data stays secure and accessible at all times.', + recommended_action: 'Modify DocumentDb cluster and enable deletion protection.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/db-cluster-delete.html', + apis: ['DocDB:describeDBClusters'], + realtime_triggers: ['docdb:CreateDBCluster','docdb:ModifyDBCluster','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + if (cluster.DeletionProtection) { + helpers.addResult(results, 0, 'DocumentDB cluster has deletion protection enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'DocumentDB cluster does not have deletion protection enabled', region, cluster.DBClusterArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbDeletionProtectionEnabled.spec.js b/plugins/aws/documentDB/docdbDeletionProtectionEnabled.spec.js new file mode 100644 index 000000000..b270cc710 --- /dev/null +++ b/plugins/aws/documentDB/docdbDeletionProtectionEnabled.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect; +var docdbDeletionProtectionEnabled = require('./docdbDeletionProtectionEnabled'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 1, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: true + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false + } +]; + +const createCache = (clusters, clustersErr) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('docdbDeletionProtectionEnabled', function () { + describe('run', function () { + it('should PASS if DocumentDb Cluster has deletion protection enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + docdbDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster has deletion protection enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Clusters does not have deletion protection enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + docdbDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have deletion protection enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters encryption" }); + docdbDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/documentDB/docdbEncryptionInTransit.js b/plugins/aws/documentDB/docdbEncryptionInTransit.js new file mode 100644 index 000000000..5b4f65418 --- /dev/null +++ b/plugins/aws/documentDB/docdbEncryptionInTransit.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Encryption In Transit', + category: 'DocumentDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that DocumentDB clusters have TLS/SSL encryption in transit enabled.', + more_info: 'DocumentDB uses TLS/SSL to encrypt data during transit. The TLS parameter in the cluster parameter group should be set to enabled to require encrypted connections. This ensures that all data transmitted between clients and the DocumentDB cluster is encrypted.', + recommended_action: 'Modify the cluster parameter group to set the tls parameter to enabled, or create a custom parameter group with TLS enabled and associate it with the cluster.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/security.encryption.ssl.html', + apis: ['DocDB:describeDBClusters', 'DocDB:describeDBClusterParameters'], + realtime_triggers: [ 'docdb:CreateDBCluster', 'docdb:ModifyDBCluster', 'docdb:ModifyDBClusterParameterGroup', 'docdb:CreateDBClusterParameterGroup','docdb:DeleteDBCluster' ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + async.each(describeDBClusters.data, function(cluster, ccb){ + if (!cluster.DBClusterArn || !cluster.DBClusterIdentifier) return ccb(); + + var resource = cluster.DBClusterArn; + var tlsEnabled = false; + + if (!cluster.DBClusterParameterGroup) { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have a parameter group associated', + region, resource); + return ccb(); + } + + var parameterGroupName = cluster.DBClusterParameterGroup; + + + var parameters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusterParameters', region, parameterGroupName]); + + if (!parameters || parameters.err || !parameters.data) { + helpers.addResult(results, 3, + `Unable to query cluster parameters: ${helpers.addError(parameters)}`, + region, resource); + return ccb(); + } + + if (!parameters.data.Parameters) { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have TLS encryption in transit enabled', + region, resource); + return ccb(); + } + + for (var param of parameters.data.Parameters) { + if (param.ParameterName && param.ParameterName === 'tls' && + param.ParameterValue && + (param.ParameterValue.toLowerCase() === 'enabled' || param.ParameterValue === '1')) { + tlsEnabled = true; + break; + } + } + + if (tlsEnabled) { + helpers.addResult(results, 0, + 'DocumentDB cluster has TLS encryption in transit enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have TLS encryption in transit enabled', + region, resource); + } + + ccb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js b/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js new file mode 100644 index 000000000..5408d2464 --- /dev/null +++ b/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js @@ -0,0 +1,233 @@ +var expect = require('chai').expect; +var docdbEncryptionInTransit = require('./docdbEncryptionInTransit'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'custom-docdb-param-group', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-11', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-11', + DBClusterParameterGroup: 'custom-docdb-param-group-disabled', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112224:cluster:docdb-2021-11-10-10-16-12', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-12', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112225:cluster:docdb-2021-11-10-10-16-13', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-13', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + } +]; + +const clusterParameters = { + 'custom-docdb-param-group': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'enabled', + Description: 'Enable TLS encryption', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: true + } + ] + }, + 'custom-docdb-param-group-disabled': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'disabled', + Description: 'Enable TLS encryption', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: true + } + ] + } +}; + +const createCache = (clusters, clustersErr, parameters, parametersErr) => { + var cache = { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; + + if (parameters) { + cache.docdb = cache.docdb || {}; + cache.docdb.describeDBClusterParameters = { + 'us-east-1': {} + }; + for (var groupName in parameters) { + cache.docdb.describeDBClusterParameters['us-east-1'][groupName] = { + err: parametersErr, + data: parameters[groupName] + }; + } + } + + return cache; +}; + +describe('docdbEncryptionInTransit', function () { + describe('run', function () { + it('should PASS if DocumentDB cluster has TLS enabled in custom parameter group', function (done) { + const cache = createCache([describeDBClusters[0]], null, clusterParameters); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster has TLS disabled in parameter group', function (done) { + const cache = createCache([describeDBClusters[1]], null, clusterParameters); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster uses default parameter group', function (done) { + const cache = createCache([describeDBClusters[2]], null, { + 'default.docdb4.0': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'disabled', + Description: 'Enable TLS encryption', + Source: 'system', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: false + } + ] + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster has no parameter group', function (done) { + const cache = createCache([describeDBClusters[3]], null); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have a parameter group associated'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB clusters found', function (done) { + const cache = createCache([]); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters" }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query cluster parameters', function (done) { + const cache = createCache([describeDBClusters[0]], null, null, { message: "Unable to query parameters" }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query cluster parameters'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if cluster parameters data is null', function (done) { + const cache = createCache([describeDBClusters[0]], null, { + 'custom-docdb-param-group': { + Parameters: null + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if cluster parameters array is empty', function (done) { + const cache = createCache([describeDBClusters[0]], null, { + 'custom-docdb-param-group': { + Parameters: [] + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dynamodb/daxClusterEncryption.js b/plugins/aws/dynamodb/daxClusterEncryption.js new file mode 100644 index 000000000..7a0f1f7ed --- /dev/null +++ b/plugins/aws/dynamodb/daxClusterEncryption.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Accelerator Cluster Encryption', + category: 'DynamoDB', + domain: 'Databases', + severity: 'High', + description: 'Ensures DynamoDB Cluster Accelerator DAX clusters have encryption enabled.', + more_info: 'DynamoDB Clusters Accelerator DAX clusters should have encryption at rest enabled to secure data from unauthorized access.', + link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DAXEncryptionAtRest.html', + recommended_action: 'Enable encryption for DAX cluster.', + apis: ['DAX:describeClusters'], + realtime_triggers: ['dax:CreateCluster','dax:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.dynamodb, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['dax', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for DAX clusters: ' + helpers.addError(describeClusters), region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No DAX clusters found', region); + return rcb(); + } + + for (var c in describeClusters.data) { + var cluster = describeClusters.data[c]; + var resource = cluster.ClusterArn; + + if (cluster.SSEDescription && + cluster.SSEDescription.Status && + cluster.SSEDescription.Status.toUpperCase() === 'ENABLED') { + helpers.addResult(results, 0, + 'Encryption is enabled for DAX :' + cluster.ClusterName, region, resource); + } else { + helpers.addResult(results, 2, + 'Encryption is not enabled for DAX :' + cluster.ClusterName, region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/daxClusterEncryption.spec.js b/plugins/aws/dynamodb/daxClusterEncryption.spec.js new file mode 100644 index 000000000..e530343eb --- /dev/null +++ b/plugins/aws/dynamodb/daxClusterEncryption.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +const daxClusterEncryption = require('./daxClusterEncryption'); + +const clusters = [ + { + ClusterName: 'test-60', + ClusterArn: 'arn:aws:dax:us-east-1:123456654321:cache/test-60', + TotalNodes: 1, + ActiveNodes: 0, + NodeType: 'dax.t2.small', + Status: 'creating', + ClusterDiscoveryEndpoint: { Port: 8111 }, + PreferredMaintenanceWindow: 'tue:08:30-tue:09:30', + SubnetGroup: 'test-subnet-dax', + SecurityGroups: [ { SecurityGroupIdentifier: 'sg-aa941691', Status: 'active' } ], + IamRoleArn: 'arn:aws:iam::123456654321:role/service-role/DAXtoDynamoDB', + ParameterGroup: { + ParameterGroupName: 'default.dax1.0', + ParameterApplyStatus: 'in-sync', + NodeIdsToReboot: [] + }, + SSEDescription: { Status: 'ENABLED' } + }, + { + ClusterName: 'test1-60', + ClusterArn: 'arn:aws:dax:us-east-1:123456654321:cache/test1-60', + TotalNodes: 1, + ActiveNodes: 0, + NodeType: 'dax.t2.small', + Status: 'creating', + ClusterDiscoveryEndpoint: { Port: 8111 }, + PreferredMaintenanceWindow: 'thu:04:00-thu:05:00', + SubnetGroup: 'test-subnet-dax', + SecurityGroups: [ { SecurityGroupIdentifier: 'sg-aa941691', Status: 'active' } ], + IamRoleArn: 'arn:aws:iam::123456654321:role/aws-service-role/dax.amazonaws.com/AWSServiceRoleForDAX', + ParameterGroup: { + ParameterGroupName: 'default.dax1.0', + ParameterApplyStatus: 'in-sync', + NodeIdsToReboot: [] + }, + SSEDescription: { Status: 'DISABLED' } + } +]; + +const createCache = (clusters) => { + return { + dax: { + describeClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + dax: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dax: { + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('daxClusterEncryption', function () { + describe('run', function () { + it('should FAIL if encryption is not enabled for DAX cluster', function (done) { + const cache = createCache([clusters[1]]); + daxClusterEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if encryption is enabled for DAX cluster', function (done) { + const cache = createCache([clusters[0]]); + daxClusterEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no DAX clusters found', function (done) { + const cache = createCache([]); + daxClusterEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for DAX clusters', function (done) { + const cache = createErrorCache(); + daxClusterEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for DAX clusters', function (done) { + const cache = createNullCache(); + daxClusterEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dynamodb/dynamoContinuousBackups.js b/plugins/aws/dynamodb/dynamoContinuousBackups.js new file mode 100644 index 000000000..ea877f72b --- /dev/null +++ b/plugins/aws/dynamodb/dynamoContinuousBackups.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Continuous Backups', + category: 'DynamoDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Amazon DynamoDB tables have continuous backups enabled.', + more_info: 'DynamoDB tables should have Continuous Backups and Point-In-Time Recovery (PITR) features enabled to protect DynamoDB data against accidental data writes.', + link: 'https://aws.amazon.com/blogs/aws/new-amazon-dynamodb-continuous-backups-and-point-in-time-recovery-pitr/', + recommended_action: 'Enable Continuous Backups and Point-In-Time Recovery (PITR) features.', + apis: ['DynamoDB:listTables', 'DynamoDB:describeContinuousBackups', 'STS:getCallerIdentity'], + realtime_triggers: ['dynamodb:CreateTable','dynamodb:UpdateContinuousBackups','dynamodb:DeleteTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.dynamodb, function(region, rcb){ + var listTables = helpers.addSource(cache, source, + ['dynamodb', 'listTables', region]); + + if (!listTables) return rcb(); + + if (listTables.err || !listTables.data) { + helpers.addResult(results, 3, + `Unable to query for DynamoDB tables: ${helpers.addError(listTables)}`, region); + return rcb(); + } + + if (!listTables.data.length) { + helpers.addResult(results, 0, 'No DynamoDB tables found', region); + return rcb(); + } + + async.each(listTables.data, function(table, cb) { + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; + + var describeContinuousBackups = helpers.addSource(cache, source, + ['dynamodb', 'describeContinuousBackups', region, table]); + + if (!describeContinuousBackups || describeContinuousBackups.err || !describeContinuousBackups.data || + !describeContinuousBackups.data.ContinuousBackupsDescription) { + helpers.addResult(results, 3, + `Unable to describe DynamoDB table continuous backups: ${helpers.addError(describeContinuousBackups)}`, + region, resource); + return cb(); + } + + if (describeContinuousBackups.data.ContinuousBackupsDescription.ContinuousBackupsStatus && + describeContinuousBackups.data.ContinuousBackupsDescription.ContinuousBackupsStatus === 'ENABLED' && + describeContinuousBackups.data.ContinuousBackupsDescription.PointInTimeRecoveryDescription && + describeContinuousBackups.data.ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus && + describeContinuousBackups.data.ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus === 'ENABLED') { + helpers.addResult(results, 0, + `DynamoDB table "${table}" has continuous backups enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `DynamoDB table "${table}" does not have continuous backups enabled`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/dynamoContinuousBackups.spec.js b/plugins/aws/dynamodb/dynamoContinuousBackups.spec.js new file mode 100644 index 000000000..f76ab8b71 --- /dev/null +++ b/plugins/aws/dynamodb/dynamoContinuousBackups.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +const continuousBackups = require('./dynamoContinuousBackups'); + +const listTables = [ + "akd-03" +]; + +const describeContinuousBackups = [ + { + "ContinuousBackupsDescription": { + "ContinuousBackupsStatus": "ENABLED", + "PointInTimeRecoveryDescription": { + "PointInTimeRecoveryStatus": "ENABLED", + "EarliestRestorableDateTime": "2020-11-25T04:44:48+05:00", + "LatestRestorableDateTime": "2020-11-25T04:44:48+05:00" + } + } + }, + { + "ContinuousBackupsDescription":{ + "ContinuousBackupsStatus":"ENABLED", + "PointInTimeRecoveryDescription":{ + "PointInTimeRecoveryStatus":"DISABLED" + } + } + }, + { + "ContinuousBackupsDescription":{ + "ContinuousBackupsStatus":"ENABLED", + } + } +]; + +const createCache = (table, backups) => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + data: table + }, + }, + describeContinuousBackups: { + 'us-east-1': { + [table]: { + data: backups + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + err: { + message: 'error listing DynamoDB tables' + }, + }, + }, + describeContinuousBackups: { + 'us-east-1': { + err: { + message: 'error describing property' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': null, + }, + describeContinuousBackups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('continuousBackups', function () { + describe('run', function () { + it('should PASS if DynamoDB table has continuous backups enabled', function (done) { + const cache = createCache(listTables, describeContinuousBackups[0]); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if DynamoDB table does not have continuous backups enabled', function (done) { + const cache = createCache(listTables, describeContinuousBackups[1]); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No DynamoDB tables found', function (done) { + const cache = createCache([], describeContinuousBackups[1]); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list DynamoDB tables', function (done) { + const cache = createErrorCache(); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe DynamoDB table continuous backups', function (done) { + const cache = createCache(listTables); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list tables response is not found', function (done) { + const cache = createNullCache(); + continuousBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/dynamodb/dynamoDeletionProtection.js b/plugins/aws/dynamodb/dynamoDeletionProtection.js new file mode 100644 index 000000000..a2b4c69df --- /dev/null +++ b/plugins/aws/dynamodb/dynamoDeletionProtection.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Deletion Protection Enabled', + category: 'DynamoDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that DynamoDB tables have deletion protection feature enabled.', + more_info: 'Enabling deletion protection feature ensures the prevention of accidental deletion of DynamoDB tables during regular maintenance operations, thereby safeguarding your data.', + link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithTables.Basics.html#WorkingWithTables.Basics.DeletionProtection', + recommended_action: 'Modify DynamoDB table and enable deletion protection.', + apis: ['DynamoDB:listTables', 'DynamoDB:describeTable', 'sts:getCallerIdentity'], + realtime_triggers: ['dynamodb:CreateTable','dynamodb:DeleteTable','dynamodb:UpdateTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.dynamodb, function(region, rcb){ + var listTables = helpers.addSource(cache, source, + ['dynamodb', 'listTables', region]); + + if (!listTables) return rcb(); + + if (listTables.err || !listTables.data) { + helpers.addResult(results, 3, + `Unable to query for DynamoDB tables: ${helpers.addError(listTables)}`, region); + return rcb(); + } + + if (!listTables.data.length) { + helpers.addResult(results, 0, 'No DynamoDB tables found', region); + return rcb(); + } + + for (let table of listTables.data){ + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; + + var describeTable = helpers.addSource(cache, source, + ['dynamodb', 'describeTable', region, table]); + + if (!describeTable || describeTable.err || !describeTable.data) { + helpers.addResult(results, 3, + `Unable to describe DynamoDB table: ${helpers.addError(describeTable)}`, + region, resource); + continue; + } + + if (describeTable.data.Table && describeTable.data.Table.DeletionProtectionEnabled) { + helpers.addResult(results, 0, + `DynamoDB table "${table}" has deletion protection enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `DynamoDB table "${table}" does not have deletion protection enabled`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/dynamoDeletionProtection.spec.js b/plugins/aws/dynamodb/dynamoDeletionProtection.spec.js new file mode 100644 index 000000000..a89fc672a --- /dev/null +++ b/plugins/aws/dynamodb/dynamoDeletionProtection.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +const dynamoDeletionProtection = require('./dynamoDeletionProtection'); + +const listTables = [ + "test-table" +]; + +const describeTable = [ + { + "Table": { + "DeletionProtectionEnabled": false + } + }, + { + "Table": { + "DeletionProtectionEnabled": true + } + }, +]; + +const createCache = (table, details) => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + data: table + }, + }, + describeTable: { + 'us-east-1': { + [table]: { + data: details + }, + }, + }, + } + }; +}; + +const createErrorCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + err: { + message: 'error listing DynamoDB tables' + }, + }, + }, + describeTable: { + 'us-east-1': { + err: { + message: 'error describing property' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': null, + }, + describeTable: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('dynamoDeletionProtection', function () { + describe('run', function () { + it('should PASS if DynamoDB table has deletion protection enabled', function (done) { + const cache = createCache(listTables, describeTable[1]); + dynamoDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.include('us-east-1'); + expect(results[0].message).to.include('DynamoDB table "test-table" has deletion protection enabled'); + done(); + }); + }); + + it('should FAIL if DynamoDB table does not have deletion protection enabled', function (done) { + const cache = createCache(listTables, describeTable[0]); + dynamoDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('DynamoDB table "test-table" does not have deletion protection enabled'); + done(); + }); + }); + + it('should PASS if No DynamoDB tables found', function (done) { + const cache = createCache([], describeTable[1]); + dynamoDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('No DynamoDB tables found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DynamoDB tables', function (done) { + const cache = createErrorCache(); + dynamoDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.include('us-east-1') + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for DynamoDB tables'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe DynamoDB table', function (done) { + const cache = createCache(listTables); + dynamoDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Unable to describe DynamoDB table'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dynamodb/dynamoKmsEncryption.js b/plugins/aws/dynamodb/dynamoKmsEncryption.js index 11288a373..50d3a76e8 100644 --- a/plugins/aws/dynamodb/dynamoKmsEncryption.js +++ b/plugins/aws/dynamodb/dynamoKmsEncryption.js @@ -4,23 +4,43 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'DynamoDB KMS Encryption', category: 'DynamoDB', + domain: 'Databases', + severity: 'High', description: 'Ensures DynamoDB tables are encrypted using a customer-owned KMS key.', more_info: 'DynamoDB tables can be encrypted using AWS-owned or customer-owned KMS keys. Customer keys should be used to ensure control over the encryption seed data.', link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/EncryptionAtRest.html', recommended_action: 'Create a new DynamoDB table using a CMK KMS key.', - apis: ['DynamoDB:listTables', 'DynamoDB:describeTable', 'STS:getCallerIdentity'], + apis: ['DynamoDB:listTables', 'DynamoDB:describeTable', 'STS:getCallerIdentity', 'KMS:listKeys', 'KMS:describeKey'], + remediation_description: 'The impacted DynamoDB table will be configured to use either KMS encryption with AWS managed CMK, or CMK-based encryption if a KMS key ID is provided.', + remediation_min_version: '202001121300', + apis_remediate: ['DynamoDB:listTables', 'KMS:listKeys', 'KMS:describeKey'], + actions: { + remediate: ['DynamoDB:updateTable'], + rollback: ['DynamoDB:updateTable'] + }, + permissions: { + remediate: ['DynamoDB:UpdateTable'], + rollback: ['DynamoDB:UpdateTable'] + }, + remediation_inputs: { + kmsKeyIdforDynamo: { + name: '(Optional) DynamoDB KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + realtime_triggers: ['dynamodb:CreateTable','dynamodb:UpdateTable','dynamodb:DeleteTable'], run: function(cache, settings, callback) { - var results = []; var source = {}; + var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); - var regions = helpers.regions(settings); - async.each(regions.dynamodb, function(region, rcb){ var listTables = helpers.addSource(cache, source, ['dynamodb', 'listTables', region]); @@ -44,20 +64,23 @@ module.exports = { var describeTable = helpers.addSource(cache, source, ['dynamodb', 'describeTable', region, table]); - var arn = 'arn:' + awsOrGov + ':dynamodb:' + region + ':' + accountId + ':table/' + table; + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; if (describeTable.err || !describeTable.data || !describeTable.data.Table) { helpers.addResult(results, 3, - 'Unable to describe DynamoDB table: ' + helpers.addError(describeTable), region, arn); + 'Unable to describe DynamoDB table: ' + helpers.addError(describeTable), region, resource); return rcb(); } - if (!describeTable.data.Table.SSEDescription) { - helpers.addResult(results, 1, - 'Table is using default encryption with AWS-owned key', region, arn); - } else { + + if (describeTable.data.Table.SSEDescription && + describeTable.data.Table.SSEDescription.Status && + describeTable.data.Table.SSEDescription.Status.toUpperCase() === 'ENABLED') { helpers.addResult(results, 0, - 'Table encryption is enabled with a KMS master key', region, arn); + 'Table encryption is enabled with a KMS master key', region, resource); + } else { + helpers.addResult(results, 2, + 'Table is using default encryption with AWS-owned key', region, resource); } } @@ -65,5 +88,68 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'dynamoKmsEncryption'; + var tableNameArr = resource.split(':'); + var tableName = tableNameArr[tableNameArr.length - 1].split('/')[1]; + let defaultKeyDesc = 'Default master key that protects my DynamoDB data when no other key is defined'; + + // find the location of the table needing to be remediated + var tableLocation = tableNameArr[3]; + + // add the location of the table to the config + config.region = tableLocation; + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforDynamo) { + params = { + 'TableName': tableName, + 'SSESpecification': { + 'Enabled': true, + 'KMSMasterKeyId': settings.input.kmsKeyIdforDynamo, + 'SSEType': 'KMS' + } + }; + } else { + let defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default DynamoDB key for the region ${config.region}`); + params = { + 'TableName': tableName, + 'SSESpecification': { + 'Enabled': true, + 'KMSMasterKeyId': defaultKmsKeyId, + 'SSEType': 'KMS' + } + }; + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'DEFAULT', + 'DynamoDB': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'DynamoDB': tableName + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } -}; +}; \ No newline at end of file diff --git a/plugins/aws/dynamodb/dynamoKmsEncryption.spec.js b/plugins/aws/dynamodb/dynamoKmsEncryption.spec.js new file mode 100644 index 000000000..896388173 --- /dev/null +++ b/plugins/aws/dynamodb/dynamoKmsEncryption.spec.js @@ -0,0 +1,163 @@ +var expect = require('chai').expect; +const dynamoKmsEncryption = require('./dynamoKmsEncryption'); + +const tables = [ + "dynamo-table-1", + "dynamo-table-2" +]; + +const describeTable = [ + { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "N" + } + ], + "TableName": "dynamo-table-1", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "TableStatus": "ACTIVE", + "CreationDateTime": 1605800911.034, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableSizeBytes": 0, + "ItemCount": 0, + "TableArn": "arn:aws:dynamodb:us-east-1:112233445566:table/dynamo-table-1", + "TableId": "abcef2b3-d76d-4ade-92bd-643d12545a20" + }, + }, + { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "N" + } + ], + "TableName": "dynamo-table-2", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "TableStatus": "ACTIVE", + "CreationDateTime": 1605800911.034, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableSizeBytes": 0, + "ItemCount": 0, + "TableArn": "arn:aws:dynamodb:us-east-1:112233445566:table/dynamo-table-2", + "TableId": "dqe324wd-wer2-eeq3-92bd-3131e231wq", + "SSEDescription": { + "Status": "Enabled", + "SSEType": "KMS", + "KMSMasterKeyArn": "arn:aws:kms:us-east-1:112233445566:key/6be9b9f1-bbc3-47f8-91c7-d2c1bed8d90c" + } + } + } +]; + +const createCache = (tables, describeTable) => { + var tableName = (tables && tables.length) ? tables[0] : null; + return { + dynamodb: { + listTables: { + 'us-east-1': { + data: tables, + }, + }, + describeTable: { + 'us-east-1': { + [tableName]: { + data: describeTable + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + dynamodb: { + listTables: { + 'us-east-1': { + err: { + message: 'error listing tables' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dynamodb: { + listTables: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('dynamoKmsEncryption', function () { + describe('run', function () { + it('should FAIL if table is using default encryption with AWS-owned key', function (done) { + const cache = createCache([tables[0]], describeTable[0]); + dynamoKmsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if table encryption is enabled with a KMS master key', function (done) { + const cache = createCache([tables[1]], describeTable[1]); + dynamoKmsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no DynamoDB tables found', function (done) { + const cache = createCache([]); + dynamoKmsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for DynamoDB tables', function (done) { + const cache = createErrorCache(); + dynamoKmsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list tables response not found', function (done) { + const cache = createNullCache(); + dynamoKmsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dynamodb/dynamoTableBackupExists.js b/plugins/aws/dynamodb/dynamoTableBackupExists.js new file mode 100644 index 000000000..48a64d4bf --- /dev/null +++ b/plugins/aws/dynamodb/dynamoTableBackupExists.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Table Backup Exists', + category: 'DynamoDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Amazon DynamoDB tables are using on-demand backups.', + more_info: 'With AWS Backup, you can configure backup policies and monitor activity for your AWS resources and on-premises workloads in one place. Using DynamoDB with AWS Backup, you can copy your on-demand backups across AWS accounts and regions, add cost allocation tags to on-demand backups, and transition on-demand backups to cold storage for lower costs.', + link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/BackupRestore.html', + recommended_action: 'Create on-demand backups for DynamoDB tables.', + apis: ['DynamoDB:listTables', 'DynamoDB:listBackups', 'STS:getCallerIdentity'], + realtime_triggers: ['dynamodb:CreateTable','backup:StartBackupJob','backup:DeleteRecoveryPoint','dynamodb:DeleteTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.dynamodb, function(region, rcb){ + var listTables = helpers.addSource(cache, source, + ['dynamodb', 'listTables', region]); + + if (!listTables) return rcb(); + + if (listTables.err || !listTables.data) { + helpers.addResult(results, 3, + `Unable to query for DynamoDB tables: ${helpers.addError(listTables)}`, + region); + return rcb(); + } + + if (!listTables.data.length) { + helpers.addResult(results, 0, 'No DynamoDB tables found', region); + return rcb(); + } + + for (let table of listTables.data){ + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; + + var listBackups = helpers.addSource(cache, source, + ['dynamodb', 'listBackups', region, table]); + + if (!listBackups || listBackups.err || !listBackups.data || !listBackups.data.BackupSummaries) { + helpers.addResult(results, 3, + `Unable to query backups for DynamoDB table: ${helpers.addError(listBackups)}`, + region, resource); + continue; + } + + if (!listBackups.data.BackupSummaries.length) { + helpers.addResult(results, 2, 'No backup exists for DynamoDB table', + region, resource); + } else { + helpers.addResult(results, 0, 'Backup exists for DynamoDB table', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/dynamoTableBackupExists.spec.js b/plugins/aws/dynamodb/dynamoTableBackupExists.spec.js new file mode 100644 index 000000000..a99df45fe --- /dev/null +++ b/plugins/aws/dynamodb/dynamoTableBackupExists.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +const dynamoTableBackupExists = require('./dynamoTableBackupExists'); + +const listTables = [ + "mine1", + "sadeedTable" +]; + +const listBackups = [ + { + "BackupSummaries": [ + { + "TableName": "sadeedTable", + "TableId": "a8077fa1-cb2a-473f-87e2-c776849ba4a5", + "TableArn": "arn:aws:dynamodb:us-east-1:000011112222:table/sadeedTable", + "BackupArn": "arn:aws:dynamodb:us-east-1:000011112222:table/sadeedTable/backup/01646839091311-17886cd9", + "BackupName": "backup1", + "BackupCreationDateTime": "2022-03-09T20:18:11.311000+05:00", + "BackupStatus": "AVAILABLE", + "BackupType": "USER", + "BackupSizeBytes": 0 + } + ] + }, + { + "BackupSummaries": [] + } +]; + +const createCache = (table, backups) => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + data: table + }, + }, + listBackups: { + 'us-east-1': { + [table]: { + data: backups + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + err: { + message: 'error listing DynamoDB tables' + }, + }, + }, + listBackups: { + 'us-east-1': { + err: { + message: 'error describing property' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': null, + }, + listBackups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('dynamoTableBackupExists', function () { + describe('run', function () { + it('should FAIL if no backup exists for DynamoDB table', function (done) { + const cache = createCache([listTables[0]], listBackups[1]); + dynamoTableBackupExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup exists for DynamoDB table') + done(); + }); + }); + + it('should PASS if backup exists for DynamoDB table', function (done) { + const cache = createCache([listTables[1]], listBackups[0]); + dynamoTableBackupExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Backup exists for DynamoDB table') + done(); + }); + }); + + it('should PASS No DynamoDB tables found', function (done) { + const cache = createCache([]); + dynamoTableBackupExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DynamoDB tables found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for DynamoDB tables', function (done) { + const cache = createErrorCache(); + dynamoTableBackupExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for DynamoDB tables') + done(); + }); + }); + + it('should not return any results if list listTables response not found', function (done) { + const cache = createNullCache(); + dynamoTableBackupExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/dynamodb/dynamoTableHasTags.js b/plugins/aws/dynamodb/dynamoTableHasTags.js new file mode 100644 index 000000000..9b103504d --- /dev/null +++ b/plugins/aws/dynamodb/dynamoTableHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Table Has Tags', + category: 'DynamoDB', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that DynamoDB tables have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Tagging.html', + recommended_action: 'Modify DynamoDB table and add tags.', + apis: ['DynamoDB:listTables', 'ResourceGroupsTaggingAPI:getResources', 'STS:getCallerIdentity'], + realtime_triggers: ['dynamodb:CreateTable','dynamodb:TagResource','dynamodb:UntagResource','dynamodb:DeleteTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.dynamodb, function(region, rcb){ + var listTables = helpers.addSource(cache, source, + ['dynamodb', 'listTables', region]); + + if (!listTables) return rcb(); + + if (listTables.err || !listTables.data) { + helpers.addResult(results, 3, + `Unable to query for DynamoDB tables: ${helpers.addError(listTables)}`, + region); + return rcb(); + } + + if (!listTables.data.length) { + helpers.addResult(results, 0, 'No DynamoDB tables found', region); + return rcb(); + } + + const ARNList = []; + for (let table of listTables.data){ + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; + ARNList.push(resource); + } + helpers.checkTags(cache, 'DynamoDB table', ARNList, region, results, settings); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/dynamoTableHasTags.spec.js b/plugins/aws/dynamodb/dynamoTableHasTags.spec.js new file mode 100644 index 000000000..3ba70e280 --- /dev/null +++ b/plugins/aws/dynamodb/dynamoTableHasTags.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var dynamoTable = require('./dynamoTableHasTags'); + +const createCache = (tableData, rgData) => { + return { + dynamodb: { + listTables: { + 'us-east-1': { + err: null, + data: tableData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '1111222222222' + } + } + } + } +}; + +describe('dynamoTableHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list the dynamodb tables', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for DynamoDB tables'); + done(); + }; + + const cache = createCache( + null, [] + ); + + dynamoTable.run(cache, {}, callback); + }); + + it('should give passing result if no dynamodb table found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No DynamoDB tables found'); + done(); + }; + + const cache = createCache( + [], null + ); + + dynamoTable.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources from group'); + done(); + }; + + const cache = createCache( + ['MyModelTypeTable'], + null + ); + + dynamoTable.run(cache, {}, callback); + }); + + it('should give passing result if dynamoDB table has tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('DynamoDB table has tags'); + done(); + }; + + const cache = createCache( + ['MyModelTypeTable'], + [{ + "ResourceARN": "arn:aws:dynamodb:us-east-1:1111222222222:table/MyModelTypeTable", + "Tags": [{key:"key1", value:"value"}], + }] + ); + dynamoTable.run(cache, {}, callback); + }) + + it('should give failing result if dynamoDB table does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('DynamoDB table does not have any tags'); + done(); + }; + + const cache = createCache( + ['MyModelTypeTable'], + [{ + "ResourceARN": "arn:aws:dynamodb:us-east-1:1111222222222:table/MyModelTypeTable", + "Tags": [], + }] + ); + + dynamoTable.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/aws/dynamodb/dynamoUnusedTable.js b/plugins/aws/dynamodb/dynamoUnusedTable.js new file mode 100644 index 000000000..01b49a2c0 --- /dev/null +++ b/plugins/aws/dynamodb/dynamoUnusedTable.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DynamoDB Empty Table', + category: 'DynamoDB', + domain: 'Databases', + description: 'Ensures that Amazon DynamoDB empty tables are removed to optimise costs.', + severity: 'Low', + more_info: 'A DynamoDB table is considered unused if its item count is zero. As a best practice, delete unused tables for operational efficiency and better resource management. This will also prevent resource wastage and unnecessary costs. This plugin might produce false positives or false negatives as AWS updates table count every 6 hours.', + link: 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithTables.Basics.html', + recommended_action: 'Remove unused tables if you no longer need them.', + apis: ['DynamoDB:listTables', 'DynamoDB:describeTable', 'STS:getCallerIdentity'], + realtime_triggers: ['dynamodb:CreateTable','dynamodb:DeleteTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.dynamodb, function(region, rcb){ + var listTables = helpers.addSource(cache, source, + ['dynamodb', 'listTables', region]); + + if (!listTables) return rcb(); + + if (listTables.err || !listTables.data) { + helpers.addResult(results, 3, + `Unable to query for DynamoDB tables: ${helpers.addError(listTables)}`, region); + return rcb(); + } + + if (!listTables.data.length) { + helpers.addResult(results, 0, 'No DynamoDB tables found', region); + return rcb(); + } + + for (let table of listTables.data){ + var resource = `arn:${awsOrGov}:dynamodb:${region}:${accountId}:table/${table}`; + + var describeTable = helpers.addSource(cache, source, + ['dynamodb', 'describeTable', region, table]); + + if (!describeTable || describeTable.err || !describeTable.data) { + helpers.addResult(results, 3, + `Unable to describe DynamoDB table: ${helpers.addError(describeTable)}`, + region, resource); + continue; + } + + if (describeTable.data && describeTable.data.Table && !describeTable.data.Table.ItemCount) { + helpers.addResult(results, 2, + `DynamoDB table "${table}" is empty`, + region, resource); + } else { + helpers.addResult(results, 0, + `DynamoDB table "${table}" is being used`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/dynamodb/dynamoUnusedTable.spec.js b/plugins/aws/dynamodb/dynamoUnusedTable.spec.js new file mode 100644 index 000000000..7850432d4 --- /dev/null +++ b/plugins/aws/dynamodb/dynamoUnusedTable.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +const dynamoUnusedTable = require('./dynamoUnusedTable'); + +const listTables = [ + "akd-03" +]; + +const describeTable = [ + { + "Table": { + "ItemCount": 0 + } + }, + { + "Table": { + "ItemCount": 1 + } + }, +]; + +const createCache = (table, details) => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + data: table + }, + }, + describeTable: { + 'us-east-1': { + [table]: { + data: details + }, + }, + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '1111222222222' + } + } + } + }; +}; + +const createErrorCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': { + err: { + message: 'error listing DynamoDB tables' + }, + }, + }, + describeTable: { + 'us-east-1': { + err: { + message: 'error describing property' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + dynamodb:{ + listTables: { + 'us-east-1': null, + }, + describeTable: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('dynamoUnusedTable', function () { + describe('run', function () { + it('should PASS if DynamoDB table is being used', function (done) { + const cache = createCache(listTables, describeTable[1]); + dynamoUnusedTable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DynamoDB table "akd-03" is being used'); + done(); + }); + }); + + it('should FAIL if DynamoDB table is not used', function (done) { + const cache = createCache(listTables, describeTable[0]); + dynamoUnusedTable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DynamoDB table "akd-03" is empty'); + done(); + }); + }); + + it('should PASS if No DynamoDB tables found', function (done) { + const cache = createCache([], describeTable[1]); + dynamoUnusedTable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DynamoDB tables found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DynamoDB tables', function (done) { + const cache = createErrorCache(); + dynamoUnusedTable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for DynamoDB tables'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe DynamoDB table', function (done) { + const cache = createCache(listTables); + dynamoUnusedTable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe DynamoDB table'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/allowedCustomPorts.js b/plugins/aws/ec2/allowedCustomPorts.js new file mode 100644 index 000000000..fffa0a27a --- /dev/null +++ b/plugins/aws/ec2/allowedCustomPorts.js @@ -0,0 +1,149 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Allowed Custom Ports', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensures that security groups does not allow public access to any port.', + more_info: 'Security groups should be used to restrict access to ports from known networks.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html', + recommended_action: 'Modify the security group to ensure the ports are not exposed publicly', + apis: ['EC2:describeSecurityGroups'], + settings: { + whitelisted_open_ports: { + name: 'Whitelisted Open Ports', + description: 'A comma-separated list of ports/port-ranges that indicates open ports allowed for any connection. Example: tcp:80,tcp:443,tcp:80-443', + regex: '[a-zA-Z0-9,:]', + default: '' + } + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var whitelisted_open_ports = settings.whitelisted_open_ports || this.settings.whitelisted_open_ports.default; + + if (!whitelisted_open_ports.length) return callback(); + + whitelisted_open_ports = whitelisted_open_ports.split(','); + + + var ports = {}; + whitelisted_open_ports.forEach(port => { + var [protocol, portNo] = port.split(':'); + if (portNo.indexOf('-') > -1) { + var portRange = portNo.split('-'); + var rangeFrom = portRange[0]; + var rangeTo = portRange[1]; + for (var i = rangeFrom; i <= rangeTo; i++) { + if (ports[protocol]) { + ports[protocol].push(Number(i)); + } else { + ports[protocol] = [Number(i)]; + } + } + } else { + if (ports[protocol]) { + ports[protocol].push(Number(portNo)); + } else { + ports[protocol] = [Number(portNo)]; + } + } + }); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to query for security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + // Loop through each security group + for (var g in describeSecurityGroups.data) { + var group = describeSecurityGroups.data[g]; + var resource = `arn:${awsOrGov}:ec2:${region}:${group.OwnerId}:security-group/${group.GroupId}`; + + if (!group.IpPermissions) continue; + + var cidrIps = []; + + // Loop through each ip permissions in a security group + IpPermissionsLoop: + for (var p in group.IpPermissions) { + var permission = group.IpPermissions[p]; + + // Loop through each ip range for an ip permissions list + IpRangesLoop: + for (var r in permission.IpRanges) { + var range = permission.IpRanges[r]; + + if (range.CidrIp && range.CidrIp === '0.0.0.0/0') { + var allowedPorts = ports[permission.IpProtocol] || []; + var portRange = permission.ToPort - permission.FromPort; + // Check for all the ports in port range + for (let p=0; p <= portRange; p++) { + var port = permission.FromPort + p; + + if (!allowedPorts.includes(port)) { + if (!cidrIps.length) cidrIps.push(range.CidrIp); + break IpRangesLoop; + } + } + } + } + + + for (var l in permission.Ipv6Ranges) { + var rangeV6 = permission.Ipv6Ranges[l]; + + if (rangeV6.CidrIpv6 && rangeV6.CidrIpv6 === '::/0') { + var allowedV6Ports = ports[permission.IpProtocol] || []; + var portRangeV6 = permission.ToPort - permission.FromPort; + + // Check for all the ports in port range + for (let p=0; p <= portRangeV6; p++) { + var portV6 = permission.FromPort + p; + + if (!allowedV6Ports.includes(portV6)) { + cidrIps.push(rangeV6.CidrIpv6); + break IpPermissionsLoop; + } + } + } + } + } + + if (!cidrIps.length) { + helpers.addResult(results, 0, + `Security group "${group.GroupName}" does not have ports open to 0.0.0.0/0`, + region, resource); + } else { + helpers.addResult(results, 2, + `Security group "${group.GroupName}" has ports open to ${cidrIps}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/allowedCustomPorts.spec.js b/plugins/aws/ec2/allowedCustomPorts.spec.js new file mode 100644 index 000000000..c7f3efe18 --- /dev/null +++ b/plugins/aws/ec2/allowedCustomPorts.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +const allowedCustomPorts = require('./allowedCustomPorts'); + +const securityGroups = [ + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissions": [{ + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 30, + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-0b5f2771716acfee4", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "launch-wizard-1 created 2020-08-10T14:28:09.271+05:00", + "GroupName": "launch-wizard-1", + "IpPermissions": [ + { + "FromPort": 80, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 80, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "12345654321", + "GroupId": "sg-0ff1642cae23c309a", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +] + +const createCache = (groups) => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('allowedCustomPorts', function () { + describe('run', function () { + it('should FAIL if security group has open ports', function (done) { + const cache = createCache([securityGroups[0]]); + allowedCustomPorts.run(cache, { whitelisted_open_ports: 'tcp:80-82,tcp:443' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if security group does not have open ports', function (done) { + const cache = createCache([securityGroups[1]]); + allowedCustomPorts.run(cache, { whitelisted_open_ports: 'tcp:80-85,tcp:443' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + allowedCustomPorts.run(cache, { whitelisted_open_ports: 'tcp:80,tcp:443' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe security groups', function (done) { + const cache = createErrorCache(); + allowedCustomPorts.run(cache, { whitelisted_open_ports: 'tcp:80,tcp:443' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe security groups response not found', function (done) { + const cache = createNullCache(); + allowedCustomPorts.run(cache, { whitelisted_open_ports: 'tcp:80,tcp:443' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/amiHasTags.js b/plugins/aws/ec2/amiHasTags.js new file mode 100644 index 000000000..737e8dd4f --- /dev/null +++ b/plugins/aws/ec2/amiHasTags.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AMI Has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that AMIs have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://aws.amazon.com/about-aws/whats-new/2020/12/amazon-machine-images-support-tag-on-create-tag-based-access-control/', + recommended_action: 'Modify AMI and add tags.', + apis: ['EC2:describeImages'], + realtime_triggers: ['ec2:CreateImage', 'ec2:CreateTags', 'ec2:DeleteTags', 'ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + 'Unable to query for AMIs: ' + helpers.addError(describeImages), region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, 'No AMIs found', region); + return rcb(); + } + + for (var ami of describeImages.data) { + if (!ami.ImageId) continue; + + const arn ='arn:' + awsOrGov + ':ec2:' + region + '::image/' + ami.ImageId; + if (!ami.Tags || !ami.Tags.length) { + helpers.addResult(results, 2, 'AMI does not have any tags', region, arn); + } else { + helpers.addResult(results, 0, 'AMI has tags', region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/amiHasTags.spec.js b/plugins/aws/ec2/amiHasTags.spec.js new file mode 100644 index 000000000..0deab3d62 --- /dev/null +++ b/plugins/aws/ec2/amiHasTags.spec.js @@ -0,0 +1,78 @@ +var expect = require('chai').expect; +const amiHasTags = require('./amiHasTags'); + +var describeImages = [ + { + Tags: [{ Key: 'key', value: 'value' }], + ImageId: 'ami-046b09f5340dfd8gb' + + }, + { + Tags: [], + ImageId: 'ami-046b09f5340dfd8gb' + + } +] + +const createCache = (instances) => { + return { + ec2: { + describeImages: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + + +describe('amiHasTags', function () { + describe('run', function () { + + it('should return UNKNOWN result if error occurs while describing AMIs', function (done) { + const cache = createCache(null); + amiHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for AMIs'); + done(); + }); + }); + + it('should return Passing result if no AMI found', function (done) { + const cache = createCache([]); + amiHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No AMIs found'); + done(); + }); + }); + + it('should return Passing result if AMI tags', function (done) { + const cache = createCache([describeImages[0]]); + amiHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI has tags'); + done(); + }); + }); + + it('should return Fail result if EC2 instance has no tags', function (done) { + const cache = createCache([describeImages[1]]); + amiHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI does not have any tags'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/amiNamingConvention.js b/plugins/aws/ec2/amiNamingConvention.js new file mode 100644 index 000000000..d3af4fe89 --- /dev/null +++ b/plugins/aws/ec2/amiNamingConvention.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AMI Naming Conventions', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Amazon Machine Images (AMIs) follow organizational naming conventions for tagging', + more_info: 'AMIs should follow a consistent naming convention using the Name tag to identify their purpose, environment, and region. This helps prevent accidental use of incorrect images, reduces operational errors, and improves resource management. Without proper naming conventions, teams may deploy instances with outdated or inappropriate AMIs, leading to security vulnerabilities or configuration issues.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html', + recommended_action: 'Update AMI Name tags to follow organizational naming conventions.', + apis: ['EC2:describeImages'], + settings: { + ami_naming_pattern: { + name: 'AMI Naming Pattern', + description: 'A regex pattern to validate AMI Name tag values. Default: ^ami-(ue1|uw1|uw2|ew1|ec1|an1|an2|as1|as2|se1)-(d|t|s|p)-([a-z0-9\\-]+)$', + regex: '^.*$', + default: '^ami-(ue1|uw1|uw2|ew1|ec1|an1|an2|as1|as2|se1)-(d|t|s|p)-([a-z0-9\\-]+)$' + } + }, + realtime_triggers: ['ec2:CreateImage', 'ec2:CreateTags', 'ec2:DeleteTags', 'ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + ami_naming_pattern: settings.ami_naming_pattern || this.settings.ami_naming_pattern.default + }; + + var namingPattern = new RegExp(config.ami_naming_pattern); + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + 'Unable to query for AMIs: ' + helpers.addError(describeImages), region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, 'No AMIs found', region); + return rcb(); + } + + for (var ami of describeImages.data) { + if (!ami.ImageId) continue; + + const arn = 'arn:' + awsOrGov + ':ec2:' + region + '::image/' + ami.ImageId; + + if (!ami.Tags || !ami.Tags.length) { + helpers.addResult(results, 2, + 'AMI does not have a name tag', region, arn); + continue; + } + + var nameTag = ami.Tags.find(tag => tag.Key === 'Name'); + + if (!nameTag || !nameTag.Value) { + helpers.addResult(results, 2, + 'AMI does not have a name tag', region, arn); + } else if (!namingPattern.test(nameTag.Value)) { + helpers.addResult(results, 2, + `AMI Name tag "${nameTag.Value}" does not follow organizational naming convention`, region, arn); + } else { + helpers.addResult(results, 0, + `AMI Name tag "${nameTag.Value}" follows organizational naming convention`, region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/amiNamingConvention.spec.js b/plugins/aws/ec2/amiNamingConvention.spec.js new file mode 100644 index 000000000..e36c7b1ed --- /dev/null +++ b/plugins/aws/ec2/amiNamingConvention.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +const amiNamingConvention = require('./amiNamingConvention'); + +const describeImages = [ + { + ImageId: 'ami-046b09f5340dfd8gb', + Tags: [ + { Key: 'Name', Value: 'ami-ue1-p-nodejs' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gc', + Tags: [ + { Key: 'Name', Value: 'ami-uw2-d-apache-spark' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gd', + Tags: [ + { Key: 'Name', Value: 'MyCustomAMI' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8ge', + Tags: [ + { Key: 'Environment', Value: 'Production' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gf', + Tags: [] + } +]; + +const createCache = (instances) => { + return { + ec2: { + describeImages: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing AMIs' + } + }, + }, + }, + }; +}; + + +describe('amiNamingConvention', function () { + describe('run', function () { + + it('should return UNKNOWN if unable to query for AMIs', function (done) { + const cache = createErrorCache(); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for AMIs'); + done(); + }); + }); + + it('should return PASS if no AMIs found', function (done) { + const cache = createCache([]); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No AMIs found'); + done(); + }); + }); + + it('should return PASS if AMI Name tag follows naming convention', function (done) { + const cache = createCache([describeImages[0]]); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('follows organizational naming convention'); + done(); + }); + }); + + it('should return FAIL if AMI Name tag does not follow naming convention', function (done) { + const cache = createCache([describeImages[2]]); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('does not follow organizational naming convention'); + done(); + }); + }); + + it('should return FAIL if AMI does not have a name tag', function (done) { + const cache = createCache([describeImages[3]]); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI does not have a name tag'); + done(); + }); + }); + + it('should return FAIL if AMI has empty tags array', function (done) { + const cache = createCache([describeImages[4]]); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI does not have a name tag'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ec2/appTierInstanceIamRole.js b/plugins/aws/ec2/appTierInstanceIamRole.js new file mode 100644 index 000000000..2088e8508 --- /dev/null +++ b/plugins/aws/ec2/appTierInstanceIamRole.js @@ -0,0 +1,142 @@ +// TODO: MOVE TO EC2 +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier EC2 Instance IAM Role', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure IAM roles attached with App-Tier EC2 instances have IAM policies attached.', + more_info: 'EC2 instances should have IAM roles configured with necessary permission to access other AWS services', + link: 'https://aws.amazon.com/blogs/security/new-attach-an-aws-iam-role-to-an-existing-amazon-ec2-instance-by-using-the-aws-cli/', + recommended_action: 'Modify EC2 instances to attach IAM roles with required IAM policies', + apis: ['EC2:describeInstances', 'IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies'], + settings: { + ec2_app_tier_tag_key: { + name: 'EC2 App-Tier Tag Key', + description: 'Tag key to indicate App-Tier EC2 instances', + regex: '^.*$s', + default: '' + }, + }, + realtime_triggers: ['ec2:RunInstances', 'ec2:AssociateIamInstanceProfile', 'ec2:DisassociateIamInstanceProfile', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var awsOrGov = helpers.defaultPartition(settings); + var config = { + ec2_app_tier_tag_key: settings.ec2_app_tier_tag_key || this.settings.ec2_app_tier_tag_key.default + }; + + if (!config.ec2_app_tier_tag_key.length) return callback(null, results, source); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource( + cache, source, ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, `Unable to query for instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + var describeTags = helpers.addSource( + cache, source, ['ec2', 'describeTags', region]); + + if (!describeTags || describeTags.err || !describeTags.data) { + helpers.addResult(results, 3, `Unable to query for tags: ${helpers.addError(describeTags)}`, region); + return rcb(); + } + + if (!describeTags.data.length) { + helpers.addResult(results, 0, 'No tags found', region); + return rcb(); + } + + async.each(describeInstances.data, function(instance, cb){ + var accountId = instance.OwnerId; + + for (var i in instance.Instances) { + var entry = instance.Instances[i]; + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:instance/${entry.InstanceId}`; + + var tagFound = false; + if (entry.Tags && entry.Tags.length) { + tagFound = entry.Tags.find(tag => tag.Key === config.ec2_app_tier_tag_key); + } + + if (!tagFound) { + helpers.addResult(results, 0, 'Instance does not have App-Tier tag key', region, resource); + return cb(); + } + + if (!entry.IamInstanceProfile || + !entry.IamInstanceProfile.Arn) { + helpers.addResult(results, 2, + 'Instance does not use an IAM role', region, resource); + } else { + var roleNameArr = entry.IamInstanceProfile.Arn.split('/'); + var roleName = roleNameArr[roleNameArr.length-1]; + + // Get managed policies attached to role + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', region, roleName]); + + + if (!listAttachedRolePolicies || + listAttachedRolePolicies.err || + !listAttachedRolePolicies.data || + !listAttachedRolePolicies.data.AttachedPolicies) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}" ${helpers.addError(listAttachedRolePolicies)}`, region, resource); + return cb(); + } + + if (listAttachedRolePolicies.data.AttachedPolicies.length) { + helpers.addResult(results, 0, + 'IAM role attached with EC2 instance contains policies', region, resource); + return cb(); + } + + // Get inline policies attached to role + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', region, roleName]); + + if (!listRolePolicies || + listRolePolicies.err || + !listRolePolicies.data || + !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}" ${helpers.addError(listRolePolicies)}`, region, resource); + return cb(); + } + + if (listRolePolicies.data.PolicyNames.length) { + helpers.addResult(results, 0, + 'IAM role attached with EC2 instance contains policies', region, resource); + } else { + helpers.addResult(results, 2, + 'IAM role attached with EC2 instance does not contain any policies', region, resource); + } + } + } + + cb(); + }); + + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/appTierInstanceIamRole.spec.js b/plugins/aws/ec2/appTierInstanceIamRole.spec.js new file mode 100644 index 000000000..8d3064187 --- /dev/null +++ b/plugins/aws/ec2/appTierInstanceIamRole.spec.js @@ -0,0 +1,399 @@ +var expect = require('chai').expect; +const appTierInstanceIamRole = require('./appTierInstanceIamRole'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-18T22:48:08.000Z", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-role-1", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "Tags": [ + { + "Key": "app-tier", + "Value": "app-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-087ce52925d75c272" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-036d7bf13e0bfe836", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-ec2-role-2", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-1323e23rede231231" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-8ee72s82hsn2nw22w", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-ec2-role-3", + "Id": "AIPAYE32SRU5VWPKDMWKD" + }, + "Tags": [ + { + "Key": "app-tier", + "Value": "app-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-d23ed2ed23wqe2r45" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-12f34r3refkn34irw", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "Tags": [ + { + "Key": "app-tier", + "Value": "app-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-0i09oijmi32e2q3e23" + } +]; + +const describeTags = [ + { + "Key": "app-tier", + "ResourceId": "i-0e5b41e1d67462547", + "ResourceType": "instance", + "Value": "app-tier" + }, + { + "Key": "app-tier", + "ResourceId": "i-8ee72s82hsn2nw22w", + "ResourceType": "instance", + "Value": "app-tier" + }, + { + "Key": "app-tier", + "ResourceId": "i-12f34r3refkn34irw", + "ResourceType": "instance", + "Value": "app-tier" + } +]; + +const listRoles = [ + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-1", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-2", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-2", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-3", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-3", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + { + "PolicyName": "AWSElasticBeanstalkWebTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + }, + { + "PolicyName": "AWSElasticBeanstalkMulticontainerDocker", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker" + }, + { + "PolicyName": "AWSElasticBeanstalkWorkerTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier" + } + ] + }, + { + "PolicyNames": [] + } +]; + +const listAttachedRolePolicies = [ + { + "AttachedPolicies": [ + { + "PolicyName": "AWSElasticBeanstalkWebTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + }, + { + "PolicyName": "AWSElasticBeanstalkMulticontainerDocker", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker" + }, + { + "PolicyName": "AWSElasticBeanstalkWorkerTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier" + } + ] + }, + { + "AttachedPolicies": [] + } +]; + +const createCache = (describeInstances, describeTags, listRoles, listRolePolicies, listAttachedRolePolicies) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: describeInstances + }, + }, + describeTags: { + 'us-east-1': { + data: describeTags + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + } + }, + }, + describeTags: { + 'us-east-1': { + err: { + message: 'error describing tags' + } + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing roles' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null + }, + describeTags: { + 'us-east-1': null + } + }, + iam: { + listRoles: { + 'us-east-1': null + }, + listAttachedRolePolicies: { + 'us-east-1': null + }, + listRolePolicies: { + 'us-east-1': null + } + } + }; +}; + +describe('appTierInstanceIamRole', function () { + describe('run', function () { + it('should PASS if IAM role attached with EC2 instance contains policies', function (done) { + const cache = createCache([describeInstances[0]], describeTags, [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[1]); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if IAM role attached with EC2 instance does not contain policies', function (done) { + const cache = createCache([describeInstances[0]], describeTags, [listRoles[0]], listRolePolicies[1], listAttachedRolePolicies[1]); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if instance does not use an IAM role', function (done) { + const cache = createCache([describeInstances[3]], describeTags, [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[0]); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if instance does not have App-Tier tag key', function (done) { + const cache = createCache([describeInstances[0]], [describeTags[2]], [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[0]); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EC2 instances found', function (done) { + const cache = createCache([]); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no tags found', function (done) { + const cache = createCache([describeInstances[0]], []); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EC2 instances', function (done) { + const cache = createErrorCache(); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe EC2 instances response not found', function (done) { + const cache = createNullCache(); + appTierInstanceIamRole.run(cache, { ec2_app_tier_tag_key: 'app-tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any results if App-Tier tag key is not provided in settings', function (done) { + const cache = createNullCache(); + appTierInstanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/classicInstances.js b/plugins/aws/ec2/classicInstances.js index b013e43d9..c8b7cfddc 100644 --- a/plugins/aws/ec2/classicInstances.js +++ b/plugins/aws/ec2/classicInstances.js @@ -5,6 +5,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Detect EC2 Classic Instances', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures AWS VPC is being used for instances instead of EC2 Classic', more_info: 'VPCs are the latest and more secure method of launching AWS resources. EC2 Classic should not be used.', link: 'http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Introduction.html', @@ -18,6 +20,7 @@ module.exports = { 'segmentation criteria for PCI. Ensure all instances are launched ' + 'within a VPC to comply with isolation requirements.' }, + realtime_triggers: ['ec2:RunInstances','ec2:TerminateInstances'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/ec2/classicInstances.spec.js b/plugins/aws/ec2/classicInstances.spec.js new file mode 100644 index 000000000..372c00ae1 --- /dev/null +++ b/plugins/aws/ec2/classicInstances.spec.js @@ -0,0 +1,248 @@ +var expect = require('chai').expect; +const classicInstances = require('./classicInstances'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0ceecc81a1c5829f6", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-09T21:27:25.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1b", + "GroupName": "", + "Tenancy": "default" + }, + "PublicIpAddress": "3.84.159.125", + "State": { + "Code": 0, + "Name": "running" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "NetworkInterfaces": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-3-84-159-125.compute-1.amazonaws.com", + "PublicIp": "3.84.159.125" + }, + "Attachment": { + "AttachTime": "2020-11-09T21:27:25.000Z", + "AttachmentId": "eni-attach-0ac6a634b2341fcbf", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attaching" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-3", + "GroupId": "sg-00227d48f69020516" + } + ], + "Ipv6Addresses": [], + "MacAddress": "12:b5:1d:12:ba:01", + "NetworkInterfaceId": "eni-060ab6c65e9b16de9", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-83-241.ec2.internal", + "PrivateIpAddress": "172.31.83.241", + "PrivateIpAddresses": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-3-84-159-125.compute-1.amazonaws.com", + "PublicIp": "3.84.159.125" + }, + "Primary": true, + "PrivateDnsName": "ip-172-31-83-241.ec2.internal", + "PrivateIpAddress": "172.31.83.241" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + } + ] + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0ceecc81a1c5829f6", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-09T21:27:25.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1b", + "GroupName": "", + "Tenancy": "default" + }, + "PublicIpAddress": "3.84.159.125", + "State": { + "Code": 0, + "Name": "running" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + } + ] + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0ceecc81a1c5829f6", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-09T21:27:25.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1b", + "GroupName": "", + "Tenancy": "default" + }, + "PublicIpAddress": "3.84.159.125", + "State": { + "Code": 0, + "Name": "pending" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + } + ] + } +]; + + +const createCache = (instances) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing EC2 instances' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('classicInstances', function () { + describe('run', function () { + it('should PASS if EC2 instances are in a VPC', function (done) { + const cache = createCache([describeInstances[0]]); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if EC2 instances are in EC2-Classic', function (done) { + const cache = createCache([describeInstances[1]]); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no running instances found', function (done) { + const cache = createCache([describeInstances[2]]); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe instances', function (done) { + const cache = createErrorCache(); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe instances response not found', function (done) { + const cache = createNullCache(); + classicInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/crossVpcPublicPrivate.js b/plugins/aws/ec2/crossVpcPublicPrivate.js index 3a01f52f7..65eabb842 100644 --- a/plugins/aws/ec2/crossVpcPublicPrivate.js +++ b/plugins/aws/ec2/crossVpcPublicPrivate.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Cross VPC Public Private Communication', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures communication between public and private VPC tiers is not enabled', more_info: 'Communication between the public tier of one VPC and the private tier of other VPCs should never be allowed. Instead, VPC peerings with proper NACLs and gateways should be used', link: 'https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Subnets.html', @@ -15,18 +17,22 @@ module.exports = { 'communicate across these segmented boundaries. Ensure that public ' + 'services in one VPC cannot communicate with the private tier of another.' }, + realtime_triggers: ['ec2:CreateVpcPeeringConnection', 'ec2:ModifyVpcPeeringConnectionOptions', 'ec2:DeleteVpcPeeringConnection'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb) { // for Subnets var describeSubnets = helpers.addSource(cache, source, ['ec2', 'describeSubnets', region]); + if (!describeSubnets) return rcb(); + // error handling - if (!describeSubnets || !describeSubnets.data || describeSubnets.err) { + if (describeSubnets.data || describeSubnets.err) { helpers.addResult(results, 3, 'Unable to query for Subnets: ' + helpers.addError(describeSubnets), region); return rcb(); } @@ -219,7 +225,7 @@ module.exports = { for (var d in prvRecord) { if (pubRecord[c].peeringId == prvRecord[d].peeringId) { register++; - helpers.addResult(results, 2, 'A route between public and private subnets of different VPCs found, for Subnets: ' + pubRecord[c].subnetId + ' and ' + prvRecord[d].subnetId, region, 'arn:aws:ec2:' + region + ':' + prvRecord[d].ownerId + ':vpc-peering-connection/' + prvRecord[d].peeringId); + helpers.addResult(results, 2, 'A route between public and private subnets of different VPCs found, for Subnets: ' + pubRecord[c].subnetId + ' and ' + prvRecord[d].subnetId, region, `arn:${awsOrGov}:ec2:` + region + ':' + prvRecord[d].ownerId + ':vpc-peering-connection/' + prvRecord[d].peeringId); } } } diff --git a/plugins/aws/ec2/defaultSecurityGroup.js b/plugins/aws/ec2/defaultSecurityGroup.js index 35fcb7397..e4dba6f94 100644 --- a/plugins/aws/ec2/defaultSecurityGroup.js +++ b/plugins/aws/ec2/defaultSecurityGroup.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Default Security Group', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensure the default security groups block all traffic by default', more_info: 'The default security group is often used for resources launched without a defined security group. For this reason, the default rules should be to block all traffic to prevent an accidental exposure.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html#default-security-group', @@ -16,11 +18,13 @@ module.exports = { 'unintended traffic to cross these isolation boundaries.', cis2: '4.3 Ensure the default security group of every VPC restricts all traffic' }, + realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeSecurityGroups = helpers.addSource(cache, source, @@ -42,7 +46,7 @@ module.exports = { for (var s in describeSecurityGroups.data) { var sg = describeSecurityGroups.data[s]; // arn:aws:ec2:region:account-id:security-group/security-group-id - var resource = 'arn:aws:ec2:' + region + ':' + sg.OwnerId + ':security-group/' + sg.GroupId; + var resource = `arn:${awsOrGov}:ec2:` + region + ':' + sg.OwnerId + ':security-group/' + sg.GroupId; if (sg.GroupName === 'default') { if (sg.IpPermissions.length || diff --git a/plugins/aws/ec2/defaultSecurityGroup.spec.js b/plugins/aws/ec2/defaultSecurityGroup.spec.js new file mode 100644 index 000000000..41de65146 --- /dev/null +++ b/plugins/aws/ec2/defaultSecurityGroup.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +const defaultSecurityGroup = require('./defaultSecurityGroup'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [ + { + "IpProtocol": "-1", + "IpRanges": [], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [ + { + "GroupId": "sg-aa941691", + "UserId": "111122223333" + } + ] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const createCache = (groups) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('defaultSecurityGroup', function () { + describe('run', function () { + it('should PASS if default security group has inbound or outbound rules', function (done) { + const cache = createCache([describeSecurityGroups[0]]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if default security group does not have inbound or outbound rules', function (done) { + const cache = createCache([describeSecurityGroups[1]]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createErrorCache(); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/defaultSecurityGroupInUse.js b/plugins/aws/ec2/defaultSecurityGroupInUse.js new file mode 100644 index 000000000..3a2dec6d8 --- /dev/null +++ b/plugins/aws/ec2/defaultSecurityGroupInUse.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Default Security Group In Use', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that AWS EC2 Instances are not associated with default security group.', + more_info: 'The default security group allows all traffic inbound and outbound, which can make your resources vulnerable to attacks. Ensure that the Amazon EC2 instances are not associated with the default security groups.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html#default-security-group', + recommended_action: 'Modify EC2 instances and change security group.', + apis: ['EC2:describeInstances'], + realtime_triggers: ['ec2:RunInstances', 'ec2:ModifyInstanceAttribute', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource(cache, source, ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, `Unable to query for instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + for (var instances of describeInstances.data){ + const { OwnerId } = instances; + + for (var instance of instances.Instances) { + const { InstanceId } = instance; + const arn = `arn:${awsOrGov}:ec2:${region}:${OwnerId}:instance/${InstanceId}`; + const defaultSecurityGroup = (instance.SecurityGroups && instance.SecurityGroups.length) ? instance.SecurityGroups.find(sg => sg.GroupName.toLowerCase() == 'default'): false; + if (defaultSecurityGroup) { + helpers.addResult(results, 2, 'EC2 instance is associated with default security group', region, arn); + } else { + helpers.addResult(results, 0, 'EC2 instance is not associated with default security group', region, arn); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/defaultSecurityGroupInUse.spec.js b/plugins/aws/ec2/defaultSecurityGroupInUse.spec.js new file mode 100644 index 000000000..5cbe7d8c5 --- /dev/null +++ b/plugins/aws/ec2/defaultSecurityGroupInUse.spec.js @@ -0,0 +1,252 @@ +var expect = require('chai').expect; +const ec2AssociatedWithDefaultSG = require('./defaultSecurityGroupInUse'); + +describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [ + { + "Key": "Name", + "Value": "sploit-959-test-instance" + } + ], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + } +] + +const createCache = (groups) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: groups + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ec2AssociatedWithDefaultSG', function () { + describe('run', function () { + it('should PASS if EC2 is not associated with default security group', function (done) { + const cache = createCache([describeInstances[0]]); + ec2AssociatedWithDefaultSG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].message).to.include('EC2 instance is not associated with default security group'); + done(); + }); + }); + + // it('should FAIL if EC2 is associated with default security group', function (done) { + // const cache = createCache([describeInstances[1]]); + // ec2AssociatedWithDefaultSG.run(cache, {}, (err, results) => { + // expect(results.length).to.equal(1); + // expect(results[0].status).to.equal(2); + // expect(results[0].region).to.equal('us-east-1') + // expect(results[0].message).to.include('EC2 instance is associated with default security group'); + // done(); + // }); + // }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + ec2AssociatedWithDefaultSG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].message).to.include('No EC2 instances found'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createErrorCache(); + ec2AssociatedWithDefaultSG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].message).to.include('Unable to query for instances:'); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + ec2AssociatedWithDefaultSG.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/defaultVpcExists.js b/plugins/aws/ec2/defaultVpcExists.js index bfc71417d..68f51eb92 100644 --- a/plugins/aws/ec2/defaultVpcExists.js +++ b/plugins/aws/ec2/defaultVpcExists.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Default VPC Exists', category: 'EC2', + domain: 'Compute', + severity: 'Low', description: 'Determines whether the default VPC exists.', more_info: 'The default VPC should not be used in order to avoid launching multiple services in the same network which may not require connectivity. Each application, or network tier, should use its own VPC.', link: 'http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/default-vpc.html', recommended_action: 'Move resources from the default VPC to a new VPC created for that application or resource group.', apis: ['EC2:describeVpcs', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpc', 'ec2:ModifyVpcAttribute', 'ec2:DeleteVpc'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/ec2/defaultVpcInUse.js b/plugins/aws/ec2/defaultVpcInUse.js index 7ee3c6878..49a6f4910 100644 --- a/plugins/aws/ec2/defaultVpcInUse.js +++ b/plugins/aws/ec2/defaultVpcInUse.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Default VPC In Use', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determines whether the default VPC is being used for launching EC2 instances.', more_info: 'The default VPC should not be used in order to avoid launching multiple services in the same network which may not require connectivity. Each application, or network tier, should use its own VPC.', link: 'http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/default-vpc.html', recommended_action: 'Move resources from the default VPC to a new VPC created for that application or resource group.', apis: ['EC2:describeVpcs', 'EC2:describeInstances', 'ELB:describeLoadBalancers', 'Lambda:listFunctions', 'RDS:describeDBInstances', 'Redshift:describeClusters'], + realtime_triggers: ['ec2:CreateVpc', 'ec2:DeleteVpc', 'ec2:ModifyVpcAttribute', 'ec2:RunInstances', 'ec2:TerminateInstances','elb:CreateLoadBalancer','elb:ModifyLoadBalancerAttributes','elb:DeleteLoadBalancer', 'lambda:CreateFunction','lambda:UpdateFunctionConfiguration', 'lamda:DeleteFunction','rds:CreateDBInstance','rds:ModifyDBInstance','rds:DeleteDBInstance','redshift:CreateCluster','redshift:ModifyCluster', 'redshift:DeleteCluster'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/ec2/defaultVpcInUse.spec.js b/plugins/aws/ec2/defaultVpcInUse.spec.js new file mode 100644 index 000000000..782c47a87 --- /dev/null +++ b/plugins/aws/ec2/defaultVpcInUse.spec.js @@ -0,0 +1,850 @@ +var expect = require('chai').expect; +const defaultVpcInUse = require('./defaultVpcInUse'); + +const describeVpcs =[ + { + "CidrBlock": "10.0.0.0/16", + "DhcpOptionsId": "dopt-02f23068a9f47e67e", + "State": "available", + "VpcId": "vpc-0d04138d1a5d1ddba", + "OwnerId": "101363889637", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-03102f76bafa1b6c9", + "CidrBlock": "10.0.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": false, + "Tags": [ + { + "Key": "Name", + "Value": "dev-vpc" + } + ] + }, + { + "CidrBlock": "10.0.0.0/16", + "DhcpOptionsId": "dopt-02f23068a9f47e67e", + "State": "available", + "VpcId": "vpc-123", + "OwnerId": "101363889637", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-0195ffaae48916244", + "CidrBlock": "10.0.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": true, + "Tags": [] + } +]; + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-08-25T02:21:49.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-025b523c155020b10" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [ + { + "Attachment": { + "AttachTime": "2020-08-25T02:21:48.000Z", + "AttachmentId": "eni-attach-077c0f4c969c20b4c", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "Ipv6Addresses": [], + "MacAddress": "06:22:7f:a4:48:f3", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [ + { + "Key": "Name", + "Value": "sploit-959-test-instance" + } + ], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-123", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-08-25T02:21:49.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-025b523c155020b10" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [ + { + "Attachment": { + "AttachTime": "2020-08-25T02:21:48.000Z", + "AttachmentId": "eni-attach-077c0f4c969c20b4c", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "Ipv6Addresses": [], + "MacAddress": "06:22:7f:a4:48:f3", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-123", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + } +]; + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356027abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-82", + "DNSName": "test-82-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-123", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356027abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs4.3", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + "VpcConfig":{ + "VpcId": "vpc-123" + } + } +]; + +const describeDBInstances = [ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-112223344', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-112223344', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + TagList: [], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, + { + DBInstanceIdentifier: 'test2-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test2-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-123', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-123', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test2-1', + TagList: [{key: "Key", value: "value"}], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, +]; +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId":"vpc-123", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + +const createCache =(vpcs,instances,loadbalancers,listfunctions,dbinstance,cluster) => +{ + return { + ec2:{ + describeVpcs:{ + 'us-east-1':{ + data:vpcs + } + }, + describeInstances: { + 'us-east-1':{ + data:instances + } + } + }, + elb:{ + describeLoadBalancers: { + 'us-east-1':{ + data:loadbalancers + } + } + }, + lambda:{ + listFunctions: { + 'us-east-1':{ + data:listfunctions + } + } + }, + rds:{ + describeDBInstances: { + 'us-east-1':{ + data:dbinstance + } + } + }, + redshift:{ + describeClusters: { + 'us-east-1': { + data: cluster + } + } + } + } +} + + +const createNullCache =(vpcs,instances,loadbalancers,listfunctions,dbinstance,cluster) => +{ + return { + ec2:{ + describeVpcs:{ + 'us-east-1': null + }, + describeInstances: { + 'us-east-1': null + } + }, + elb:{ + describeLoadBalancers: { + 'us-east-1': null + } + }, + lambda:{ + listFunctions: { + 'us-east-1': null + } + }, + rds:{ + describeDBInstances: { + 'us-east-1':null + } + }, + redshift:{ + describeClusters: { + 'us-east-1': null + } + } + } +} + +const createErrorCache =(vpcs,instances,loadbalancers,listfunctions,dbinstance,cluster) => +{ + return { + ec2:{ + describeVpcs:{ + 'us-east-1':{ + err:{ + message: 'error describing VPC' + } + } + }, + describeInstances: { + 'us-east-1':{ + err:{ + message: 'error describing instance' + } + } + } + }, + elb:{ + describeLoadBalancers: { + 'us-east-1':{ + err:{ + message: 'error describing loadbalancer' + } + } + } + }, + lambda:{ + listFunctions: { + 'us-east-1':{ + err:{ + message: 'error listing functions' + } + } + } + }, + rds:{ + describeDBInstances: { + 'us-east-1':{ + err:{ + message: 'error describing dbinstance' + } + } + } + }, + redshift:{ + describeClusters: { + 'us-east-1': { + err:{ + message: 'error describing cluster' + } + } + } + } + } +} + + +describe('defaultVpcInUse', function () { + describe('run', function () { + it('should PASS if no vpc found', function (done) { + const cache = createCache([],[],[],[],[]); + defaultVpcInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPCs present'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if default vpc is not in use ', function (done) { + const cache = createCache(describeVpcs,[describeInstances[0]],[describeDBInstances[0]],[listFunctions[0]],[describeClusters[0]],[describeLoadBalancers[0]]); + defaultVpcInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default VPC is not in use'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if default vpc is in use ', function (done) { + const cache = createCache(describeVpcs,[describeInstances[1]],[describeDBInstances[1]],[listFunctions[1]],[describeClusters[1]],[describeLoadBalancers[1]]); + defaultVpcInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default VPC is in use'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if error occur while describe VPC or EC2 Instance or db Instance or list function or cluster or elb ', function (done) { + const cache= createErrorCache(); + defaultVpcInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return any results if unable to fetch VPC or EC2 Instance or db Instance or list function or cluster or elb', function (done) { + const cache = createNullCache(); + defaultVpcInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/ebsBackupEnabled.js b/plugins/aws/ec2/ebsBackupEnabled.js new file mode 100644 index 000000000..9db9a3277 --- /dev/null +++ b/plugins/aws/ec2/ebsBackupEnabled.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Backup Enabled', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Checks whether EBS Backup is enabled', + more_info: 'EBS volumes should have backups in the form of snapshots.', + recommended_action: 'Ensure that each EBS volumes contain at least .', + link: 'https://docs.aws.amazon.com/prescriptive-guidance/latest/backup-recovery/new-ebs-volume-backups.html', + apis: ['EC2:describeVolumes', 'EC2:describeSnapshots', 'EC2:describeInstances', 'STS:getCallerIdentity'], + settings: { + ignore_spot_instance_volumes: { + name: 'Ignore Spot Instance Volumes', + description: 'Ignore volumes which are attached to spot instances', + regex: '^(true|false)$', + default: 'true' + } + }, + realtime_triggers: ['ec2:CreateSnapshot', 'ec2:CreateVolume', 'ec2: DeleteVolume', 'ec2:DeleteSnapshot'], + + run: function(cache, settings, callback) { + let results = []; + let source = {}; + let regions = helpers.regions(settings); + + let config = { + ignore_spot_instance_volumes: settings.ignore_spot_instance_volumes || this.settings.ignore_spot_instance_volumes.default + }; + + let ignoreSpot = (config.ignore_spot_instance_volumes == 'true'); + + let acctRegion = helpers.defaultRegion(settings); + let awsOrGov = helpers.defaultPartition(settings); + let accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + let describeVolumes = helpers.addSource(cache, source, + ['ec2', 'describeVolumes', region]); + let describeSnapshots = helpers.addSource(cache, source, + ['ec2', 'describeSnapshots', region]); + + if (!describeVolumes) return rcb(); + + if (describeVolumes.err || !describeVolumes.data) { + helpers.addResult(results, 3, + 'Unable to query for EBS Volumes: ' + helpers.addError(describeVolumes), region); + return rcb(); + } + + if (!describeVolumes.data.length) { + helpers.addResult(results, 0, 'No EBS Volumes found', region); + return rcb(); + } + + if (!describeSnapshots || describeSnapshots.err || !describeSnapshots.data) { + helpers.addResult(results, 3, + `Unable to query for EBS Snapshots: ${helpers.addError(describeSnapshots)}`, region); + return rcb(); + } + + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances || describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for instances: ' + helpers.addError(describeInstances), region); + return rcb(); + } + + let volumeSet = new Set(); + let spotInstances = []; + + if (ignoreSpot) { + for (var instance of describeInstances.data) { + for (var entry of instance.Instances) { + if (entry.InstanceId && + entry.InstanceLifecycle && + entry.InstanceLifecycle.toLowerCase() == 'spot') spotInstances.push(entry.InstanceId); + } + } + } + + describeSnapshots.data.forEach(function(snapshot){ + if (snapshot.VolumeId) { + volumeSet.add(snapshot.VolumeId); + } + }); + + describeVolumes.data.forEach(function(volume) { + let volumeArn = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':volume/' + volume.VolumeId; + if (volume.VolumeId) { + if (ignoreSpot && volume.Attachments && volume.Attachments.length) { + let found = volume.Attachments.find(attachment => attachment.InstanceId && !spotInstances.includes(attachment.InstanceId)); + if (!found) return; + } + + if (volumeSet.has(volume.VolumeId)) { + helpers.addResult(results, 0, + 'EBS Volume is backed up', + region, volumeArn); + } else { + helpers.addResult(results, 2, + 'EBS Volume is not backed up', + region, volumeArn); + } + } + }); + rcb(); + + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsBackupEnabled.spec.js b/plugins/aws/ec2/ebsBackupEnabled.spec.js new file mode 100644 index 000000000..97a5d5a84 --- /dev/null +++ b/plugins/aws/ec2/ebsBackupEnabled.spec.js @@ -0,0 +1,226 @@ +let expect = require('chai').expect; +let ebsBackupEnabled = require('./ebsBackupEnabled'); + +const describeVolumes = [ + { + "Attachments": [], + "AvailabilityZone": "us-east-1d", + "CreateTime": "2020-09-01T03:40:13.595Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "available", + "VolumeId": "vol-0d7619e666a54b52a", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + }, + { + "Attachments": [ + { + "AttachTime": "2020-08-25T02:21:49.000Z", + "Device": "/dev/xvda", + "InstanceId": "i-03afb9daa31f31bb0", + "State": "attached", + "VolumeId": "vol-025b523c155020b10", + "DeleteOnTermination": true + } + ], + "AvailabilityZone": "us-east-1e", + "CreateTime": "2020-08-25T02:21:49.073Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "in-use", + "VolumeId": "vol-025b523c155020b10", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + }, + { + "Attachments": [ + { + "AttachTime": "2020-08-25T02:21:49.000Z", + "Device": "/dev/xvda", + "InstanceId": "i-0ceecc81a1c5829f6", + "State": "attached", + "VolumeId": "vol-025b523c155020b10", + "DeleteOnTermination": true + } + ], + "AvailabilityZone": "us-east-1e", + "CreateTime": "2020-08-25T02:21:49.073Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "in-use", + "VolumeId": "vol-025b523c155020b10", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + } +] + +const describeSnapshots = [ + { + "Description": "Created for testing", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-00317ba0e33942c5a", + "StartTime": "2020-8-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0d7619e666a54b52a", + "VolumeSize": 1, + "Tags": [] + }, + { + "Description": "Created for testing", + "Encrypted": false, + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-03fb4402f29407fa0", + "StartTime": "2020-10-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0065e2a7632d0d083", + "VolumeSize": 1, + "Tags": [] + } +]; + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0ceecc81a1c5829f6", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-09T21:27:25.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1b", + "GroupName": "", + "Tenancy": "default" + }, + "PublicIpAddress": "3.84.159.125", + "State": { + "Code": 0, + "Name": "running" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "InstanceLifecycle": "spot" + } + ] + }, +]; + +const createCache = (volumes, snapshots, instances) => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + data: snapshots + }, + }, + describeVolumes: { + 'us-east-1': { + data: volumes + }, + }, + describeInstances: { + 'us-east-1': { + data: instances + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing snapshots' + }, + }, + }, + describeVolumes: { + 'us-east-1': { + data: 'error describing volumes' + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': null + }, + describeVolumes: { + 'us-east-1': null + } + }, + }; +}; + +describe('ebsBackupEnabled', function () { + describe('run', function () { + it('should PASS if EBS snapshots found', function (done) { + const cache = createCache([describeVolumes[0]], [describeSnapshots[0]], describeInstances); + ebsBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + it('should not return anything if EBS volume is attached to a spot instance', function (done) { + const cache = createCache([describeVolumes[2]], [describeSnapshots[0]], describeInstances); + ebsBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + it('should PASS if snapshot for 1 volume is found and not found for another', function (done) { + const cache = createCache(describeVolumes, describeSnapshots, describeInstances); + ebsBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(2); + done(); + }); + }); + it('should UNKNOWN if error occurs while describe EBS snapshots or EBS volumes', function (done) { + const cache = createErrorCache(); + ebsBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + it('should not return any results if unable to fetch EBS snapshots or EBS volumes', function (done) { + const cache = createNullCache(); + ebsBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsDefaultEncryptionEnabled.js b/plugins/aws/ec2/ebsDefaultEncryptionEnabled.js new file mode 100644 index 000000000..e6e6ff78b --- /dev/null +++ b/plugins/aws/ec2/ebsDefaultEncryptionEnabled.js @@ -0,0 +1,109 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Encryption Enabled By Default', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensure the setting for encryption by default is enabled', + more_info: 'AWS account should be configured to enable encryption for new EBS volumes and snapshots for all regions.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default', + recommended_action: 'Enable EBS Encryption by Default', + apis: ['EC2:getEbsEncryptionByDefault', 'EC2:getEbsDefaultKmsKeyId', 'KMS:describeKey', 'KMS:listKeys', 'KMS:listAliases'], + settings: { + ebs_encryption_level: { + name: 'EBS Minimum Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + }, + }, + realtime_triggers: ['ec2:CreateVolume', 'ec2:EnableEbsEncryptionByDefault', 'ec2:DisableEbsEncryptionByDefault', 'ec2:ModifyEbsDefaultKmsKeyId', 'ec2:DeleteVolume'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var ebs_encryption_level = settings.ebs_encryption_level || this.settings.ebs_encryption_level.default; + var targetEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(ebs_encryption_level); + + async.each(regions.ec2, function(region, rcb){ + var getEbsEncryptionByDefault = helpers.addSource(cache, source, + ['ec2', 'getEbsEncryptionByDefault', region]); + + if (!getEbsEncryptionByDefault) return rcb(); + + if (getEbsEncryptionByDefault.err) { + helpers.addResult(results, 3, + 'Unable to query for ebs encryption by default: ' + helpers.addError(getEbsEncryptionByDefault), region); + return rcb(); + } + + if (!getEbsEncryptionByDefault.data) { + helpers.addResult(results, 2, + 'EBS default encryption is disabled', region); + return rcb(); + } + + var getEbsDefaultKmsKeyId = helpers.addSource(cache, source, + ['ec2', 'getEbsDefaultKmsKeyId', region]); + + if (!getEbsDefaultKmsKeyId || getEbsDefaultKmsKeyId.err || !getEbsDefaultKmsKeyId.data) { + helpers.addResult(results, 3, + 'Unable to query for ebs default kms key id: ' + helpers.addError(getEbsDefaultKmsKeyId), region); + return rcb(); + } + + var kmsKeyId = ''; + var isPredefinedAlias = false; + if (getEbsDefaultKmsKeyId.data.split('/')[0] === 'alias') { + var listAliases = helpers.addSource(cache, source, ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, 'Unable to query for list aliases: ' + helpers.addError(listAliases), region); + return rcb(); + } + + listAliases.data.forEach(function(alias){ + if (alias.AliasName === getEbsDefaultKmsKeyId.data) { + if (alias.TargetKeyId) { + kmsKeyId = alias.TargetKeyId; + } else { + isPredefinedAlias = true; + } + } + }); + } else { + kmsKeyId = getEbsDefaultKmsKeyId.data.split('/')[1]; + } + + var encryptionLevel; + if (!isPredefinedAlias) { + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + 'Unable to query kms key: ' + helpers.addError(describeKey), region); + return rcb(); + } + + encryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + encryptionLevel = 2; //awskms + } + + if (encryptionLevel < targetEncryptionLevel) { + helpers.addResult(results, 2, + `EBS default encryption is enabled but current encryption level ${helpers.ENCRYPTION_LEVELS[encryptionLevel]} is less than the target level ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, region); + } else { + helpers.addResult(results, 0, + `EBS default encryption is enabled and current encryption level ${helpers.ENCRYPTION_LEVELS[encryptionLevel]} is greater than or equal to the target level ${helpers.ENCRYPTION_LEVELS[targetEncryptionLevel]}`, region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsDefaultEncryptionEnabled.spec.js b/plugins/aws/ec2/ebsDefaultEncryptionEnabled.spec.js new file mode 100644 index 000000000..62d3741aa --- /dev/null +++ b/plugins/aws/ec2/ebsDefaultEncryptionEnabled.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var ebsDefaultEncryptionEnabled = require('./ebsDefaultEncryptionEnabled'); + +const createCache = (boolValue) => { + return { + ec2: { + getEbsEncryptionByDefault: { + 'us-east-1': { + data: boolValue, + } + }, + getEbsDefaultKmsKeyId: { + 'us-east-1': { + data: 'key/0987dcba-09fe-87dc-65ba-ab0987654321', + } + }, + }, + kms: { + describeKey: { + 'us-east-1': { + '0987dcba-09fe-87dc-65ba-ab0987654321': { + data: { + KeyMetadata: { + Origin: 'AWS_KMS', + KeyManager: 'CUSTOMER', + }, + } + }, + } + }, + listAliases: { + 'us-east-1': { + data: [], + } + }, + listKeys: { + 'us-east-1': { + data: [ + { + 'KeyId': '0987dcba-09fe-87dc-65ba-ab0987654321', + 'KeyArn': 'arn:aws:kms:us-east-1:0123456789101:key/0987dcba-09fe-87dc-65ba-ab0987654321' + } + ] + } + }, + }, + }; +}; + + +describe('ebsDefaultEncryptionEnabled', function () { + describe('run', function () { + it('should FAIL if ebs encryption by default is disabled', function (done) { + const cache = createCache(false); + const settings = {}; + + ebsDefaultEncryptionEnabled.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if ebs encryption by default is enabled, with detail on "encryption level"', function (done) { + const cache = createCache(true); + const settings = {}; + + ebsDefaultEncryptionEnabled.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ebs encryption level is "lower" than target encryption level', function (done) { + const cache = createCache(true); + const settings = { + ebs_encryption_level: 'cloudhsm', + }; + + ebsDefaultEncryptionEnabled.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/ebsEncryptedSnapshots.js b/plugins/aws/ec2/ebsEncryptedSnapshots.js index 21acfc4b5..18820c5e7 100644 --- a/plugins/aws/ec2/ebsEncryptedSnapshots.js +++ b/plugins/aws/ec2/ebsEncryptedSnapshots.js @@ -4,22 +4,26 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EBS Encrypted Snapshots', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures EBS snapshots are encrypted at rest', more_info: 'EBS snapshots should have at-rest encryption enabled through AWS using KMS. If the volume was not encrypted and a snapshot was taken the snapshot will be unencrypted.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html#encryption-support', recommended_action: 'Configure volume encryption and delete unencrypted EBS snapshots.', - apis: ['EC2:describeSnapshots', 'STS:getCallerIdentity'], + apis: ['EC2:describeSnapshots'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + 'EBS is a HIPAA-compliant solution that provides automated encryption ' + 'of EC2 instance data at rest, but volumes must be configured to use ' + 'encryption so their snapshots are also encrypted.' }, + realtime_triggers: ['ec2:CreateSnapshot', 'ec2:CopySnapshot', 'ec2:DeleteSnapshot'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeSnapshots = helpers.addSource(cache, source, @@ -38,26 +42,15 @@ module.exports = { return rcb(); } - var unencryptedSnapshots = []; - describeSnapshots.data.forEach(function(snapshot){ - if (!snapshot.Encrypted){ - // arn:aws:ec2:region:account-id:snapshot/snapshot-id - var arn = 'arn:aws:ec2:' + region + ':' + snapshot.OwnerId + ':snapshot/' + snapshot.SnapshotId; - unencryptedSnapshots.push(arn); + var arn = `arn:${awsOrGov}:ec2:` + region + ':' + snapshot.OwnerId + ':snapshot/' + snapshot.SnapshotId; + if (snapshot.Encrypted){ + helpers.addResult(results, 0, 'EBS snapshot is encrypted', region, arn); + } else { + helpers.addResult(results, 2, 'EBS snapshot is unencrypted', region, arn); } }); - if (unencryptedSnapshots.length > 20) { - helpers.addResult(results, 2, 'More than 20 EBS snapshots are unencrypted', region); - } else if (unencryptedSnapshots.length) { - for (var u in unencryptedSnapshots) { - helpers.addResult(results, 2, 'EBS snapshot is unencrypted', region, unencryptedSnapshots[u]); - } - } else { - helpers.addResult(results, 0, 'No unencrypted snapshots found', region); - } - rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/ec2/ebsEncryptedSnapshots.spec.js b/plugins/aws/ec2/ebsEncryptedSnapshots.spec.js new file mode 100644 index 000000000..826ed4625 --- /dev/null +++ b/plugins/aws/ec2/ebsEncryptedSnapshots.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +const ebsEncryptedSnapshots = require('./ebsEncryptedSnapshots'); + +const describeSnapshots = [ + { + "Description": '', + "Encrypted": true, + "KmsKeyId": 'arn:aws:kms:us-east-1:111122223333:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0', + "OwnerId": '111122223333', + "Progress": '100%', + "SnapshotId": 'snap-0a97ac5b19a598f50', + "StartTime": "2020-11-09T22:38:48.321Z", + "State": 'completed', + "VolumeId": 'vol-0ea73f15efe1c3f67', + "VolumeSize": 8, + "Tags": [] + }, + { + "Encrypted": false, + "OwnerId": '111122223333', + "SnapshotId": 'snap-0a97ac5b19a598f50' + }, +]; + + +const createCache = (snapshots) => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + data: snapshots + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing snapshots' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ebsEncryptedSnapshots', function () { + describe('run', function () { + it('should PASS if EBS snapshot is encrypted', function (done) { + const cache = createCache([describeSnapshots[0]]); + ebsEncryptedSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EBS snapshot is unencrypted', function (done) { + const cache = createCache([describeSnapshots[1]]); + ebsEncryptedSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no EBS snapshots present', function (done) { + const cache = createCache([]); + ebsEncryptedSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe snapshots', function (done) { + const cache = createErrorCache(); + ebsEncryptedSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe snapshots response not found', function (done) { + const cache = createNullCache(); + ebsEncryptedSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/ec2/ebsEncryptionEnabled.js b/plugins/aws/ec2/ebsEncryptionEnabled.js index 1bd002b83..877c86d80 100644 --- a/plugins/aws/ec2/ebsEncryptionEnabled.js +++ b/plugins/aws/ec2/ebsEncryptionEnabled.js @@ -35,11 +35,13 @@ function getEncryptionLevel(kmsKey) { module.exports = { title: 'EBS Encryption Enabled', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures EBS volumes are encrypted at rest', more_info: 'EBS volumes should have at-rest encryption enabled through AWS using KMS. If the volume is used for a root volume, the instance must be launched from an AMI that has been encrypted as well.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html', recommended_action: 'Enable encryption for EBS volumes.', - apis: ['EC2:describeVolumes', 'KMS:describeKey', 'KMS:listKeys'], + apis: ['EC2:describeVolumes', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + 'EBS is a HIPAA-compliant solution that provides automated encryption ' + @@ -50,25 +52,24 @@ module.exports = { }, settings: { ebs_encryption_level: { - name: 'EBS Minimum Encryption Level', + name: 'EBS Minimum Encryption Level at rest', description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', default: 'awskms', }, - ebs_result_limit: { - name: 'EBS Result Limit', - description: 'If the number of results is greater than this value, combine them into one result', - regex: '^[0-9]*$', - default: '20', - }, + }, + realtime_triggers: ['ec2:CreateVolume', 'ec2;DeleteVolume'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); var targetEncryptionLevel = encryptionLevelMap[settings.ebs_encryption_level || this.settings.ebs_encryption_level.default]; - var ebsResultLimit = parseInt(settings.ebs_result_limit || this.settings.ebs_result_limit.default); async.each(regions.ec2, function(region, rcb) { var describeVolumes = helpers.addSource(cache, source, ['ec2', 'describeVolumes', region]); @@ -83,52 +84,34 @@ module.exports = { return rcb(); } - var unencryptedVolumes = []; - var poorlyEncryptedVolumes = []; - var kmsErrors = []; - for (let volume of describeVolumes.data) { + var resource = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':volume/' + volume.VolumeId; if (!volume.Encrypted || !volume.KmsKeyId){ - unencryptedVolumes.push(volume.VolumeId); + helpers.addResult(results, 2, 'EBS volume is unencrypted', region, resource); continue; } + var kmsKeyId = volume.KmsKeyId.split('/')[1]; var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, kmsKeyId]); - if (!describeKey || describeKey.err || !describeKey.data) { - kmsErrors.push({ kmsKeyId, err: helpers.addError(describeKey) }); + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, 'Could not describe KMS key', region, volume.KmsKeyId); continue; } + var encryptionLevel = getEncryptionLevel(describeKey.data.KeyMetadata); - if (encryptionLevel < targetEncryptionLevel) { - poorlyEncryptedVolumes.push(volume.VolumeId); - } - } - if (unencryptedVolumes.length) { - if (unencryptedVolumes.length > ebsResultLimit) { - helpers.addResult(results, 2, `More than ${ebsResultLimit} EBS volumes are unencrypted`, region); - } else { - unencryptedVolumes.forEach(volume => helpers.addResult(results, 2, 'EBS volume is unencrypted', region, volume)); - } - } - if (poorlyEncryptedVolumes.length) { - if (poorlyEncryptedVolumes.length > ebsResultLimit) { - helpers.addResult(results, 1, `More than ${ebsResultLimit} EBS volumes are not encrypted to ${encryptionLevelMap[targetEncryptionLevel]}`, region); - } else { - poorlyEncryptedVolumes.forEach(volume => helpers.addResult(results, 1, `EBS volume is not encrypted to ${encryptionLevelMap[targetEncryptionLevel]}`, region, volume)); - } - } - if (kmsErrors.length) { - if (kmsErrors.length > ebsResultLimit) { - helpers.addResult(results, 3, `More than ${ebsResultLimit} errors describing kms keys happened`, region); + if (encryptionLevel < targetEncryptionLevel) { + helpers.addResult(results, 1, + `EBS volume is not encrypted to ${encryptionLevelMap[targetEncryptionLevel]}`, + region, resource); } else { - kmsErrors.forEach(({err, kmsKeyId}) => helpers.addResult(results, 3, `Could not describe KMS key: ${err}`, region, kmsKeyId)); + helpers.addResult(results, 0, + `EBS volume is encrypted to ${encryptionLevelMap[targetEncryptionLevel]}`, + region, resource); } } - if (!unencryptedVolumes.length && !poorlyEncryptedVolumes.length && !kmsErrors.length) { - helpers.addResult(results, 0, `All volumes encrypted to at least ${encryptionLevelMap[targetEncryptionLevel]}`, region); - } + rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/ec2/ebsEncryptionEnabled.spec.js b/plugins/aws/ec2/ebsEncryptionEnabled.spec.js index cdb04b2e1..e82c131a4 100644 --- a/plugins/aws/ec2/ebsEncryptionEnabled.spec.js +++ b/plugins/aws/ec2/ebsEncryptionEnabled.spec.js @@ -1,18 +1,71 @@ var expect = require('chai').expect; -var ebsEncryptionEnabled = require('./ebsEncryptionEnabled') +const ebsEncryptionEnabled = require('./ebsEncryptionEnabled'); + +const describeVolumes = [ + { + "Encrypted": false, + "VolumeId": "vol-0ebea24b6b5ab89d5", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + }, + { + + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/mykmskey", + "VolumeId": "vol-0c3475c8999065481", + "Iops": 300, + "VolumeType": "gp2", + "MultiAttachEnabled": false + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "Origin": "AWS_KMS", + "KeyManager": "AWS", + } + }, + { + "KeyMetadata": { + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + } + } +]; const createCache = (volumes, keys) => { return { ec2: { describeVolumes: { 'us-east-1': { - data: volumes, + data: volumes }, - }, + } }, kms: { describeKey: { - 'us-east-1': keys, + 'us-east-1': { + 'mykmskey': { + data: keys + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVolumes: { + 'us-east-1': null, + } + }, + kms: { + describeKey: { + 'us-east-1': null, }, }, }; @@ -20,107 +73,49 @@ const createCache = (volumes, keys) => { describe('ebsEncryptionEnabled', function () { describe('run', function () { - it('should FAIL if volume is not encrypted', function (done) { - const cache = createCache([{ - VolumeId: 'abc123', - Encrypted: false, - }], { - 'mykmskey': { - data: { - KeyMetadata: { - Origin: 'AWS_KMS', - KeyManager: 'AWS', - }, - }, - }, - }); - const settings = { - ebs_encryption_level: 'awscmk', - }; - - ebsEncryptionEnabled.run(cache, settings, (err, results) => { + it('should PASS if EBS volume is encrypted', function (done) { + const cache = createCache([describeVolumes[1]], describeKey[1]); + ebsEncryptionEnabled.run(cache, { ebs_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); - done() + expect(results[0].status).to.equal(0); + done(); }); }); - it('should WARN if encryption level is less than configured setting', function (done) { - const cache = createCache([{ - VolumeId: 'abc123', - Encrypted: true, - KmsKeyId: 'arn:aws:kms:us-east-1:123412341234:key/mykmskey', - }], { - 'mykmskey': { - data: { - KeyMetadata: { - Origin: 'AWS_KMS', - KeyManager: 'AWS', - }, - }, - }, - }); - const settings = { - ebs_encryption_level: 'awscmk', - }; - - ebsEncryptionEnabled.run(cache, settings, (err, results) => { + it('should FAIL if EBS volume is unencrypted', function (done) { + const cache = createCache([describeVolumes[0]], describeKey[0]); + ebsEncryptionEnabled.run(cache, { ebs_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(1, 'bad status'); - done() + expect(results[0].status).to.equal(2); + done(); }); }); - it('should PASS if encryption level meets configured target setting', function (done) { - const cache = createCache([{ - VolumeId: 'abc123', - Encrypted: true, - KmsKeyId: 'arn:aws:kms:us-east-1:123412341234:key/mykmskey', - }], { - 'mykmskey': { - data: { - KeyMetadata: { - Origin: 'AWS_KMS', - KeyManager: 'AWS', - }, - }, - }, - }); - const settings = { - ebs_encryption_level: 'awskms', - }; - - ebsEncryptionEnabled.run(cache, settings, (err, results) => { + it('should PASS if No EBS volumes present', function (done) { + const cache = createCache([],[]); + ebsEncryptionEnabled.run(cache, { ebs_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); done(); }); }); - - it('should UNKNWON if encryption level meets configured target setting', function (done) { - const cache = createCache([{ - VolumeId: 'abc123', - Encrypted: true, - KmsKeyId: 'arn:aws:kms:us-east-1:123412341234:key/mykmskey', - }], { - 'mykmskey': { - data: { - KeyMetadata: { - Origin: 'AWS_KMS', - KeyManager: 'AWS', - }, - }, - }, + + it('should not return any results if unable to fetch EBS volumes', function (done) { + const cache = createNullCache(); + ebsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); }); - const settings = { - ebs_encryption_level: 'awskms', - }; + }); - ebsEncryptionEnabled.run(cache, settings, (err, results) => { + it('should WARN if EBS volume is not encrypted to target encryption level', function (done) { + const cache = createCache([describeVolumes[1]], describeKey[0]); + ebsEncryptionEnabled.run(cache, { ebs_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(1); done(); }); }); + }); -}); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsOldSnapshots.js b/plugins/aws/ec2/ebsOldSnapshots.js new file mode 100644 index 000000000..1412accea --- /dev/null +++ b/plugins/aws/ec2/ebsOldSnapshots.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Volumes Too Old Snapshots', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that EBS volume snapshots are deleted after defined time period.', + more_info: 'EBS volume snapshots older than indicated should be deleted after defined time period for cost optimization.', + link: 'https://docs.amazonaws.cn/en_us/AWSEC2/latest/UserGuide/ebs-deleting-snapshot.html', + recommended_action: 'Delete the EBS snapshots past their defined expiration date', + apis: ['EC2:describeSnapshots'], + settings: { + ebs_snapshot_life: { + name: 'EBS Snapshot Life', + description: 'EBS volume snapshot will FAIL if its creation date is before this number of days in the past', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + }, + ebs_result_limit: { + name: 'EBS Result Limit', + description: 'Plugin results will become aggregated when this value is breached', + regex: '^[0-9]*$', + default: '20', + }, + }, + realtime_triggers: ['ec2:CreateSnapshot', 'ec2:DeleteSnapshot'], + + run: function(cache, settings, callback) { + var config = { + ebs_snapshot_life: parseInt(settings.ebs_snapshot_life || this.settings.ebs_snapshot_life.default), + ebs_result_limit: parseInt(settings.ebs_result_limit || this.settings.ebs_result_limit.default) + }; + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSnapshots = helpers.addSource(cache, source, + ['ec2', 'describeSnapshots', region]); + + if (!describeSnapshots) return rcb(); + + if (describeSnapshots.err || !describeSnapshots.data) { + helpers.addResult(results, 3, + `Unable to query for EBS Snapshots: ${helpers.addError(describeSnapshots)}`, region); + return rcb(); + } + + if (!describeSnapshots.data.length) { + helpers.addResult(results, 0, 'No EBS snapshots present', region); + return rcb(); + } + + var now = new Date(); + describeSnapshots.data.forEach(snapshot => { + if (!snapshot.SnapshotId) return; + + var resource = `arn:${awsOrGov}:${region}:${snapshot.OwnerId}:snapshot/${snapshot.SnapshotId}`; + var then = new Date(snapshot.StartTime); + var difference = helpers.daysBetween(then, now); + + if (Math.abs(difference) > config.ebs_snapshot_life) { + helpers.addResult(results, 2, + `EBS snapshots is ${config.ebs_snapshot_life} days old`, region, resource); + } else { + helpers.addResult(results, 0, + 'No old EBS snapshots found', region, resource); + } + }); + + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsOldSnapshots.spec.js b/plugins/aws/ec2/ebsOldSnapshots.spec.js new file mode 100644 index 000000000..d2816722c --- /dev/null +++ b/plugins/aws/ec2/ebsOldSnapshots.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +var ebsOldSnapshots = require('./ebsOldSnapshots'); + +const describeSnapshots = [ + { + "Description": "Created for testing", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-00317ba0e33942c5a", + "StartTime": "2020-8-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0065e2a7632d0d083", + "VolumeSize": 1, + "Tags": [] + }, + { + "Description": "Created for testing", + "Encrypted": false, + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-03fb4402f29407fa0", + "StartTime": "2020-10-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0065e2a7632d0d083", + "VolumeSize": 1, + "Tags": [] + } +]; + +const createCache = (describeSnapshots) => { + return { + ec2: { + describeSnapshots: { + 'us-east-1': { + data: describeSnapshots + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing EC2 snapshots' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeSnapshots: { + 'us-east-1': null + } + } + }; +}; + +describe('ebsOldSnapshots', function () { + describe('run', function () { + it('should PASS if no old EBS snapshots found', function (done) { + const cache = createCache([describeSnapshots[0]]); + ebsOldSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EBS snapshot is old', function (done) { + const cache = createCache([describeSnapshots[1]]); + ebsOldSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No EBS snapshots present', function (done) { + const cache = createCache([]); + ebsOldSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if describe EC2 snapshots response not found', function (done) { + const cache = createNullCache(); + ebsOldSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable t0 describe EC2 snapshots', function (done) { + const cache = createErrorCache(); + ebsOldSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsRecentSnapshots.js b/plugins/aws/ec2/ebsRecentSnapshots.js new file mode 100644 index 000000000..6f875b5d3 --- /dev/null +++ b/plugins/aws/ec2/ebsRecentSnapshots.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Volumes Recent Snapshots', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that EBS volume has had a recent snapshot within the configured time period', + more_info: 'EBS volumes without recent snapshots may be at risk of data loss or recovery issues.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html', + recommended_action: 'Create a new snapshot for EBS volume within the configured time period.', + apis: ['EC2:describeSnapshots','STS:getCallerIdentity'], + settings: { + ebs_recent_snapshot_days: { + name: 'EBS Recent Snapshot Days', + description: 'Number of days to consider a snapshot as recent. Snapshots older than this will be flagged as FAIL.', + regex: '^[1-9]{1}[0-9]{0,2}$', + default: '7' + } + }, + realtime_triggers: ['ec2:CreateSnapshot', 'ec2:DeleteSnapshot'], + + run: function(cache, settings, callback) { + var config = { + ebs_recent_snapshot_days: parseInt(settings.ebs_recent_snapshot_days || this.settings.ebs_recent_snapshot_days.default) + }; + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSnapshots = helpers.addSource(cache, source, + ['ec2', 'describeSnapshots', region]); + + if (!describeSnapshots) return rcb(); + + if (describeSnapshots.err || !describeSnapshots.data) { + helpers.addResult(results, 3, + `Unable to query for EBS Snapshots: ${helpers.addError(describeSnapshots)}`, region); + return rcb(); + } + + if (!describeSnapshots.data.length) { + helpers.addResult(results, 0, 'No EBS snapshots present', region); + return rcb(); + } + + var today = new Date(); + describeSnapshots.data.forEach(snapshot => { + if (!snapshot.SnapshotId) return; + + var resource = `arn:${awsOrGov}:${region}:${snapshot.OwnerId}:snapshot/${snapshot.SnapshotId}`; + var snapshotTime = new Date(snapshot.StartTime); + var difference = Math.floor((today -snapshotTime) / (1000 * 60 * 60 * 24)); + + if (difference > config.ebs_recent_snapshot_days){ + helpers.addResult(results, 2, + 'EBS volume does not have a recent snapshot', region,resource); + } else { + helpers.addResult(results, 0, + 'EBS volume has a recent snapshot', region,resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/ebsRecentSnapshots.spec.js b/plugins/aws/ec2/ebsRecentSnapshots.spec.js new file mode 100644 index 000000000..0f4bd7dcb --- /dev/null +++ b/plugins/aws/ec2/ebsRecentSnapshots.spec.js @@ -0,0 +1,189 @@ +let expect = require('chai').expect; +let ebsRecentSnapshots = require('./ebsRecentSnapshots'); + +var snapshotPass = new Date(); +snapshotPass.setDate(snapshotPass.getDate() - 1); + +var snapshotFail = new Date(); +snapshotFail.setDate(snapshotFail.getDate() - 10); + +var snapshotCustom = new Date(); +snapshotCustom.setDate(snapshotCustom.getDate() - 15); + +const describeSnapshots = [ + { + "Description": "", + "Encrypted": false, + "OwnerId": "193063503752", + "Progress": "100%", + "SnapshotId": "snap-06c4f7f6004cecfe5", + "StartTime": snapshotPass, + "State": "completed", + "VolumeId": "vol-02c402f5a6a02c6e7", + "VolumeSize": 8, + "Tags": [], + + }, + + { + "Description": "Created for testing", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-023f96b23f5b82f59", + "StartTime": snapshotFail, + "State": "completed", + "VolumeId": "vol-0025f6823d19c56d9", + "VolumeSize": 1, + "Tags": [] + }, + { + + "Description": "Created for testing", + "Encrypted": false, + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-03fb4402f29407fa0", + "StartTime": "2020-10-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-02c402f5a6a02c6e7", + "VolumeSize": 1, + "Tags": [] + }, + { + "Description": "Custom test snapshot", + "Encrypted": false, + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-04custom567890abc", + "StartTime": snapshotCustom, + "State": "completed", + "VolumeId": "vol-03custom567890def", + "VolumeSize": 10, + "Tags": [] + } +]; + +const createCache = (snapshots) => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + data: snapshots + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing snapshots' + } + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': null + } + }, + }; +}; + +describe('ebsRecentSnapshots', function () { + describe('run', function () { + it('should PASS if EBS volume has snapshot within 7 days', function (done) { + const cache = createCache([describeSnapshots[0]]); + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume has a recent snapshot'); + done(); + }); + }); + it('should FAIL if EBS volume have not snapshot within 7 days', function (done) { + const cache = createCache([describeSnapshots[1]]); + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have a recent snapshot'); + done(); + }); + }); + it('should UNKNOWN if error occurs while describe EBS snapshots or EBS volumes', function (done) { + const cache = createErrorCache(); + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EBS Snapshots: '); + done(); + }); + }); + it('should PASS if No EBS snapshots present', function (done) { + const cache = createCache([]); + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EBS snapshots present'); + done(); + }); + }); + it('should not return any results if unable to fetch EBS snapshots or EBS volumes', function (done) { + const cache = createNullCache(); + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should use custom snapshot age threshold when setting is provided', function (done) { + const cache = createCache([describeSnapshots[3]]); // 15-day old snapshot + const settings = { ebs_recent_snapshot_days: '20' }; + ebsRecentSnapshots.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume has a recent snapshot'); + done(); + }); + }); + + it('should FAIL when snapshot is older than custom threshold', function (done) { + const cache = createCache([describeSnapshots[3]]); // 15-day old snapshot + const settings = { ebs_recent_snapshot_days: '10' }; + ebsRecentSnapshots.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have a recent snapshot'); + done(); + }); + }); + + it('should use default 7 days when no setting is provided', function (done) { + const cache = createCache([describeSnapshots[1]]); // 10-day old snapshot + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have a recent snapshot'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsSnapshotHasTags.js b/plugins/aws/ec2/ebsSnapshotHasTags.js new file mode 100644 index 000000000..7888c4c82 --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Snapshot Has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that EBS snapshots have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://aws.amazon.com/blogs/compute/tag-amazon-ebs-snapshots-on-creation-and-implement-stronger-security-policies/', + recommended_action: 'Modify EBS snapshots and add tags.', + apis: ['EC2:describeSnapshots'], + realtime_triggers: ['ec2:CreateSnapshot', 'ec2:AddTags', 'ec2:DeleteTags','ec2:DeleteSnapshot'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSnapshots = helpers.addSource(cache, source, + ['ec2', 'describeSnapshots', region]); + + if (!describeSnapshots) return rcb(); + + if (describeSnapshots.err || !describeSnapshots.data) { + helpers.addResult(results, 3, + `Unable to query for EBS Snapshots: ${helpers.addError(describeSnapshots)}`, region); + return rcb(); + } + + if (!describeSnapshots.data.length) { + helpers.addResult(results, 0, 'No EBS snapshots found', region); + return rcb(); + } + for (let snapshot of describeSnapshots.data){ + if (!snapshot.OwnerId || !snapshot.SnapshotId) continue; + + var resourceARN = `arn:${awsOrGov}:${region}:${snapshot.OwnerId}:snapshot/${snapshot.SnapshotId}`; + + if (!snapshot.Tags || !snapshot.Tags.length) { + helpers.addResult(results, 2, 'EBS Snapshot does not have tags', region, resourceARN); + } else { + helpers.addResult(results, 0, 'EBS Snapshot has tags', region, resourceARN); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsSnapshotHasTags.spec.js b/plugins/aws/ec2/ebsSnapshotHasTags.spec.js new file mode 100644 index 000000000..307765926 --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotHasTags.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const ebsSnapShotHasTags = require('./ebsSnapshotHasTags'); + +const describeSnapshots = [ + { + "Description": '', + "Encrypted": true, + "KmsKeyId": 'arn:aws:kms:us-east-1:111122223333:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0', + "OwnerId": '111122223333', + "Progress": '100%', + "SnapshotId": 'snap-0a97ac5b19a598f50', + "StartTime": "2020-11-09T22:38:48.321Z", + "State": 'completed', + "VolumeId": 'vol-0ea73f15efe1c3f67', + "VolumeSize": 8, + "Tags": [] + }, + { + "Description": '', + "Encrypted": true, + "KmsKeyId": 'arn:aws:kms:us-east-1:111122223333:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0', + "OwnerId": '111122223333', + "Progress": '100%', + "SnapshotId": 'snap-0a97ac5b19a598f50', + "StartTime": "2020-11-09T22:38:48.321Z", + "State": 'completed', + "VolumeId": 'vol-0ea73f15efe1c3f67', + "VolumeSize": 8, + "Tags": [{'key': 'value'}] + }, +]; + + +const createCache = (snapshots) => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + data: snapshots + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing snapshots' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSnapshots: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ebsSnapShotHasTags', function () { + describe('run', function () { + it('should PASS if EBS snapshot has Tags', function (done) { + const cache = createCache([describeSnapshots[1]]); + ebsSnapShotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS Snapshot has tags'); + done(); + }); + }); + + it('should FAIL if EBS snapshot does not have tags', function (done) { + const cache = createCache([describeSnapshots[0]]); + ebsSnapShotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS Snapshot does not have tags'); + done(); + }); + }); + + it('should PASS if no EBS snapshots present', function (done) { + const cache = createCache([]); + ebsSnapShotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EBS snapshots found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe snapshots', function (done) { + const cache = createErrorCache(); + ebsSnapShotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EBS Snapshots'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/ebsSnapshotLifecycle.js b/plugins/aws/ec2/ebsSnapshotLifecycle.js new file mode 100644 index 000000000..bdcc1f76b --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotLifecycle.js @@ -0,0 +1,163 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Automate EBS Snapshot Lifecycle', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure DLM is used to automate EBS volume snapshots management.', + more_info: 'Amazon Data Lifecycle Manager (DLM) service enables you to manage the lifecycle of EBS volume snapshots.\ + Using DLM helps in enforcing regular backup schedule, retaining backups, deleting outdated EBS snapshots', + recommended_action: 'Create lifecycle policy for EBS volumes.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/snapshot-lifecycle.html', + apis: ['EC2:describeInstances', 'EC2:describeVolumes', 'DLM:getLifecyclePolicies', + 'DLM:getLifecyclePolicy', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVolume','dlm:CreateLifecyclePolicy', 'dlm:DeleteLifecyclePolicy', 'dlm:UpdateLifecyclePolicy','ec2:DeleteVolume'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + var describeVolumes = helpers.addSource(cache, source, + ['ec2', 'describeVolumes', region]); + + if (!describeVolumes) return rcb(); + + if (describeVolumes.err || !describeVolumes.data) { + helpers.addResult(results, 3, + `Unable to describe EBS volumes: ${helpers.addError(describeVolumes)}`, region); + return rcb(); + } + + if (!describeVolumes.data.length) { + helpers.addResult(results, 0, + 'No EBS volumes found', region); + return rcb(); + } + + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances || describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + `Unable to EC2 instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + var getLifecyclePolicies = helpers.addSource(cache, source, + ['dlm', 'getLifecyclePolicies', region]); + + if (!getLifecyclePolicies || getLifecyclePolicies.err | !getLifecyclePolicies.data) { + helpers.addResult(results, 3, + `Unable to query DLM lifecycle policies: ${helpers.addError(getLifecyclePolicies)}`, region); + return rcb(); + } + + var passingVolumes = []; + var passingVolumeTags = {}; + var passingInstanceTags = {}; + getLifecyclePolicies.data.forEach(policy => { + var getLifecyclePolicy = helpers.addSource(cache, source, + ['dlm', 'getLifecyclePolicy', region, policy.PolicyId]); + + if (!getLifecyclePolicy || getLifecyclePolicy.err || !getLifecyclePolicy.data || !getLifecyclePolicy.data.Policy) { + helpers.addResult(results, 3, + `Unable to query lifecycle policy: ${helpers.addError(getLifecyclePolicy)}`, + region, policy.PolicyId); + return; + } + + if (getLifecyclePolicy.data.Policy.State && + getLifecyclePolicy.data.Policy.State === 'ENABLED' && + getLifecyclePolicy.data.Policy.PolicyDetails && + getLifecyclePolicy.data.Policy.PolicyDetails.TargetTags && + getLifecyclePolicy.data.Policy.PolicyDetails.TargetTags.length) { + if (getLifecyclePolicy.data.Policy.PolicyDetails.ResourceTypes && + getLifecyclePolicy.data.Policy.PolicyDetails.ResourceTypes.length) { + if (getLifecyclePolicy.data.Policy.PolicyDetails.ResourceTypes.includes('VOLUME')) { + getLifecyclePolicy.data.Policy.PolicyDetails.TargetTags.forEach(tag => { + if (tag.Key && tag.Value) { + if (passingVolumeTags[tag.Key]) passingVolumeTags[tag.Key].push(tag.Value); + else passingVolumeTags[tag.Key] = [tag.Value]; + } + }); + } + + if (getLifecyclePolicy.data.Policy.PolicyDetails.ResourceTypes.includes('INSTANCE')) { + getLifecyclePolicy.data.Policy.PolicyDetails.TargetTags.forEach(tag => { + if (tag.Key && tag.Value) { + if (passingInstanceTags[tag.Key]) passingInstanceTags[tag.Key].push(tag.Value); + else passingInstanceTags[tag.Key] = [tag.Value]; + } + }); + } + } + } + }); + + if (describeInstances.data.length) { + describeInstances.data.forEach(instance => { + if (instance.Instances && instance.Instances.length) { + instance.Instances.forEach(entry => { + if (entry.Tags && entry.Tags.length) { + for (var it in entry.Tags) { + var itag = entry.Tags[it]; + + if (passingInstanceTags[itag.Key] && passingInstanceTags[itag.Key].includes(itag.Value)) { + if (entry.BlockDeviceMappings && entry.BlockDeviceMappings.length) { + entry.BlockDeviceMappings.forEach(mapping => { + if (mapping.Ebs && mapping.Ebs.VolumeId) passingVolumes.push(mapping.Ebs.VolumeId); + }); + } + break; + } + } + } + }); + } + }); + } + + async.each(describeVolumes.data, function(volume, vcb){ + if (!volume.VolumeId) return vcb(); + + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:volume/${volume.VolumeId}`; + var tagFound = false; + + if (passingVolumes.includes(volume.VolumeId)) tagFound = true; + else if (volume.Tags && volume.Tags.length) { + for (var vt in volume.Tags) { + var vtag = volume.Tags[vt]; + if (vtag.Key && vtag.Value && + passingVolumeTags[vtag.Key] && + passingVolumeTags[vtag.Key].includes(vtag.Value)) { + tagFound = true; + break; + } + } + } + + if (tagFound) { + helpers.addResult(results, 0, + `EBS volume "${volume.VolumeId}" has lifecycle policy configured`, region, resource); + } else { + helpers.addResult(results, 2, + `EBS volume "${volume.VolumeId}" does not have lifecycle policy configured`, region, resource); + } + + vcb(); + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsSnapshotLifecycle.spec.js b/plugins/aws/ec2/ebsSnapshotLifecycle.spec.js new file mode 100644 index 000000000..c9550eae2 --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotLifecycle.spec.js @@ -0,0 +1,249 @@ +const expect = require('chai').expect; +const ebsSnapshotLifecycle = require('./ebsSnapshotLifecycle'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-02cd6ecf4fb6f634d", + "InstanceType": "t2.micro", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-10-14T22:57:34.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-02f1886d6c361b9d5" + } + } + ], + "Tags": [ + { + "Key": "env", + "Value": "prod" + } + ] + } + ], + "OwnerId": "112233445566", + "ReservationId": "r-06b211b02d99a6a2d" + } +]; + +const describeVolumes = [ + { + "Attachments": [], + "AvailabilityZone": "us-east-1a", + "CreateTime": "2020-09-09T14:30:42.601Z", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "Size": 1, + "SnapshotId": "", + "State": "available", + "VolumeId": "vol-0065e2a7632d0d083", + "Iops": 100, + "Tags": [ + { + "Key": "env", + "Value": "prod" + } + ], + "VolumeType": "gp2", + "MultiAttachEnabled": false + }, + { + "Attachments": [ + { + "AttachTime": "2020-10-14T22:57:34.000Z", + "Device": "/dev/xvda", + "InstanceId": "i-02cd6ecf4fb6f634d", + "State": "attached", + "VolumeId": "vol-02f1886d6c361b9d5", + "DeleteOnTermination": true + } + ], + "AvailabilityZone": "us-east-1e", + "CreateTime": "2020-10-14T22:57:34.416Z", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "Size": 8, + "SnapshotId": "snap-0299d083f0ce6cd12", + "State": "in-use", + "VolumeId": "vol-02f1886d6c361b9d5", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + } +]; + +const getLifecyclePolicies = [ + { + "PolicyId": "policy-061754468570a2ed1", + "Description": "test-policy-1", + "State": "ENABLED", + "Tags": {} + } +]; + +const getLifecyclePolicy = [ + { + "Policy": { + "PolicyId": "policy-061754468570a2ed1", + "Description": "test-policy-1", + "State": "ENABLED", + "StatusMessage": "ENABLED", + "ExecutionRoleArn": "arn:aws:iam::112233445566:role/service-role/AWSDataLifecycleManagerDefaultRole", + "DateCreated": 1604050465.702, + "DateModified": 1604050465.834, + "PolicyDetails": { + "PolicyType": "EBS_SNAPSHOT_MANAGEMENT", + "ResourceTypes": [ + "VOLUME" + ], + "TargetTags": [ + { + "Key": "env", + "Value": "prod" + } + ], + "Schedules": [ + { + "Name": "Schedule 1", + "CopyTags": false, + "CreateRule": { + "Interval": 12, + "IntervalUnit": "HOURS", + "Times": [ + "09:00" + ] + }, + "RetainRule": { + "Count": 2, + "Interval": 0 + } + } + ] + }, + "PolicyArn": "arn:aws:dlm:us-east-1:112233445566:policy/policy-061754468570a2ed1" + } + } +]; + +const createCache = (describeVolumes, describeInstances, getLifecyclePolicies, getLifecyclePolicy) => { + var policyId = (getLifecyclePolicies && getLifecyclePolicies.length) ? getLifecyclePolicies[0].PolicyId : null + return { + ec2: { + describeVolumes: { + 'us-east-1': { + data: describeVolumes + } + }, + describeInstances: { + 'us-east-1': { + data: describeInstances + } + } + }, + dlm: { + getLifecyclePolicies: { + 'us-east-1': { + data: getLifecyclePolicies + } + }, + getLifecyclePolicy: { + 'us-east-1': { + [policyId]: { + data: getLifecyclePolicy + } + } + } + } + }; +} + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing ec2 instances' + }, + }, + }, + describeVolumes: { + 'us-east-1': { + err: { + message: 'error describing ebs volumes' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + }, + describeVolumes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ebsSnapshotLifecycle', function () { + describe('run', function () { + it('should PASS if EBS volume has lifecycle policy configures', function (done) { + const cache = createCache([describeVolumes[0]], describeInstances, getLifecyclePolicies, getLifecyclePolicy[0]); + ebsSnapshotLifecycle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EBS volume does not have lifecycle policy configured', function (done) { + const cache = createCache([describeVolumes[1]], [describeInstances[0]], getLifecyclePolicies, getLifecyclePolicy[0]); + ebsSnapshotLifecycle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no EBS volumes found', function (done) { + const cache = createCache([]); + ebsSnapshotLifecycle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if describe EC2 instances or EBS volumes response not found', function (done) { + const cache = createNullCache(); + ebsSnapshotLifecycle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EC2 instances or EBS volumes', function (done) { + const cache = createErrorCache(); + ebsSnapshotLifecycle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsSnapshotPrivate.js b/plugins/aws/ec2/ebsSnapshotPrivate.js index 8c2a7a95b..d31caa93a 100644 --- a/plugins/aws/ec2/ebsSnapshotPrivate.js +++ b/plugins/aws/ec2/ebsSnapshotPrivate.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EBS Volume Snapshot Public', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures EBS volume snapshots are private', more_info: 'EBS volumes often contain sensitive data from running EC2 instances and should be set to private so they cannot be accidentally shared with other accounts.', recommended_action: 'Ensure that each EBS snapshot has its permissions set to private.', @@ -18,7 +20,9 @@ module.exports = { async.each(regions.support, function(region, rcb) { var describeTrustedAdvisorChecks = helpers.addSource(cache, source, ['support', 'describeTrustedAdvisorChecks', region]); - if (!describeTrustedAdvisorChecks || describeTrustedAdvisorChecks.err || !describeTrustedAdvisorChecks.data) { + if (!describeTrustedAdvisorChecks) return rcb(); + + if (describeTrustedAdvisorChecks.err || !describeTrustedAdvisorChecks.data) { var errMsg = helpers.addError(describeTrustedAdvisorChecks); if (errMsg === 'AWS Premium Support Subscription is required to use this service.') { errMsg = 'Please activate AWS Premium Support'; diff --git a/plugins/aws/ec2/ebsSnapshotPublic.js b/plugins/aws/ec2/ebsSnapshotPublic.js new file mode 100644 index 000000000..ff06bcd2f --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotPublic.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Amazon EBS Public Snapshots', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Amazon EBS volume snapshots are not shared to all AWS accounts.', + more_info: 'AWS Elastic Block Store (EBS) volume snapshots should not be not publicly shared with other AWS account to avoid data exposure.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html', + recommended_action: 'Modify the permissions of public snapshots to remove public access.', + apis: ['EC2:describeSnapshots', 'EC2:describeSnapshotAttribute'], + realtime_triggers: ['ec2:CreateSnapshot' , 'ec2:ModifySnapshotAttribute', 'ec2:DeleteSnapshot'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSnapshots = helpers.addSource(cache, source, + ['ec2', 'describeSnapshots', region]); + + if (!describeSnapshots) return rcb(); + + if (describeSnapshots.err || !describeSnapshots.data) { + helpers.addResult(results, 3, + `Unable to query for EBS Snapshots: ${helpers.addError(describeSnapshots)}`, region); + return rcb(); + } + + if (!describeSnapshots.data.length) { + helpers.addResult(results, 0, 'No EBS snapshots present', region); + return rcb(); + } + + var publicSnapshots = []; + describeSnapshots.data.forEach(snapshot => { + if (!snapshot.SnapshotId) return; + + var resource = `arn:${awsOrGov}:${region}:${snapshot.OwnerId}:snapshot/${snapshot.SnapshotId}`; + + var describeSnapshotAttribute = helpers.addSource(cache, source, + ['ec2', 'describeSnapshotAttribute', region, snapshot.SnapshotId]); + + if (!describeSnapshotAttribute || + describeSnapshotAttribute.err || + !describeSnapshotAttribute.data || + !describeSnapshotAttribute.data.CreateVolumePermissions) { + helpers.addResult(results, 3, + `Unable to query EBS snapshot attribute: ${helpers.addError(describeSnapshotAttribute)}`, region, resource); + return; + } + + if (describeSnapshotAttribute.data.CreateVolumePermissions.length) { + for (var p in describeSnapshotAttribute.data.CreateVolumePermissions) { + var perm = describeSnapshotAttribute.data.CreateVolumePermissions[p]; + + if (perm.Group && perm.Group === 'all') { + publicSnapshots.push(resource); + break; + } + } + } + }); + + if (publicSnapshots.length > 20) { + helpers.addResult(results, 2, 'More than 20 EBS snapshots are publicly shared', region); + } else if (publicSnapshots.length) { + for (var ps in publicSnapshots) { + helpers.addResult(results, 2, + 'EBS snapshot is publicly shared', + region, publicSnapshots[ps]); + } + } else { + helpers.addResult(results, 0, + 'No public EBS snapshots found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ebsSnapshotPublic.spec.js b/plugins/aws/ec2/ebsSnapshotPublic.spec.js new file mode 100644 index 000000000..6b2d62b48 --- /dev/null +++ b/plugins/aws/ec2/ebsSnapshotPublic.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var ebsSnapshotPublic = require('./ebsSnapshotPublic'); + +const describeSnapshots = [ + { + "Description": "Created for testing", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-00317ba0e33942c5a", + "StartTime": "2020-10-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0065e2a7632d0d083", + "VolumeSize": 1, + "Tags": [] + }, + { + "Description": "Created for testing", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/c48d9687-cdd3-4a1f-9d80-f92a7693c5d0", + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-03fb4402f29407fa0", + "StartTime": "2020-10-31T11:40:33.066Z", + "State": "completed", + "VolumeId": "vol-0065e2a7632d0d083", + "VolumeSize": 1, + "Tags": [] + } +]; + +const describeSnapshotAttribute = [ + { + "CreateVolumePermissions": [ { Group: 'all' } ], + "ProductCodes": [], + "SnapshotId": "snap-00317ba0e33942c5a" + }, + { + "CreateVolumePermissions": [], + "ProductCodes": [], + "SnapshotId": "snap-03fb4402f29407fa0" + } +]; + +const createCache = (describeSnapshots, describeSnapshotAttribute) => { + var snapshotId = (describeSnapshots && describeSnapshots.length) ? describeSnapshots[0].SnapshotId : null; + return { + ec2: { + describeSnapshots: { + 'us-east-1': { + data: describeSnapshots + } + }, + describeSnapshotAttribute: { + 'us-east-1': { + [snapshotId]: { + data: describeSnapshotAttribute + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeSnapshots: { + 'us-east-1': { + err: { + message: 'error describing EC2 snapshots' + } + } + }, + describeSnapshotAttribute: { + 'us-east-1': { + err: { + message: 'error describing EC2 snapshot attributes' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeSnapshots: { + 'us-east-1': null + }, + describeSnapshotAttribute: { + 'us-east-1': null + } + } + }; +}; + +describe('ebsSnapshotPublic', function () { + describe('run', function () { + it('should PASS if no public EBS snapshots found', function (done) { + const cache = createCache([describeSnapshots[1]], describeSnapshotAttribute[1]); + ebsSnapshotPublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EBS snapshot is publicly shared to all AWS accounts', function (done) { + const cache = createCache([describeSnapshots[0]], describeSnapshotAttribute[0]); + ebsSnapshotPublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no EBS volumes found', function (done) { + const cache = createCache([]); + ebsSnapshotPublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if describe EC2 snapshots response not found', function (done) { + const cache = createNullCache(); + ebsSnapshotPublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable tp describe EC2 snapshots', function (done) { + const cache = createErrorCache(); + ebsSnapshotPublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsUnusedVolumes.js b/plugins/aws/ec2/ebsUnusedVolumes.js new file mode 100644 index 000000000..6e5f75ad3 --- /dev/null +++ b/plugins/aws/ec2/ebsUnusedVolumes.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused EBS Volumes', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures EBS volumes are in use and attached to EC2 instances', + more_info: 'EBS volumes should be deleted if the parent instance has been deleted to prevent accidental exposure of data.', + recommended_action: 'Delete the unassociated EBS volume.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-deleting-volume.html', + apis: ['EC2:describeInstances', 'EC2:describeVolumes', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVolume','ec2:DeleteVolume'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + var describeVolumes = helpers.addSource(cache, source, + ['ec2', 'describeVolumes', region]); + + if (!describeInstances || !describeVolumes) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for EC2 Instances: ' + helpers.addError(describeInstances), region); + return rcb(); + } + + if (describeVolumes.err || !describeVolumes.data) { + helpers.addResult(results, 3, + 'Unable to query for EBS Volumes: ' + helpers.addError(describeVolumes), region); + return rcb(); + } + + if (!describeVolumes.data.length) { + helpers.addResult(results, 0, 'No EBS Volumes found', region); + return rcb(); + } + + var usedEbsVolumes = []; + if (describeInstances.data.length) { + describeInstances.data.forEach(function(instances) { + instances.Instances.forEach(function(instance) { + if (instance.BlockDeviceMappings && instance.BlockDeviceMappings.length) { + instance.BlockDeviceMappings.forEach(function(ebsMapping) { + usedEbsVolumes.push(ebsMapping.Ebs.VolumeId); + }); + } + }); + }); + } + + describeVolumes.data.forEach(function(volume) { + if (volume.VolumeId) { + var volumeArn = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':volume/' + volume.VolumeId; + if (!usedEbsVolumes.includes(volume.VolumeId)) { + helpers.addResult(results, 2, + 'EBS Volume is not attached to any EC2 instance', + region, volumeArn); + } else { + helpers.addResult(results, 0, + 'EBS Volume is attached to an EC2 instance', + region, volumeArn); + } + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/ebsUnusedVolumes.spec.js b/plugins/aws/ec2/ebsUnusedVolumes.spec.js new file mode 100644 index 000000000..7995964e7 --- /dev/null +++ b/plugins/aws/ec2/ebsUnusedVolumes.spec.js @@ -0,0 +1,269 @@ +var expect = require('chai').expect; +const ebsUnusedVolumes = require('./ebsUnusedVolumes'); + +describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-08-25T02:21:49.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-025b523c155020b10" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [ + { + "Attachment": { + "AttachTime": "2020-08-25T02:21:48.000Z", + "AttachmentId": "eni-attach-077c0f4c969c20b4c", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "Ipv6Addresses": [], + "MacAddress": "06:22:7f:a4:48:f3", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [ + { + "Key": "Name", + "Value": "sploit-959-test-instance" + } + ], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + } +] + +describeVolumes = [ + { + "Attachments": [], + "AvailabilityZone": "us-east-1d", + "CreateTime": "2020-09-01T03:40:13.595Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "available", + "VolumeId": "vol-0d7619e666a54b52a", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + }, + { + "Attachments": [ + { + "AttachTime": "2020-08-25T02:21:49.000Z", + "Device": "/dev/xvda", + "InstanceId": "i-03afb9daa31f31bb0", + "State": "attached", + "VolumeId": "vol-025b523c155020b10", + "DeleteOnTermination": true + } + ], + "AvailabilityZone": "us-east-1e", + "CreateTime": "2020-08-25T02:21:49.073Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "in-use", + "VolumeId": "vol-025b523c155020b10", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false + } +] + +const createCache = (instances, volumes) => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + describeVolumes: { + 'us-east-1': { + data: volumes + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing ec2 instances' + }, + }, + }, + describeVolumes: { + 'us-east-1': { + err: { + message: 'error describing ebs volumes' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + }, + describeVolumes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ebsUnusedVolumes', function () { + describe('run', function () { + it('should PASS if EBS volume is attached to EC2 instance', function (done) { + const cache = createCache([describeInstances[0]], [describeVolumes[1]]); + ebsUnusedVolumes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + // expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EBS volume is not attached to EC2 instance', function (done) { + const cache = createCache([describeInstances[0]], [describeVolumes[0]]); + ebsUnusedVolumes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no EBS volumes found', function (done) { + const cache = createCache([describeInstances[0]],[]); + ebsUnusedVolumes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to fetch EC2 instances or EBS volumes', function (done) { + const cache = createNullCache(); + ebsUnusedVolumes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while describe EC2 instances or EBS volumes', function (done) { + const cache = createErrorCache(); + ebsUnusedVolumes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ebsVolumeHasTags.js b/plugins/aws/ec2/ebsVolumeHasTags.js new file mode 100644 index 000000000..66211bf53 --- /dev/null +++ b/plugins/aws/ec2/ebsVolumeHasTags.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EBS Volume has tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that EBS Volumes have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify EBS volumes and add tags', + link: 'https://aws.amazon.com/blogs/aws/new-tag-ec2-instances-ebs-volumes-on-creation/', + apis: ['EC2:describeVolumes', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVolume', 'ec2:AddTags', 'ec2:DeleteTags','ec2:DeleteVolume'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + var describeVolumes = helpers.addSource(cache, source, + ['ec2', 'describeVolumes', region]); + + if (!describeVolumes) return rcb(); + + if (describeVolumes.err || !describeVolumes.data) { + helpers.addResult(results, 3, + 'Unable to query for EBS Volumes: ' + helpers.addError(describeVolumes), region); + return rcb(); + } + + if (!describeVolumes.data.length) { + helpers.addResult(results, 0, 'No EBS Volumes found', region); + return rcb(); + } + + for (let volume of describeVolumes.data) { + if (!volume.VolumeId) continue; + + var volumeArn = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':volume/' + volume.VolumeId; + + if (!volume.Tags || !volume.Tags.length) { + helpers.addResult(results, 2, 'EBS volume does not have tags', region, volumeArn); + } else { + helpers.addResult(results, 0, 'EBS volume has tags', region, volumeArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/ebsVolumeHasTags.spec.js b/plugins/aws/ec2/ebsVolumeHasTags.spec.js new file mode 100644 index 000000000..1e1016702 --- /dev/null +++ b/plugins/aws/ec2/ebsVolumeHasTags.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +const ebsVolumeHasTags = require('./ebsVolumeHasTags'); + +describeVolumes = [ + { + "Attachments": [], + "AvailabilityZone": "us-east-1d", + "CreateTime": "2020-09-01T03:40:13.595Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "available", + "VolumeId": "vol-0d7619e666a54b52a", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false, + 'Tags': [] + }, + { + "Attachments": [], + "AvailabilityZone": "us-east-1d", + "CreateTime": "2020-09-01T03:40:13.595Z", + "Encrypted": false, + "Size": 8, + "SnapshotId": "snap-06d919bfeced8496a", + "State": "available", + "VolumeId": "vol-0d7619e666a54b52a", + "Iops": 100, + "VolumeType": "gp2", + "MultiAttachEnabled": false, + 'Tags': [ {key : 'value'} ] + } +] + +const createCache = (volumes) => { + return { + ec2: { + describeVolumes: { + 'us-east-1': { + data: volumes + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVolumes: { + 'us-east-1': { + err: { + message: 'error describing ebs volumes' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVolumes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ebsVolumeHasTags', function () { + describe('run', function () { + it('should PASS if EBS volume has tags', function (done) { + const cache = createCache([describeVolumes[1]]); + ebsVolumeHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume has tags'); + done(); + }); + }); + + it('should FAIL if EBS volume does not have tags', function (done) { + const cache = createCache([describeVolumes[0]]); + ebsVolumeHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have tags'); + done(); + }); + }); + + it('should PASS if no EBS volumes found', function (done) { + const cache = createCache([]); + ebsVolumeHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EBS Volumes found'); + done(); + }); + }); + + it('should UNKNOWN if error occurs while describe EBS volume', function (done) { + const cache = createErrorCache(); + ebsVolumeHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EBS Volumes'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ec2HasTags.js b/plugins/aws/ec2/ec2HasTags.js new file mode 100644 index 000000000..ba3f54e61 --- /dev/null +++ b/plugins/aws/ec2/ec2HasTags.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EC2 has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that AWS EC2 Instances have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html', + recommended_action: 'Modify EC2 instances and add tags.', + apis: ['EC2:describeInstances'], + realtime_triggers: ['ec2:RunInstances', 'ec2:AddTags', 'ec2:DeleteTags', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource(cache, source, ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, `Unable to query for instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + for (var instances of describeInstances.data){ + const { OwnerId } = instances; + + for (var instance of instances.Instances) { + const { Tags, InstanceId } = instance; + const arn = `arn:${awsOrGov}:ec2:${region}:${OwnerId}:instance/${InstanceId}`; + if (!Tags || !Tags.length){ + helpers.addResult(results, 2, 'EC2 Instance does not have tags associated', region, arn); + } else { + helpers.addResult(results, 0, 'EC2 Instance has tags associated', region, arn); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/ec2HasTags.spec.js b/plugins/aws/ec2/ec2HasTags.spec.js new file mode 100644 index 000000000..9a58ba8af --- /dev/null +++ b/plugins/aws/ec2/ec2HasTags.spec.js @@ -0,0 +1,307 @@ +var expect = require('chai').expect; +const ec2HasTags = require('./ec2HasTags'); + +describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-08-25T02:21:49.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-025b523c155020b10" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [ + { + "Attachment": { + "AttachTime": "2020-08-25T02:21:48.000Z", + "AttachmentId": "eni-attach-077c0f4c969c20b4c", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "Ipv6Addresses": [], + "MacAddress": "06:22:7f:a4:48:f3", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [ + { + "Key": "Name", + "Value": "sploit-959-test-instance" + } + ], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-02354e95b39ca8dec", + "InstanceId": "i-03afb9daa31f31bb0", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-08-31T23:52:43.000Z", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1e", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "ProductCodes": [], + "PublicDnsName": "", + "State": { + "Code": 80, + "Name": "stopped" + }, + "StateTransitionReason": "User initiated (2020-09-01 03:39:08 GMT)", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2020-08-25T02:21:49.000Z", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-025b523c155020b10" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [ + { + "Attachment": { + "AttachTime": "2020-08-25T02:21:48.000Z", + "AttachmentId": "eni-attach-077c0f4c969c20b4c", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached" + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "Ipv6Addresses": [], + "MacAddress": "06:22:7f:a4:48:f3", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-54-187.ec2.internal", + "PrivateIpAddress": "172.31.54.187" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-4", + "GroupId": "sg-0174d5e394e23015e" + } + ], + "SourceDestCheck": true, + "StateReason": { + "Code": "Client.UserInitiatedShutdown", + "Message": "Client.UserInitiatedShutdown: User initiated shutdown" + }, + "Tags": [], + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled" + } + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-073e1215b28407ada" + } +] + +const createCache = (instances) => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + + +describe('ec2HasTags', function () { + describe('run', function () { + + it('should return UNKNOWN result if error occurs while describe EC2 instances ', function (done) { + const cache = createCache(null); + ec2HasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should return Passing result if no EC2 instances found', function (done) { + const cache = createCache([]); + ec2HasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should return Passing result if EC2 instance has tags', function (done) { + const cache = createCache([describeInstances[0]]); + ec2HasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + // expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should return Fail result if EC2 instance has no tags', function (done) { + const cache = createCache([describeInstances[1]]); + ec2HasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/ec2MetadataOptions.js b/plugins/aws/ec2/ec2MetadataOptions.js index 6dae5110e..4ce1ba1fe 100644 --- a/plugins/aws/ec2/ec2MetadataOptions.js +++ b/plugins/aws/ec2/ec2MetadataOptions.js @@ -5,16 +5,20 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Insecure EC2 Metadata Options', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensures EC2 instance metadata is updated to require HttpTokens or disable HttpEndpoint', more_info: 'The new EC2 metadata service prevents SSRF attack escalations from accessing the sensitive instance metadata endpoints.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html#configuring-instance-metadata-service', recommended_action: 'Update instance metadata options to use IMDSv2', apis: ['EC2:describeInstances'], + realtime_triggers: ['ec2:RunInstances', 'ec2:ModifyInstanceMetadataOptions', 'ec2:TerminateInstances'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeInstances = helpers.addSource( @@ -34,7 +38,7 @@ module.exports = { for (var reservation of describeInstances.data) { var accountId = reservation.OwnerId; for (var instance of reservation.Instances) { - var arn = 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + instance.InstanceId; + var arn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instance.InstanceId; if (!instance.MetadataOptions) { helpers.addResult(results, 3, 'Unable to get instance metadata options', region, arn); @@ -53,15 +57,11 @@ module.exports = { } } - var message = 'Instances using insecure V1 endpoint: ' + instancesInsecure.length + '; ' + - 'instances using secure V2 endpoint: ' + instancesTokensRequired.length + '; ' + - 'instances with disabled endpoints: ' + instancesEndpointDisabled.length; - var totalCount = instancesInsecure.length + instancesTokensRequired.length + instancesEndpointDisabled.length; if (!totalCount) { helpers.addResult(results, 0, 'No instances found', region); - } else if (totalCount <= 20) { + } else { // Add individual results for (var iArn of instancesEndpointDisabled) { helpers.addResult(results, 0, 'Instance has instance metadata endpoint disabled', region, iArn); @@ -74,12 +74,8 @@ module.exports = { for (var kArn of instancesInsecure) { helpers.addResult(results, 2, 'Instance has instance metadata endpoint enabled and does not require HttpTokens', region, kArn); } - } else if (instancesInsecure.length) { - helpers.addResult(results, 2, message, region); - } else { - helpers.addResult(results, 0, message, region); } - + return rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/ec2/ec2NetworkExposure.js b/plugins/aws/ec2/ec2NetworkExposure.js new file mode 100644 index 000000000..1892d5c6f --- /dev/null +++ b/plugins/aws/ec2/ec2NetworkExposure.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Exposure', + category: 'EC2', + domain: 'Compute', + severity: 'Info', + description: 'Check if EC2 instances are exposed to the internet.', + more_info: 'EC2 instances exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security groups, NACLs, and route tables.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html', + recommended_action: 'Secure EC2 instances by restricting access with properly configured security groups and NACLs.', + apis: ['EC2:describeInstances', 'EC2:describeNetworkAcls', 'EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'EC2:describeSubnets', + 'EC2:describeRouteTables', 'ELB:describeLoadBalancers','ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', 'ELBv2:describeTargetHealth', 'ELBv2:describeListeners'], + realtime_triggers: ['ec2:RunInstances','ec2:TerminateInstances', 'ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:ReplaceNetworkAclAssociation', + 'ec2:DeleteNetworkAcl', 'ec2:CreateSecurityGroup', 'ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', + 'ec2:DeleteSecurityGroup', 'ec2:ModifyInstanceAttribute', 'ec2:ModifySubnetAttribute', 'elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyTargetGroups', 'elasticloadbalancing:RegisterTarget', 'elasticloadbalancing:DeregisterTargets', 'elasticloadbalancing:DeleteLoadBalancer', + 'elasticloadbalancing:DeleteTargetGroup', 'elasticloadbalancing:RegisterInstancesWithLoadBalancer', 'elasticloadbalancing:DeregisterInstancesWithLoadBalancer','elasticloadbalancing:CreateListener', 'elasticloadbalancing:DeleteListener'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for instances: ' + helpers.addError(describeInstances), region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No instances found', region); + return rcb(); + } + + for (var instances of describeInstances.data){ + const { OwnerId } = instances; + for (var instance of instances.Instances) { + const { InstanceId } = instance; + const arn = `arn:${awsOrGov}:ec2:${region}:${OwnerId}:instance/${InstanceId}`; + + // List all ELB's attached to the instance + let elbs = helpers.getAttachedELBs(cache, source, region, InstanceId, 'Instances', 'InstanceId'); + + let internetExposed = helpers.checkNetworkExposure(cache, source, [{id: instance.SubnetId}], instance.SecurityGroups, elbs, region, results, instance); + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `EC2 instance is exposed to the internet through ${internetExposed}`, region, arn); + } else { + helpers.addResult(results, 0, 'EC2 instance is not exposed to the internet', region, arn); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/ec2PrivilegeAnalysis.js b/plugins/aws/ec2/ec2PrivilegeAnalysis.js new file mode 100644 index 000000000..0c4d73b4c --- /dev/null +++ b/plugins/aws/ec2/ec2PrivilegeAnalysis.js @@ -0,0 +1,18 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'EC2', + domain: 'Compute', + severity: 'Info', + description: 'Check if EC2 instances are overly permissive.', + more_info: 'EC2 instances exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security groups, NACLs, and route tables.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html', + recommended_action: 'Secure EC2 instances by restricting access with properly configured security groups and NACLs.', + realtime_triggers: ['ec2:RunInstances','ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + callback(null, results, source); + + } +}; diff --git a/plugins/aws/ec2/elasticIpLimit.js b/plugins/aws/ec2/elasticIpLimit.js index 49a339b37..14e583983 100644 --- a/plugins/aws/ec2/elasticIpLimit.js +++ b/plugins/aws/ec2/elasticIpLimit.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Elastic IP Limit', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if the number of allocated EIPs is close to the AWS per-account limit', more_info: 'AWS limits accounts to certain numbers of resources. Exceeding those limits could prevent resources from launching.', recommended_action: 'Contact AWS support to increase the number of EIPs available', diff --git a/plugins/aws/ec2/elasticIpLimit.spec.js b/plugins/aws/ec2/elasticIpLimit.spec.js new file mode 100644 index 000000000..3c29a7dbb --- /dev/null +++ b/plugins/aws/ec2/elasticIpLimit.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +const elasticIpLimit = require('./elasticIpLimit'); + +const describeAccountAttributes = [ + [ + { + "AttributeName": "max-elastic-ips", + "AttributeValues": [ + { + "AttributeValue": "5" + } + ] + }, + ] +]; + +const describeAddresses = [ + { + "PublicIp": "52.73.207.255", + "AllocationId": "eipalloc-012a1de6c78e459ba", + "AssociationId": "eipassoc-058f91ade38b552c3", + "Domain": "standard", + "NetworkInterfaceId": "eni-0c9ee96ca599e524f", + "NetworkInterfaceOwnerId": "111122223333", + "PrivateIpAddress": "172.31.56.42", + "PublicIpv4Pool": "amazon", + "NetworkBorderGroup": "us-east-1" + } +]; + +const createCache = (attributes, addresses) => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': { + data: attributes + }, + }, + describeAddresses: { + 'us-east-1': { + data: addresses + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': { + err: { + message: 'error describing account attributes' + }, + }, + }, + describeAddresses: { + 'us-east-1': { + err: { + message: 'error describing addresses' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': null, + }, + describeAddresses: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('elasticIpLimit', function () { + describe('run', function () { + it('should PASS if account is using VPC Elastic IPs less than the defined warn percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0]]); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if account is using VPC Elastic IPs within the defined warn percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0]]); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if account is using VPC Elastic IPs within the defined fail percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0]]); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no addresses found', function (done) { + const cache = createCache(describeAccountAttributes[0], []); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to describe account attributes', function (done) { + const cache = createErrorCache(); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should FAIL if unable to describe addresses', function (done) { + const cache = createCache([]); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe account attributes response not found', function (done) { + const cache = createNullCache(); + elasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/enableDetailedMonitoring.js b/plugins/aws/ec2/enableDetailedMonitoring.js new file mode 100644 index 000000000..b7f9b5331 --- /dev/null +++ b/plugins/aws/ec2/enableDetailedMonitoring.js @@ -0,0 +1,61 @@ + +const async = require('async'); +const helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Instance Detailed Monitoring', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that EC2 instances have detailed monitoring feature enabled.', + more_info: 'By default, your instance is enabled for basic monitoring. After you enable detailed monitoring, EC2 console displays monitoring graphs with a 1-minute period.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html', + recommended_action: 'Modify EC2 instance to enable detailed monitoring.', + apis: ['EC2:describeInstances'], + realtime_triggers: ['ec2:RunInstances', 'ec2:MonitorInstances', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb) { + const describeInstances = helpers.addSource( + cache, source, ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, `Unable to query for instances: + ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + for (const reservation of describeInstances.data) { + const accountId = reservation.OwnerId; + for (const instance of reservation.Instances) { + const arn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instance.InstanceId; + + if (instance.Monitoring && instance.Monitoring.State && instance.Monitoring.State.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, + 'Instance has enabled detailed monitoring', region, arn); + } else { + helpers.addResult(results, 2, + 'Instance does not have enabled detailed monitoring', region, arn); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + +}; diff --git a/plugins/aws/ec2/enableDetailedMonitoring.spec.js b/plugins/aws/ec2/enableDetailedMonitoring.spec.js new file mode 100644 index 000000000..40f742717 --- /dev/null +++ b/plugins/aws/ec2/enableDetailedMonitoring.spec.js @@ -0,0 +1,113 @@ +const expect = require('chai').expect; +const enableDetailedMonitoring = require('./enableDetailedMonitoring'); + +describeInstances = [ + { + Groups: [], + Instances: [ + { + AmiLaunchIndex: 0, + ImageId: 'ami-0022f774911c1d690', + InstanceId: 'i-02d03efaa61cec2b4', + InstanceType: 't2.micro', + KeyName: 'test', + LaunchTime: '2022-06-21T13:19:06+00:00', + Monitoring: { + State: 'disabled' + } + } + ] + }, + { + Groups: [], + Instances: [ + { + AmiLaunchIndex: 0, + ImageId: 'ami-026b57f3c383c2eec', + InstanceId: 'i-014684b238c2a2542', + InstanceType: 't2.micro', + KeyName: 'key', + LaunchTime: '2022-09-23T08:04:24+00:00', + Monitoring: { + State: 'enabled' + } + } + ], + OwnerId: '000011112222', + ReservationId: 'r-065644dc06b2b1443' + } + +]; + +const createCache = (instances) => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: instances + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error' + } + } + } + } + }; +}; + +describe('enableDetailedMonitoring', function () { + describe('run', function () { + it('should PASS if there are no instances', function (done) { + const cache = createCache([]); + enableDetailedMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EC2 instances found'); + done(); + }); + }); + + it('should UNKNOWN if describeInstances error', function (done) { + const cache = createErrorCache(); + enableDetailedMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for instances'); + done(); + }); + }); + + it('should PASS if EC2 instance has enabled detailed monitoring', function (done) { + const cache = createCache([describeInstances[1]]); + enableDetailedMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal( 'Instance has enabled detailed monitoring'); + done(); + }); + }); + + it('should FAIL if EC2 instance does not have enabled detailed monitoring', function (done) { + const cache = createCache([describeInstances[0]]); + enableDetailedMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal( 'Instance does not have enabled detailed monitoring'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/encryptedAmi.js b/plugins/aws/ec2/encryptedAmi.js index acfdf256d..73568aaef 100644 --- a/plugins/aws/ec2/encryptedAmi.js +++ b/plugins/aws/ec2/encryptedAmi.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Encrypted AMI', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures EBS-backed AMIs are configured to use encryption', more_info: 'AMIs with unencrypted data volumes can be used to launch unencrypted instances that place data at risk.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIEncryption.html', @@ -15,6 +17,7 @@ module.exports = { 'allow it to remain compliant with the encryption at-rest ' + 'regulatory requirement.' }, + realtime_triggers: ['ec2:CreateImage', 'ec2:CopyImage', 'ec2:DeregisterImage'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/ec2/encryptedAmi.spec.js b/plugins/aws/ec2/encryptedAmi.spec.js new file mode 100644 index 000000000..3d57743fb --- /dev/null +++ b/plugins/aws/ec2/encryptedAmi.spec.js @@ -0,0 +1,429 @@ +var expect = require('chai').expect; +const encryptedAmi = require('./encryptedAmi'); + +const describeImages = [ + { + ImageId: 'ami-0b8afcbfa2e909c96', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: true + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c01', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c02', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c03', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c04', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c05', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c06', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c07', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c08', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c09', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c10', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c11', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c12', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c13', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c14', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c15', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c16', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c17', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c18', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c19', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c20', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c21', + OwnerId: '111122223333', + State: 'available', + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: false + } + }], + } +] + +const createCache = (images) => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + data: images + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + err: { + message: 'error describing images' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeImages: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('encryptedAmi', function () { + describe('run', function () { + it('should PASS if no AMIs with unencrypted volumes found', function (done) { + const cache = createCache([describeImages[0]]); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no AMIs with unencrypted volumes found', function (done) { + const cache = createCache([describeImages[1]]); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if more than 20 unencrypted AMI EBS volumes found', function (done) { + const cache = createCache(describeImages); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no AMIs found', function (done) { + const cache = createCache([]); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe images', function (done) { + const cache = createErrorCache(); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe images response is not found', function (done) { + const cache = createNullCache(); + encryptedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/excessiveSecurityGroups.js b/plugins/aws/ec2/excessiveSecurityGroups.js index 2a0e02271..e22024fdb 100644 --- a/plugins/aws/ec2/excessiveSecurityGroups.js +++ b/plugins/aws/ec2/excessiveSecurityGroups.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Excessive Security Groups', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if there are an excessive number of security groups in the account', more_info: 'Keeping the number of security groups to a minimum helps reduce the attack surface of an account. Rather than creating new groups with the same rules for each project, common rules should be grouped under the same security groups. For example, instead of adding port 22 from a known IP to every group, create a single "SSH" security group which can be used on multiple instances.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', @@ -29,6 +31,7 @@ module.exports = { default: 30 } }, + realtime_triggers: ['ec2:CreateSecurityGroup', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/ec2/excessiveSecurityGroups.spec.js b/plugins/aws/ec2/excessiveSecurityGroups.spec.js new file mode 100644 index 000000000..771874179 --- /dev/null +++ b/plugins/aws/ec2/excessiveSecurityGroups.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +const excessiveSecurityGroups = require('./excessiveSecurityGroups'); + +const describeSecurityGroups = [ + { + Description: 'default VPC security group', + GroupName: 'default', + IpPermissions: [Array], + OwnerId: '111122223333', + GroupId: 'sg-aa941691', + IpPermissionsEgress: [Array], + Tags: [], + VpcId: 'vpc-99de2fe4' + } +]; + +const createCache = (groups) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('excessiveSecurityGroups', function () { + describe('run', function () { + it('should PASS if acceptable number of security groups present', function (done) { + const cache = createCache([describeSecurityGroups[0]]); + var settings = { + excessive_security_groups_fail: 2, + excessive_security_groups_warn: 1 + }; + excessiveSecurityGroups.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if large number of security groups present', function (done) { + const cache = createCache([describeSecurityGroups[0],describeSecurityGroups[0]]); + var settings = { + excessive_security_groups_fail: 2, + excessive_security_groups_warn: 1 + }; + excessiveSecurityGroups.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if excessive number of security groups present', function (done) { + const cache = createCache([describeSecurityGroups[0],describeSecurityGroups[0],describeSecurityGroups[0]]); + var settings = { + excessive_security_groups_fail: 2, + excessive_security_groups_warn: 1 + }; + excessiveSecurityGroups.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no security groups present', function (done) { + const cache = createCache([]); + excessiveSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe security groups', function (done) { + const cache = createErrorCache(); + excessiveSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + excessiveSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/flowLogsEnabled.js b/plugins/aws/ec2/flowLogsEnabled.js index 3aa2e384f..bb473dc83 100644 --- a/plugins/aws/ec2/flowLogsEnabled.js +++ b/plugins/aws/ec2/flowLogsEnabled.js @@ -5,11 +5,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'VPC Flow Logs Enabled', category: 'EC2', + domain: 'Compute', + severity: 'Low', description: 'Ensures VPC flow logs are enabled for traffic logging', more_info: 'VPC flow logs record all traffic flowing in to and out of a VPC. These logs are critical for auditing and review after security incidents.', link: 'http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/flow-logs.html', recommended_action: 'Enable VPC flow logs for each VPC', - apis: ['EC2:describeVpcs', 'EC2:describeFlowLogs'], + apis: ['EC2:describeVpcs', 'EC2:describeFlowLogs', 'STS:getCallerIdentity'], compliance: { hipaa: 'VPC Flow Logs provide a detailed traffic log of a VPC network ' + 'containing HIPAA data. Flow Logs should be enabled to satisfy ' + @@ -18,12 +20,17 @@ module.exports = { 'cardholder data. Enable VPC flow logs to log these network requests.', cis2: '2.9 Ensure VPC flow logging is enabled in all VPCs' }, + realtime_triggers: ['ec2:CreateVpc', 'ec2:CreateFlowLogs', 'ec2:DeleteFlowLogs', 'ec2:DeleteVpc'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + async.each(regions.flowlogs, function(region, rcb){ var describeVpcs = helpers.addSource(cache, source, ['ec2', 'describeVpcs', region]); @@ -66,9 +73,10 @@ module.exports = { } // Loop through VPCs and add results - for (var v in vpcMap) { + for (var v in vpcMap) { + var resource = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':vpc/' + v; if (!vpcMap[v].length) { - helpers.addResult(results, 2, 'VPC flow logs are not enabled', region, v); + helpers.addResult(results, 2, 'VPC flow logs are not enabled', region, resource); } else { var activeLogs = false; @@ -80,9 +88,9 @@ module.exports = { } if (activeLogs) { - helpers.addResult(results, 0, 'VPC flow logs are enabled', region, v); + helpers.addResult(results, 0, 'VPC flow logs are enabled', region, resource); } else { - helpers.addResult(results, 2, 'VPC flow logs are enabled, but not active', region, v); + helpers.addResult(results, 2, 'VPC flow logs are enabled, but not active', region, resource); } } } diff --git a/plugins/aws/ec2/flowLogsEnabled.spec.js b/plugins/aws/ec2/flowLogsEnabled.spec.js new file mode 100644 index 000000000..c8753f2fd --- /dev/null +++ b/plugins/aws/ec2/flowLogsEnabled.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +const flowLogsEnabled = require('./flowLogsEnabled'); + +const describeVpcs = [ + { + "CidrBlock": '172.31.0.0/16', + "DhcpOptionsId": 'dopt-3a821040', + "State": 'available', + "VpcId": 'vpc-99de2fe4', + "OwnerId": '111122223333', + "InstanceTenancy": 'default', + "Ipv6CidrBlockAssociationSet": [], + "CidrBlockAssociationSet": "", + "IsDefault": true, + "Tags": [] + } +]; + +const describeFlowLogs = [ + { + "CreationTime": '2020-11-10T23:42:32.557Z', + "DeliverLogsPermissionArn": 'arn:aws:iam::111122223333:role/service-role/test-lambda-role-rewuhg4u', + "DeliverLogsStatus": 'SUCCESS', + "FlowLogId": 'fl-0698ebb14b1748a3f', + "FlowLogStatus": 'ACTIVE', + "LogGroupName": '/aws/lambda/test-lambda', + "ResourceId": 'vpc-99de2fe4', + "TrafficType": 'ACCEPT', + "LogDestinationType": 'cloud-watch-logs', + "LogFormat": '${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status}', + "Tags": [], + "MaxAggregationInterval": 600 + }, + { + "CreationTime": '2020-11-10T23:42:32.557Z', + "DeliverLogsPermissionArn": 'arn:aws:iam::111122223333:role/service-role/test-lambda-role-rewuhg4u', + "DeliverLogsStatus": 'SUCCESS', + "FlowLogId": 'fl-0698ebb14b1748a3f', + "FlowLogStatus": 'INACTIVE', + "LogGroupName": '/aws/lambda/test-lambda', + "ResourceId": 'vpc-99de2fe4', + "TrafficType": 'ACCEPT', + "LogDestinationType": 'cloud-watch-logs', + "LogFormat": '${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status}', + "Tags": [], + "MaxAggregationInterval": 600 + }, +]; + +const createCache = (vpcs, flowlogs) => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + data: vpcs + }, + }, + describeFlowLogs: { + 'us-east-1': { + data: flowlogs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + err: { + message: 'error describing vpcs' + }, + }, + }, + describeFlowLogs: { + 'us-east-1': { + err: { + message: 'error describing flowlogs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': null, + }, + describeFlowLogs: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('flowLogsEnabled', function () { + describe('run', function () { + it('should PASS if VPC flow logs are enabled', function (done) { + const cache = createCache([describeVpcs[0]], [describeFlowLogs[0]]); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if VPC flow logs are enabled, but not active', function (done) { + const cache = createCache([describeVpcs[0]], [describeFlowLogs[1]]); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if VPC flow logs are not enabled', function (done) { + const cache = createCache([describeVpcs[0]], []); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPCs found', function (done) { + const cache = createCache([]); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON if unable to describe vpcs', function (done) { + const cache = createErrorCache(); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNWON if unable to describe flowlogs', function (done) { + const cache = createCache([describeVpcs[0]], null); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe vpcs response not found', function (done) { + const cache = createNullCache(); + flowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/instanceIamRole.js b/plugins/aws/ec2/instanceIamRole.js index 725aa0a2c..ea0ed5934 100644 --- a/plugins/aws/ec2/instanceIamRole.js +++ b/plugins/aws/ec2/instanceIamRole.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Instance IAM Role', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures EC2 instances are using an IAM role instead of hard-coded AWS credentials', more_info: 'IAM roles should be assigned to all instances to enable them to access AWS resources. Using an IAM role is more secure than hard-coding AWS access keys into application code.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html', @@ -17,6 +19,7 @@ module.exports = { default: 10 } }, + realtime_triggers: ['ec2:RunInstances','ec2:AssociateIamInstanceProfile', 'ec2:DisassociateIamInstanceProfile', 'ec2:TerminateInstances'], run: function(cache, settings, callback) { var config = { @@ -28,6 +31,7 @@ module.exports = { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeInstances = helpers.addSource(cache, source, @@ -59,7 +63,7 @@ module.exports = { found += 1; helpers.addResult(results, 2, 'Instance does not use an IAM role', region, - 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + + `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instance.InstanceId, custom); } } diff --git a/plugins/aws/ec2/instanceIamRole.spec.js b/plugins/aws/ec2/instanceIamRole.spec.js new file mode 100644 index 000000000..4ba3f4fce --- /dev/null +++ b/plugins/aws/ec2/instanceIamRole.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +const instanceIamRole = require('./instanceIamRole'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + }, + ], + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + }, + ], + }, +]; + +const createCache = (instances) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('instanceIamRole', function () { + describe('run', function () { + it('should PASS if all instances are using IAM roles', function (done) { + const cache = createCache([describeInstances[0]]); + instanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instance does not use an IAM role', function (done) { + const cache = createCache([describeInstances[1]]); + instanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if more than threshold instances do not use an IAM role', function (done) { + const cache = createCache([describeInstances[1], describeInstances[1], describeInstances[1]]); + var settings = { + instance_iam_role_threshold: 2 + }; + instanceIamRole.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + instanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe instances', function (done) { + const cache = createErrorCache(); + instanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe instances response not found', function (done) { + const cache = createNullCache(); + instanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/instanceKeyBasedLogin.js b/plugins/aws/ec2/instanceKeyBasedLogin.js index e41bf7e19..d116f3951 100644 --- a/plugins/aws/ec2/instanceKeyBasedLogin.js +++ b/plugins/aws/ec2/instanceKeyBasedLogin.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EC2 Instance Key Based Login', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensures EC2 instances have associated keys for password-less SSH login', more_info: 'AWS allows EC2 instances to be launched with a specified PEM key for SSH login which should be used instead of user and password login.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html', @@ -12,15 +14,16 @@ module.exports = { settings: { instance_keypair_threshold: { name: 'Instance Key Pair Threshold', - description: 'If more than this number of instances are missing an ssh key pair, results will be collapsed into a single result to avoid excessive result counts. Max is 299.', + description: 'Plugin results will become aggregated when this value is breached. Max is 299.', regex: '^[1-2]{1}[0-9]{0,2}$', - default: 10 + default: '10' } }, + realtime_triggers: ['ec2:RunInstances', 'ec2:ModifyInstanceAttribute', 'ec2;TerminateInstances'], run: function(cache, settings, callback) { var config = { - instance_keypair_threshold: settings.instance_keypair_threshold || this.settings.instance_keypair_threshold.default + instance_keypair_threshold: parseInt(settings.instance_keypair_threshold || this.settings.instance_keypair_threshold.default) }; var custom = helpers.isCustom(settings, this.settings); @@ -28,6 +31,7 @@ module.exports = { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeInstances = helpers.addSource(cache, source, @@ -58,7 +62,7 @@ module.exports = { found += 1; helpers.addResult(results, 2, 'Instance does not have associated keys for password-less SSH login', region, - 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + + `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instance.InstanceId, custom); } } diff --git a/plugins/aws/ec2/instanceKeyBasedLogin.spec.js b/plugins/aws/ec2/instanceKeyBasedLogin.spec.js new file mode 100644 index 000000000..fe39373c1 --- /dev/null +++ b/plugins/aws/ec2/instanceKeyBasedLogin.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +const instanceKeyBasedLogin = require('./instanceKeyBasedLogin'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "KeyName": "auto-scaling-test-instance", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::112233445566:instance-profile/aws-elasticbeanstalk-ec2-role", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + }, + ], + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::112233445566:instance-profile/aws-elasticbeanstalk-ec2-role", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + }, + ], + } +]; + +const createCache = (instances) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('instanceKeyBasedLogin', function () { + describe('run', function () { + it('should PASS if instance has associated keys for password-less SSH login', function (done) { + const cache = createCache([describeInstances[0]]); + var settings = { + instance_keypair_threshold: '2', + }; + instanceKeyBasedLogin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instance does not have associated keys for password-less SSH login', function (done) { + const cache = createCache([describeInstances[1]]); + var settings = { + instance_keypair_threshold: '2', + }; + instanceKeyBasedLogin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if EC2 instances do not have associated keys for password-less SSH login', function (done) { + const cache = createCache([describeInstances[1],describeInstances[1],describeInstances[1]]); + var settings = { + instance_keypair_threshold: '2', + }; + instanceKeyBasedLogin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + instanceKeyBasedLogin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe instances', function (done) { + const cache = createErrorCache(); + instanceKeyBasedLogin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe instances response not found', function (done) { + const cache = createNullCache(); + instanceKeyBasedLogin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/instanceLimit.js b/plugins/aws/ec2/instanceLimit.js index 12f4f542c..5e9320f74 100644 --- a/plugins/aws/ec2/instanceLimit.js +++ b/plugins/aws/ec2/instanceLimit.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Instance Limit', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if the number of EC2 instances is close to the AWS per-account limit', more_info: 'AWS limits accounts to certain numbers of resources. Exceeding those limits could prevent resources from launching.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html#using-instance-addressing-limit', recommended_action: 'Contact AWS support to increase the number of instances available', - apis: ['EC2:describeAccountAttributes', 'EC2:describeInstances'], + apis: ['EC2:describeInstances'], settings: { instance_limit_percentage_fail: { name: 'Instance Limit Percentage Fail', @@ -21,13 +23,21 @@ module.exports = { description: 'Return a warning result when utilized instances equals or exceeds this percentage', regex: '^(100|[1-9][0-9]?)$', default: 75 - } + }, + max_instance_count: { + name: 'Max Instance Count', + description: 'Check for the max number of utilized instance in a region', + regex: '^[0-9]{1,4}$', + default: 100 + }, }, + realtime_triggers: ['ec2:RunInstances', 'ec2:TerminateInstances'], run: function(cache, settings, callback) { var config = { instance_limit_percentage_fail: settings.instance_limit_percentage_fail || this.settings.instance_limit_percentage_fail.default, - instance_limit_percentage_warn: settings.instance_limit_percentage_warn || this.settings.instance_limit_percentage_warn.default + instance_limit_percentage_warn: settings.instance_limit_percentage_warn || this.settings.instance_limit_percentage_warn.default, + max_instance_count: settings.max_instance_count || this.settings.max_instance_count.default }; var custom = helpers.isCustom(settings, this.settings); @@ -37,27 +47,6 @@ module.exports = { var regions = helpers.regions(settings); async.each(regions.ec2, function(region, rcb){ - var describeAccountAttributes = helpers.addSource(cache, source, - ['ec2', 'describeAccountAttributes', region]); - - if (!describeAccountAttributes) return rcb(); - - if (describeAccountAttributes.err || !describeAccountAttributes.data) { - helpers.addResult(results, 3, - 'Unable to query for account limits: ' + helpers.addError(describeAccountAttributes), region); - return rcb(); - } - - var limits = { - 'max-instances': 20 - }; - - // Loop through response to assign custom limits - for (var i in describeAccountAttributes.data) { - if (limits[describeAccountAttributes.data[i].AttributeName]) { - limits[describeAccountAttributes.data[i].AttributeName] = describeAccountAttributes.data[i].AttributeValues[0].AttributeValue; - } - } var describeInstances = helpers.addSource(cache, source, ['ec2', 'describeInstances', region]); @@ -70,23 +59,25 @@ module.exports = { return rcb(); } - var ec2Instances = 0; + var instanceCount = 0; if (!describeInstances.data.length) { helpers.addResult(results, 0, 'No instances found', region); return rcb(); } else { - for (var instances in describeInstances.data){ - for (var instance in describeInstances.data[instances].Instances){ - if (!describeInstances.data[instances].Instances[instance].SpotInstanceRequestId){ - ec2Instances += 1; + for (var i in describeInstances.data) { + for (var j in describeInstances.data[i].Instances) { + var instance = describeInstances.data[i].Instances[j]; + + if (instance.State.Name === 'running') { + instanceCount +=1; } } } } - var percentage = Math.ceil((ec2Instances / limits['max-instances'])*100); - var returnMsg = 'Account contains ' + ec2Instances + ' of ' + limits['max-instances'] + ' (' + percentage + '%) available instances'; + var percentage = Math.ceil((instanceCount / config.max_instance_count)*100); + var returnMsg = 'Account contains ' + instanceCount + ' of ' + config.max_instance_count + ' (' + percentage + '%) available instances'; if (percentage >= config.instance_limit_percentage_fail) { helpers.addResult(results, 2, returnMsg, region, null, custom); diff --git a/plugins/aws/ec2/instanceLimit.spec.js b/plugins/aws/ec2/instanceLimit.spec.js new file mode 100644 index 000000000..725a7bfb2 --- /dev/null +++ b/plugins/aws/ec2/instanceLimit.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +const instanceLimit = require('./instanceLimit'); + + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "KeyName": "auto-scaling-test-instance", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + "State": { + "Code": 80, + "Name": "running" + }, + }, + ], + }, +]; + +const createCache = (instances) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': null, + } + }, + }; +}; + + +describe('instanceLimit', function () { + describe('run', function () { + it('should PASS if account contains instances less than the defined warn percentage', function (done) { + const cache = createCache([describeInstances[0]]); + instanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if account contains instances within the defined warn percentage', function (done) { + const cache = createCache([describeInstances[0],describeInstances[0],describeInstances[0],describeInstances[0]]); + instanceLimit.run(cache, {max_instance_count : 5}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if elastic ip usage is more than the defined fail percentage', function (done) { + const cache = createCache([describeInstances[0],describeInstances[0],describeInstances[0],describeInstances[0],describeInstances[0]]); + instanceLimit.run(cache, {max_instance_count : 2}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + instanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to describe instances', function (done) { + const cache = createErrorCache(); + instanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/instanceMaxCount.js b/plugins/aws/ec2/instanceMaxCount.js index be82c0767..ef28ed01b 100644 --- a/plugins/aws/ec2/instanceMaxCount.js +++ b/plugins/aws/ec2/instanceMaxCount.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EC2 Max Instances', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Ensures the total number of EC2 instances does not exceed a set threshold.', more_info: 'The number of running EC2 instances should be carefully audited, especially in unused regions, to ensure only approved applications are consuming compute resources. Many compromised AWS accounts see large numbers of EC2 instances launched.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring_ec2.html', @@ -64,6 +66,12 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_ap_southeast_3: { + name: 'Instance Count Region Threshold: ap-southeast-3', + description: 'Checks for the number of running instances in the ap-southeast-3 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_eu_central_1: { name: 'Instance Count Region Threshold: eu-central-1', description: 'Checks for the number of running instances in the eu-central-1 region and triggers a failing result if it exceeds the specified count', @@ -94,6 +102,12 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_eu_south_1: { + name: 'Instance Count Region Threshold: eu-south-1', + description: 'Checks for the number of running instances in the eu-south-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_sa_east_1: { name: 'Instance Count Region Threshold: sa-east-1', description: 'Checks for the number of running instances in the sa-east-1 region and triggers a failing result if it exceeds the specified count', @@ -118,6 +132,30 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_ap_northeast_3: { + name: 'Instance Count Region Threshold: ap-northeast-3', + description: 'Checks for the number of running instances in the ap-northeast-3 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_me_south_1: { + name: 'Instance Count Region Threshold: me-south-1', + description: 'Checks for the number of running instances in the me-south-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_me_central_1: { + name: 'Instance Count Region Threshold: me-central-1', + description: 'Checks for the number of running instances in the me-central-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_af_south_1: { + name: 'Instance Count Region Threshold: af-south-1', + description: 'Checks for the number of running instances in the af-south-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_us_gov_west_1: { name: 'Instance Count Region Threshold: us-gov-west-1', description: 'Checks for the number of running instances in the us-gov-west-1 region and triggers a failing result if it exceeds the specified count', @@ -141,8 +179,34 @@ module.exports = { description: 'Checks for the number of running instances in the cn-northwest-1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 100 - } + }, + instance_count_region_threshold_ap_south_2: { + name: 'Instance Count Region Threshold: ap-south-2', + description: 'Checks for the number of running instances in the ap-south-2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_ap_southeast_4: { + name: 'Instance Count Region Threshold: ap-southeast-4', + description: 'Checks for the number of running instances in the ap-southeast-4 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_eu_south_2: { + name: 'Instance Count Region Threshold: eu-south-2', + description: 'Checks for the number of running instances in the eu-south-2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_eu_central_2: { + name: 'Instance Count Region Threshold: eu-central-2', + description: 'Checks for the number of running instances in the eu-central-2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + }, + realtime_triggers: ['ec2:RunInstances', 'ec2:TerminateInstances'], run: function(cache, settings, callback) { var config = { @@ -153,21 +217,31 @@ module.exports = { instance_count_region_threshold_us_west_2: settings.instance_count_region_threshold_us_west_2 || this.settings.instance_count_region_threshold_us_west_2.default, instance_count_region_threshold_ap_northeast_1: settings.instance_count_region_threshold_ap_northeast_1 || this.settings.instance_count_region_threshold_ap_northeast_1.default, instance_count_region_threshold_ap_northeast_2: settings.instance_count_region_threshold_ap_northeast_2 || this.settings.instance_count_region_threshold_ap_northeast_2.default, + instance_count_region_threshold_ap_northeast_3: settings.instance_count_region_threshold_ap_northeast_3 || this.settings.instance_count_region_threshold_ap_northeast_3.default, instance_count_region_threshold_ap_southeast_1: settings.instance_count_region_threshold_ap_southeast_1 || this.settings.instance_count_region_threshold_ap_southeast_1.default, instance_count_region_threshold_ap_southeast_2: settings.instance_count_region_threshold_ap_southeast_2 || this.settings.instance_count_region_threshold_ap_southeast_2.default, + instance_count_region_threshold_ap_southeast_3: settings.instance_count_region_threshold_ap_southeast_3 || this.settings.instance_count_region_threshold_ap_southeast_3.default, instance_count_region_threshold_eu_central_1: settings.instance_count_region_threshold_eu_central_1 || this.settings.instance_count_region_threshold_eu_central_1.default, instance_count_region_threshold_eu_west_1: settings.instance_count_region_threshold_eu_west_1 || this.settings.instance_count_region_threshold_eu_west_1.default, instance_count_region_threshold_eu_west_2: settings.instance_count_region_threshold_eu_west_2 || this.settings.instance_count_region_threshold_eu_west_2.default, instance_count_region_threshold_eu_west_3: settings.instance_count_region_threshold_eu_west_3 || this.settings.instance_count_region_threshold_eu_west_3.default, instance_count_region_threshold_eu_north_1: settings.instance_count_region_threshold_eu_north_1 || this.settings.instance_count_region_threshold_eu_north_1.default, + instance_count_region_threshold_eu_south_1: settings.instance_count_region_threshold_eu_south_1 || this.settings.instance_count_region_threshold_eu_south_1.default, instance_count_region_threshold_sa_east_1: settings.instance_count_region_threshold_sa_east_1 || this.settings.instance_count_region_threshold_sa_east_1.default, instance_count_region_threshold_ap_south_1: settings.instance_count_region_threshold_ap_south_1 || this.settings.instance_count_region_threshold_ap_south_1.default, instance_count_region_threshold_ap_east_1: settings.instance_count_region_threshold_ap_east_1 || this.settings.instance_count_region_threshold_ap_east_1.default, instance_count_region_threshold_ca_central_1: settings.instance_count_region_threshold_ca_central_1 || this.settings.instance_count_region_threshold_ca_central_1.default, + instance_count_region_threshold_me_south_1: settings.instance_count_region_threshold_me_south_1 || this.settings.instance_count_region_threshold_me_south_1.default, + instance_count_region_threshold_me_central_1: settings.instance_count_region_threshold_me_central_1 || this.settings.instance_count_region_threshold_me_central_1.default, + instance_count_region_threshold_af_south_1: settings.instance_count_region_threshold_af_south_1 || this.settings.instance_count_region_threshold_af_south_1.default, instance_count_region_threshold_us_gov_west_1: settings.instance_count_region_threshold_us_gov_west_1 || this.settings.instance_count_region_threshold_us_gov_west_1.default, instance_count_region_threshold_us_gov_east_1: settings.instance_count_region_threshold_us_gov_east_1 || this.settings.instance_count_region_threshold_us_gov_east_1.default, instance_count_region_threshold_cn_north_1: settings.instance_count_region_threshold_cn_north_1 || this.settings.instance_count_region_threshold_cn_north_1.default, - instance_count_region_threshold_cn_northwest_1: settings.instance_count_region_threshold_cn_northwest_1 || this.settings.instance_count_region_threshold_cn_northwest_1.default + instance_count_region_threshold_cn_northwest_1: settings.instance_count_region_threshold_cn_northwest_1 || this.settings.instance_count_region_threshold_cn_northwest_1.default, + instance_count_region_threshold_ap_south_2: settings.instance_count_region_threshold_ap_south_2 || this.settings.instance_count_region_threshold_ap_south_2.default, + instance_count_region_threshold_eu_central_2: settings.instance_count_region_threshold_eu_central_2 || this.settings.instance_count_region_threshold_eu_central_2.default, + instance_count_region_threshold_eu_south_2: settings.instance_count_region_threshold_eu_south_2 || this.settings.instance_count_region_threshold_eu_south_2.default, + instance_count_region_threshold_ap_southeast_4: settings.instance_count_region_threshold_ap_southeast_4 || this.settings.instance_count_region_threshold_ap_southeast_4.default }; for (var c in config) { diff --git a/plugins/aws/ec2/instanceMaxCount.spec.js b/plugins/aws/ec2/instanceMaxCount.spec.js new file mode 100644 index 000000000..69d2254bc --- /dev/null +++ b/plugins/aws/ec2/instanceMaxCount.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +const instanceMaxCount = require('./instanceMaxCount'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "KeyName": "auto-scaling-test-instance", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/aws-elasticbeanstalk-ec2-role", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + "State": { + "Code": 80, + "Name": "running" + }, + }, + ], + }, +]; + +const createCache = (instances) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('instanceMaxCount', function () { + describe('run', function () { + it('should PASS if instances are within the regional and global expected count', function (done) { + const cache = createCache([describeInstances[0]]); + var settings = { + instance_count_global_threshold: 2, + instance_count_region_threshold_us_east_1: 1 + }; + + instanceMaxCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instances are not in the regional expected count', function (done) { + const cache = createCache([describeInstances[0],describeInstances[0]]); + var settings = { + instance_count_global_threshold: 2, + instance_count_region_threshold_us_east_1: 1 + }; + + instanceMaxCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instances are not in the global expected count', function (done) { + const cache = createCache([describeInstances[0],describeInstances[0]]); + var settings = { + instance_count_global_threshold: 1, + instance_count_region_threshold_us_east_1: 2 + }; + + instanceMaxCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if instances are not in the regional and global expected count', function (done) { + const cache = createCache([describeInstances[0],describeInstances[0]]); + var settings = { + instance_count_global_threshold: 1, + instance_count_region_threshold_us_east_1: 1 + }; + + instanceMaxCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + instanceMaxCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to describe instances', function (done) { + const cache = createErrorCache(); + instanceMaxCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should PASS if describe instances response not found', function (done) { + const cache = createNullCache(); + instanceMaxCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/instanceVcpusLimit.js b/plugins/aws/ec2/instanceVcpusLimit.js index 63f023dfb..ca7f49c77 100644 --- a/plugins/aws/ec2/instanceVcpusLimit.js +++ b/plugins/aws/ec2/instanceVcpusLimit.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Instance vCPU On-Demand Based Limits', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if the number of EC2 On-Demand instances is close to the regional vCPU based limit.', more_info: 'AWS limits accounts to certain numbers of resources per region. Exceeding those limits could prevent resources from launching.', link: 'https://aws.amazon.com/ec2/faqs/#EC2_On-Demand_Instance_limits', @@ -23,6 +25,7 @@ module.exports = { default: 75 } }, + realtime_triggers: ['ec2:RunInstances', 'ec2:TerminateInstances', 'servicequotas:RequestServiceQuotaIncrease'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/ec2/instanceVcpusLimit.spec.js b/plugins/aws/ec2/instanceVcpusLimit.spec.js index 94f977335..7bbe593b7 100644 --- a/plugins/aws/ec2/instanceVcpusLimit.spec.js +++ b/plugins/aws/ec2/instanceVcpusLimit.spec.js @@ -1,4 +1,3 @@ -var assert = require('assert'); var expect = require('chai').expect; var instanceLimit = require('./instanceVcpusLimit') @@ -630,75 +629,14 @@ const serviceQuotas = [ } ]; -const accountAttributes = [ - { - "AttributeName": "supported-platforms", - "AttributeValues": [ - { - "AttributeValue": "VPC" - } - ] - }, - { - "AttributeName": "vpc-max-security-groups-per-interface", - "AttributeValues": [ - { - "AttributeValue": "5" - } - ] - }, - { - "AttributeName": "max-elastic-ips", - "AttributeValues": [ - { - "AttributeValue": "5" - } - ] - }, - { - "AttributeName": "max-instances", - "AttributeValues": [ - { - "AttributeValue": "20" - } - ] - }, - { - "AttributeName": "vpc-max-elastic-ips", - "AttributeValues": [ - { - "AttributeValue": "5" - } - ] - }, - { - "AttributeName": "default-vpc", - "AttributeValues": [ - { - "AttributeValue": "vpc-3baa5252" - } - ] - } -]; - const createCache = (instances) => { return { ec2: { describeInstances: { 'us-east-1': { data: [{ - Instances: instances['us-east-1'] - }], - }, - 'eu-north-1': { - data: [{ - Instances: instances['eu-north-1'] + Instances: instances }], - }, - }, - describeAccountAttributes: { - 'eu-north-1': { - data: accountAttributes } } }, @@ -899,6 +837,33 @@ const createNullServiceQuotaCache = (instances) => { describe('instanceLimit', function () { describe('run', function () { + it('should PASS if account contains instances less than the defined warn percentage', function (done) { + const cache = createCache([oneInstance]); + instanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if account contains instances within the defined warn percentage', function (done) { + const cache = createCache([oneInstance]); + instanceLimit.run(cache, { instance_limit_percentage_warn: '1' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if elastic ip usage is more than the defined fail percentage', function (done) { + const cache = createCache([oneInstance]); + instanceLimit.run(cache, { instance_limit_percentage_fail: '1' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + it('should PASS if there are no instances', function (done) { const cache = createEmptyInstancesCache([]); instanceLimit.run(cache, {}, (err, results) => { @@ -976,202 +941,5 @@ describe('instanceLimit', function () { done(); }); }); - - it('should return a value even if empty account attributes data (use default limit), notify user', function (done) { - var instances = []; - instances['eu-north-1'] = []; - - for (i=0;i<10;i++){ - instances['eu-north-1'].push(oneInstance); - } - - const cache = createEmptyAccountAttributesCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(3); - expect(results[1].status).to.equal(0); - done(); - }); - }); - - it('should return a value even if error account attributes data (use default limit), notify user', function (done) { - var instances = []; - instances['eu-north-1'] = []; - - for (i=0;i<10;i++){ - instances['eu-north-1'].push(oneInstance); - } - - const cache = createErrorAccountAttributesCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(3); - expect(results[1].status).to.equal(0); - done(); - }); - }); - - it('should return a value even if null account attributes data (use default limit), notify user', function (done) { - var instances = []; - instances['eu-north-1'] = []; - - for (i=0;i<10;i++){ - instances['eu-north-1'].push(oneInstance); - } - - const cache = createNullAccountAttributesCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(3); - expect(results[1].status).to.equal(0); - done(); - }); - }); - - it('Low Usage: should PASS if instances usage is less than the Instance Limit Percentage Fail', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<10;i++){ - instances['eu-north-1'].push(oneInstance); - instances['us-east-1'].push(oneInstance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(0); - expect(results[1].status).to.equal(0); - done(); - }); - }); - - it('Medium Usage: should WARN if instances usage is more than the Instance Limit Percentage Warn', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<17;i++){ - instances['eu-north-1'].push(oneInstance); - } - - for (i=0;i<27;i++){ - instances['us-east-1'].push(oneInstance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(1); - expect(results[1].status).to.equal(1); - done(); - }); - }); - - it('Medium Usage: should FAIL for legacy region, PASS for vCPU region', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<20;i++){ - instances['eu-north-1'].push(oneInstance); - instances['us-east-1'].push(oneInstance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(0); - expect(results[1].status).to.equal(2); - done(); - }); - }); - - it('High Usage: should FAIL if instances usage is more than the Instance Limit Percentage Fail', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<20;i++){ - instances['eu-north-1'].push(oneInstance); - } - - for (i=0;i<34;i++){ - instances['us-east-1'].push(oneInstance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(2); - expect(results[1].status).to.equal(2); - done(); - }); - }); - - it('High Usage: should FAIL for legacy region, PASS for vCPU region', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<20;i++){ - instances['eu-north-1'].push(oneInstance); - } - - for (i=0;i<17;i++){ - instances['us-east-1'].push(oneInstance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(2); - expect(results[0].status).to.equal(0); - expect(results[1].status).to.equal(2); - done(); - }); - }); - - it('High Usage: should FAIL for all vCPU regions and PASS for legacy region', function (done) { - var instances = []; - instances['eu-north-1'] = []; - instances['us-east-1'] = []; - - for (i=0;i<10;i++){ - instances['eu-north-1'].push(oneInstance); - } - - for (i=0;i<30;i++){ - instances['us-east-1'].push(oneInstance); - } - - var instanceTemplateString = JSON.stringify(oneInstance); - var f5Instance = JSON.parse(instanceTemplateString); - f5Instance.InstanceType = 'f5.large'; - f5Instance.CpuOptions.CoreCount = 6; - f5Instance.CpuOptions.ThreadsPerCore = 9; - - for (i=0;i<5;i++){ - instances['us-east-1'].push(f5Instance); - } - - const cache = createCache(instances); - - instanceLimit.run(cache, {}, (err, results) => { - expect(results.length).to.equal(3); - expect(results[0].status).to.equal(2); - expect(results[1].status).to.equal(2); - expect(results[2].status).to.equal(0); - done(); - }); - }); }); }); \ No newline at end of file diff --git a/plugins/aws/ec2/internetGatewayInVpc.js b/plugins/aws/ec2/internetGatewayInVpc.js new file mode 100644 index 000000000..b58a0e3ac --- /dev/null +++ b/plugins/aws/ec2/internetGatewayInVpc.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Gateways In VPC', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure Internet Gateways are associated with at least one available VPC.', + more_info: 'Internet Gateways allow communication between instances in VPC and the internet. They provide a target in VPC route tables for internet-routable traffic and also perform network address translation (NAT) for instances that have been assigned public IPv4 addresses. ' + + 'Make sure they are always associated with a VPC to meet security and compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html', + recommended_action: 'Ensure Internet Gateways have VPC attached to them.', + apis: ['EC2:describeInternetGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateInternetGateway', 'ec2:DetachInternetGateway', 'ec2:AttachInternetGateway', 'ec2:DeleteInternatGateway'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeInternetGateways = helpers.addSource(cache, source, + ['ec2', 'describeInternetGateways', region]); + + if (!describeInternetGateways) return rcb(); + + if (describeInternetGateways.err || !describeInternetGateways.data) { + helpers.addResult(results, 3, + `Unable to query for Internet Gateways: ${helpers.addError(describeInternetGateways)}`, + region); + return rcb(); + } + + if (!describeInternetGateways.data.length) { + helpers.addResult(results, 0, + 'No Internet Gateways found', region); + return rcb(); + } + + describeInternetGateways.data.forEach(function(gateway){ + let resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:internet-gateway/${gateway.InternetGatewayId}`; + if (gateway.Attachments && gateway.Attachments.length) { + let attached = gateway.Attachments.find(attachment => attachment.VpcId && attachment.State && attachment.State.toUpperCase() == 'AVAILABLE'); + if (attached) { + helpers.addResult(results, 0, + 'Internet Gateway is associated with VPC', + region, resource); + } else { + helpers.addResult(results, 2, + 'Internet Gateway is not associated with VPC', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Internet Gateway is not associated with VPC', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/internetGatewayInVpc.spec.js b/plugins/aws/ec2/internetGatewayInVpc.spec.js new file mode 100644 index 000000000..4c4249e9c --- /dev/null +++ b/plugins/aws/ec2/internetGatewayInVpc.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +const internetGatewayInVpc = require('./internetGatewayInVpc'); + +const describeInternetGateways = [ + { + "Attachments": [ + { + "State": "available", + "VpcId": "vpc-99de2fe4" + } + ], + "InternetGatewayId": "igw-7f3e1a04", + "OwnerId": "111122223333", + "Tags": [] + }, + { + "Attachments": [], + "InternetGatewayId": "igw-0a82fd444d2c310d1", + "OwnerId": "111122223333", + "Tags": [ + { + "Key": "Name", + "Value": "test-64" + } + ] + } +]; + + +const createCache = (ig) => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': { + data: ig + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': { + err: { + message: 'error describing Internet Gateways' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('internetGatewayInVpc', function () { + describe('run', function () { + it('should PASS if Internet Gateway is associated with VPC', function (done) { + const cache = createCache([describeInternetGateways[0]]); + internetGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Internet Gateway is associated with VPC') + done(); + }); + }); + + it('should FAIL if Internet Gateway is not associated with VPC', function (done) { + const cache = createCache([describeInternetGateways[1]]); + internetGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Internet Gateway is not associated with VPC') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Internet Gateways', function (done) { + const cache = createErrorCache(); + internetGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Internet Gateways') + done(); + }); + }); + + it('should PASS if no Internet Gateways found', function (done) { + const cache = createCache([]); + internetGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Internet Gateways found') + done(); + }); + }); + + it('should not return anything if describe internet gateways response is not found', function (done) { + const cache = createNullCache(); + internetGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/launchWizardSecurityGroups.js b/plugins/aws/ec2/launchWizardSecurityGroups.js index 5c73babd2..008208a1a 100644 --- a/plugins/aws/ec2/launchWizardSecurityGroups.js +++ b/plugins/aws/ec2/launchWizardSecurityGroups.js @@ -4,16 +4,20 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EC2 LaunchWizard Security Groups', category: 'EC2', + domain: 'Compute', + severity: 'Low', description: 'Ensures security groups created by the EC2 launch wizard are not used', more_info: 'The EC2 launch wizard frequently creates insecure security groups that are exposed publicly. These groups should not be used and custom security groups should be created instead.', link: 'https://docs.aws.amazon.com/launchwizard/latest/userguide/launch-wizard-sap-security-groups.html', recommended_action: 'Delete the launch wizard security group and replace it with a custom security group.', apis: ['EC2:describeSecurityGroups'], + realtime_triggers: ['ec2:CreateSecurityGroup', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeSecurityGroups = helpers.addSource(cache, source, @@ -34,9 +38,9 @@ module.exports = { for (var s in describeSecurityGroups.data) { var sg = describeSecurityGroups.data[s]; - var resource = 'arn:aws:ec2:' + region + ':' + sg.OwnerId + ':security-group/' + sg.GroupId; + var resource = `arn:${awsOrGov}:ec2:` + region + ':' + sg.OwnerId + ':security-group/' + sg.GroupId; - if(!sg.GroupName) { + if (!sg.GroupName) { helpers.addResult(results, 2, 'Unable to get group name of security group', region, resource); diff --git a/plugins/aws/ec2/launchWizardSecurityGroups.spec.js b/plugins/aws/ec2/launchWizardSecurityGroups.spec.js index a2b8baa2e..82d59338e 100644 --- a/plugins/aws/ec2/launchWizardSecurityGroups.spec.js +++ b/plugins/aws/ec2/launchWizardSecurityGroups.spec.js @@ -20,7 +20,7 @@ const securityGroups = [ "UserIdGroupPairs": [] } ], - "OwnerId": "560213429563", + "OwnerId": "123456654321", "GroupId": "sg-0ff1642cae23c309a", "IpPermissionsEgress": [ { @@ -42,7 +42,7 @@ const securityGroups = [ "Description": "Allows SSh access to developer", "GroupName": "spec-test-sg", "IpPermissions": [], - "OwnerId": "560213429563", + "OwnerId": "123456654321", "GroupId": "sg-0b5f2771716acfee4", "IpPermissionsEgress": [ { diff --git a/plugins/aws/ec2/managedNatGateway.js b/plugins/aws/ec2/managedNatGateway.js new file mode 100644 index 000000000..f5b8fbd40 --- /dev/null +++ b/plugins/aws/ec2/managedNatGateway.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Managed NAT Gateway In Use', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure AWS VPC Managed NAT (Network Address Translation) Gateway service is enabled for high availability (HA).', + more_info: 'VPCs should use highly available Managed NAT Gateways in order to enable EC2 instances to connect to the internet or with other AWS components.', + link: 'https://aws.amazon.com/blogs/aws/new-managed-nat-network-address-translation-gateway-for-aws/', + recommended_action: 'Update VPCs to use Managed NAT Gateways instead of NAT instances', + apis: ['EC2:describeVpcs', 'EC2:describeNatGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateNatGateway', 'ec2:ReplaceRoute','ec2:CreateVpc', 'ec2:DeleteNatGateway', 'ec2:DeleteVpc'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var foundVpcIds = []; + + async.each(regions.ec2, function(region, rcb){ + var describeVpcs = helpers.addSource(cache, source, + ['ec2', 'describeVpcs', region]); + + if (!describeVpcs) return rcb(); + + if (describeVpcs.err || !describeVpcs.data) { + helpers.addResult(results, 3, + `Unable to query for VPCs: ${helpers.addError(describeVpcs)}`, region); + return rcb(); + } + + if (!describeVpcs.data.length) { + helpers.addResult(results, 0, 'No AWS VPCs found', region); + return rcb(); + } + + var describeNatGateways = helpers.addSource(cache, source, + ['ec2', 'describeNatGateways', region]); + + if (!describeNatGateways || describeNatGateways.err || !describeNatGateways.data) { + helpers.addResult(results, 3, + `Unable to query for NAT Gateways: ${helpers.addError(describeNatGateways)}`, region); + return rcb(); + } + + if (describeNatGateways.data.length) { + describeNatGateways.data.forEach(function(nat){ + if (nat.VpcId && !foundVpcIds.includes(nat.VpcId)) { + foundVpcIds.push(nat.VpcId); + } + }); + } + + describeVpcs.data.forEach(function(vpc){ + var resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:/vpc/${vpc.VpcId}`; + + if (foundVpcIds.includes(vpc.VpcId)) { + helpers.addResult(results, 0, + `VPC "${vpc.VpcId}" is using managed NAT Gateway`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPC "${vpc.VpcId}" is not using managed NAT Gateway`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/managedNatGateway.spec.js b/plugins/aws/ec2/managedNatGateway.spec.js new file mode 100644 index 000000000..928074f24 --- /dev/null +++ b/plugins/aws/ec2/managedNatGateway.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +const managedNatGateway = require('./managedNatGateway'); + +const describeVpcs = [ + { + "CidrBlock": "172.31.0.0/16", + "DhcpOptionsId": "dopt-3a821040", + "State": "available", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-35ef2d5a", + "CidrBlock": "172.31.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": true + }, + { + "CidrBlock": "10.0.0.0/24", + "DhcpOptionsId": "dopt-3a821040", + "State": "available", + "VpcId": "vpc-0b739af479bea9bff", + "OwnerId": "111122223333", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-017f349579cad8c30", + "CidrBlock": "10.0.0.0/24", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": false, + "Tags": [ + { + "Key": "Name", + "Value": "test-vpc" + } + ] + } +] + +const describeNatGateways = [ + { + "CreateTime": "2020-10-22T03:52:03.000Z", + "NatGatewayAddresses": [ + { + "AllocationId": "eipalloc-012a1de6c78e459ba", + "NetworkInterfaceId": "eni-0e1f6ede5831b878c", + "PrivateIp": "172.31.50.47", + "PublicIp": "52.73.207.255" + } + ], + "NatGatewayId": "nat-042a6ab635c627c61", + "State": "available", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Tags": [ + { + "Key": "Name", + "Value": "test-65" + } + ] + } +]; + + +const createCache = (vpc, nat) => { + return { + ec2: { + describeVpcs: { + 'us-east-1': { + data: vpc + }, + }, + describeNatGateways: { + 'us-east-1': { + data: nat + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpcs: { + 'us-east-1': { + err: { + message: 'error describing VPCs' + }, + }, + }, + describeNatGateways: { + 'us-east-1': { + err: { + message: 'error describing NAT Gateways' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpcs: { + 'us-east-1': null, + }, + describeNatGateways: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('managedNatGateway', function () { + describe('run', function () { + it('should PASS if VPC is using managed NAT gateway', function (done) { + const cache = createCache([describeVpcs[0]], [describeNatGateways[0]]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if VPC is not using managed NAT gateway', function (done) { + const cache = createCache([describeVpcs[1]], [describeNatGateways[0]]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPCs found', function (done) { + const cache = createCache([]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe VPCs', function (done) { + const cache = createErrorCache(); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe VPCs response not found', function (done) { + const cache = createNullCache(); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/multipleSubnets.js b/plugins/aws/ec2/multipleSubnets.js index dbbd36fbe..4e6d68e47 100644 --- a/plugins/aws/ec2/multipleSubnets.js +++ b/plugins/aws/ec2/multipleSubnets.js @@ -4,17 +4,24 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'VPC Multiple Subnets', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that VPCs have multiple subnets to provide a layered architecture', more_info: 'VPCs should be designed to have separate public and private subnets, ideally across availability zones, enabling a DMZ-style architecture.', link: 'https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Subnets.html#SubnetSecurity', recommended_action: 'Create at least two subnets in each VPC, utilizing one for public traffic and the other for private traffic.', - apis: ['EC2:describeVpcs', 'EC2:describeSubnets'], + apis: ['EC2:describeVpcs', 'EC2:describeSubnets', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpc', 'ec2:CreateSubnet', 'ec2:DeleteSubnet'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + async.each(regions.ec2, function(region, rcb){ var describeVpcs = helpers.addSource(cache, source, ['ec2', 'describeVpcs', region]); @@ -48,24 +55,26 @@ module.exports = { var describeSubnets = helpers.addSource(cache, source, ['ec2', 'describeSubnets', region, vpcId]); - if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + if (!describeSubnets || describeSubnets.err || !describeSubnets.data || !describeSubnets.data.Subnets) { helpers.addResult(results, 3, 'Unable to query for subnets in VPC: ' + helpers.addError(describeSubnets), region, vpcId); return rcb(); } + var resource = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':vpc/' + vpcId; + if (describeSubnets.data.Subnets.length > 1) { helpers.addResult(results, 0, 'There are ' + describeSubnets.data.Subnets.length + ' subnets used in one VPC.', - region, vpcId); + region, resource); } else if (describeSubnets.data.Subnets.length === 1) { helpers.addResult(results, 2, 'Only one subnet (' + describeSubnets.data.Subnets[0].SubnetId + ') in one VPC is used.', - region, vpcId); + region, resource); } else { helpers.addResult(results, 0, 'The VPC does not contain any subnets', - region, vpcId); + region, resource); } rcb(); diff --git a/plugins/aws/ec2/multipleSubnets.spec.js b/plugins/aws/ec2/multipleSubnets.spec.js new file mode 100644 index 000000000..8165d8f4b --- /dev/null +++ b/plugins/aws/ec2/multipleSubnets.spec.js @@ -0,0 +1,243 @@ +var expect = require('chai').expect; +const multipleSubnets = require('./multipleSubnets'); + +const describeVpcs = [ + { + "CidrBlock": "172.31.0.0/16", + "DhcpOptionsId": "dopt-3a821040", + "State": "available", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-35ef2d5a", + "CidrBlock": "172.31.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": true + }, + { + "CidrBlock": "172.31.0.0/16", + "DhcpOptionsId": "dopt-3a821040", + "State": "available", + "OwnerId": "111122223333", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-35ef2d5a", + "CidrBlock": "172.31.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": true + } +]; + +const describeSubnets = [ + { + "Subnets": [ + { + "AvailabilityZone": "us-east-1b", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4088, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4088, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, + ] + }, + { + "Subnets": [ + { + "AvailabilityZone": "us-east-1b", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4088, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, + ] + }, + { + "Subnets": [] + } +]; + + +const createCache = (vpcs, subnets) => { + if (vpcs && vpcs.length && vpcs[0].VpcId) var vpcId = vpcs[0].VpcId; + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + data: vpcs + }, + }, + describeSubnets: { + 'us-east-1': { + [vpcId]: { + data: subnets + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + err: { + message: 'error describing vpcs' + }, + }, + }, + describeSubnets: { + 'us-east-1': { + err: { + message: 'error describing subnets' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': null, + }, + describeSubnets: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('multipleSubnets', function () { + describe('run', function () { + it('should PASS if multiple subnets used in one VPC', function (done) { + const cache = createCache([describeVpcs[0]],describeSubnets[0]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if only one subnets in one VPC is used', function (done) { + const cache = createCache([describeVpcs[0]],describeSubnets[1]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if the VPC does not contain any subnets', function (done) { + const cache = createCache([describeVpcs[0]],describeSubnets[2]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no VPCs found', function (done) { + const cache = createCache([]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if multiple VPCs are used', function (done) { + const cache = createCache([describeVpcs[0],describeVpcs[0]]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON if vpcId is not found', function (done) { + const cache = createCache([describeVpcs[1]]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNWON if unable to describe VPCs', function (done) { + const cache = createErrorCache(); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe subnets', function (done) { + const cache = createCache([describeVpcs[0]]); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe VPCs response not found', function (done) { + const cache = createNullCache(); + multipleSubnets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/natMultiAz.js b/plugins/aws/ec2/natMultiAz.js index 907604d90..55401a244 100644 --- a/plugins/aws/ec2/natMultiAz.js +++ b/plugins/aws/ec2/natMultiAz.js @@ -4,16 +4,20 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'NAT Multiple AZ', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensures managed NAT instances exist in at least 2 AZs for availability purposes', more_info: 'Creating NAT instances in a single AZ creates a single point of failure for all systems in the VPC. All managed NAT instances should be created in multiple AZs to ensure proper failover.', link: 'http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/vpc-nat-gateway.html', recommended_action: 'Launch managed NAT instances in multiple AZs.', apis: ['EC2:describeVpcs', 'EC2:describeNatGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateNatGateway', 'ec2:DeleteNatGateway'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); @@ -69,10 +73,10 @@ module.exports = { if (numSubnets) { // arn:aws:ec2:region:account-id:vpc/vpc-id - var vpcArn = 'arn:aws:ec2:' + region + + var vpcArn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':vpc/' + v; - if (numSubnets.length === 1) { + if (numSubnets === 1) { helpers.addResult(results, 1, 'VPC is using NAT gateways in only 1 subnet', region, vpcArn); } else { diff --git a/plugins/aws/ec2/natMultiAz.spec.js b/plugins/aws/ec2/natMultiAz.spec.js new file mode 100644 index 000000000..9e526561a --- /dev/null +++ b/plugins/aws/ec2/natMultiAz.spec.js @@ -0,0 +1,186 @@ +var expect = require('chai').expect; +const natMultiAz = require('./natMultiAz'); + +const describeVpcs = [ + { + "CidrBlock": "172.31.0.0/16", + "DhcpOptionsId": "dopt-3a821040", + "State": "available", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "InstanceTenancy": "default", + "CidrBlockAssociationSet": [ + { + "AssociationId": "vpc-cidr-assoc-35ef2d5a", + "CidrBlock": "172.31.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "IsDefault": true + } +]; + +const describeNatGateways = [ + { + "CreateTime": "2020-11-12T19:59:52.000Z", + "NatGatewayAddresses": [ + { + "AllocationId": "eipalloc-02fbee66ba40a5920", + "NetworkInterfaceId": "eni-06bb49bfd636abb8e", + "PrivateIp": "172.31.60.19" + } + ], + "NatGatewayId": "nat-07895bffa88a7af7c", + "State": "pending", + "SubnetId": "subnet-6a8b635b", + "VpcId": "vpc-99de2fe4", + "Tags": [ + { + "Key": "Name", + "Value": "test-spec" + } + ] + }, + { + "CreateTime": "2020-11-12T19:59:52.000Z", + "NatGatewayAddresses": [ + { + "AllocationId": "eipalloc-02fbee66ba40a5920", + "NetworkInterfaceId": "eni-06bb49bfd636abb8e", + "PrivateIp": "172.31.60.19" + } + ], + "NatGatewayId": "nat-07895bffa88a7af7c", + "State": "pending", + "SubnetId": "subnet-6a8b635c", + "VpcId": "vpc-99de2fe4", + "Tags": [ + { + "Key": "Name", + "Value": "test-spec" + } + ] + }, +]; + + +const createCache = (vpcs, natGateways) => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + data: vpcs + }, + }, + describeNatGateways: { + 'us-east-1': { + data: natGateways + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': { + err: { + message: 'error describing vpcs' + }, + }, + }, + describeNatGateways: { + 'us-east-1': { + err: { + message: 'error describing NAT gateways' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeVpcs: { + 'us-east-1': null, + }, + describeNatGateways: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('natMultiAz', function () { + describe('run', function () { + it('should PASS if VPC is using NAT gateways in multiple subnet', function (done) { + const cache = createCache([describeVpcs[0]],[describeNatGateways[0],describeNatGateways[1]]); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if VPC is using NAT gateways in only 1 subnet', function (done) { + const cache = createCache([describeVpcs[0]],[describeNatGateways[0]]); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no VPC with NAT gateways found', function (done) { + const cache = createCache([describeVpcs[0]],[]); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no VPCs found', function (done) { + const cache = createCache([]); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON if unable to describe VPCs', function (done) { + const cache = createErrorCache(); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe NAT gateways', function (done) { + const cache = createCache([describeVpcs[0]]); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe VPCs response not found', function (done) { + const cache = createNullCache(); + natMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/networkAclHasTags.js b/plugins/aws/ec2/networkAclHasTags.js new file mode 100644 index 000000000..65889328d --- /dev/null +++ b/plugins/aws/ec2/networkAclHasTags.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Network ACL has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Amazon Network ACLs have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Network ACL and add tags.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html', + apis: ['EC2:describeNetworkAcls', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateNetworkAcl', 'ec2:AddTags', 'ec2:DeleteTags', 'ec2:DeleteNetworkAcl'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeNetworkAcls = helpers.addSource(cache, source, + ['ec2', 'describeNetworkAcls', region]); + + if (!describeNetworkAcls) return rcb(); + + if (describeNetworkAcls.err || !describeNetworkAcls.data) { + helpers.addResult(results, 3, + `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); + return rcb(); + } + + if (!describeNetworkAcls.data.length) { + helpers.addResult(results, 0, 'No Network ACLs found', region); + return rcb(); + } + for (let nAcl of describeNetworkAcls.data) { + if (!nAcl.NetworkAclId) continue; + + var resourceARN = `arn:${awsOrGov}:ec2:${region}:${accountId}:network-acl/${nAcl.NetworkAclId}`; + + if (!nAcl.Tags || !nAcl.Tags.length) { + helpers.addResult(results, 2, 'Network ACL does not have tags', region, resourceARN); + } else { + helpers.addResult(results, 0, 'Network ACL has tags', region, resourceARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/networkAclHasTags.spec.js b/plugins/aws/ec2/networkAclHasTags.spec.js new file mode 100644 index 000000000..7d10bfcfa --- /dev/null +++ b/plugins/aws/ec2/networkAclHasTags.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const networkAclHasTags = require('./networkAclHasTags'); + +const describeNetworkAcls = [ + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + "PortRange": { + "From": 0, + "To": 200 + }, + }, + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + }, + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + }, + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [{key: "value"}], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + } +]; + +const createCache = (networkAcls) => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + data: networkAcls + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + err: { + message: 'error describing Network ACLs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('networkAclHasTags', function () { + describe('run', function () { + it('should FAIL if network ACL does not have tags.', function (done) { + const cache = createCache([describeNetworkAcls[0]]); + + networkAclHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Network ACL does not have tags') + done(); + }); + }); + + it('should PASS if network ACL have tags', function (done) { + const cache = createCache([describeNetworkAcls[1]]); + + networkAclHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Network ACL has tags') + done(); + }); + }); + + it('should PASS if no network ACLs found', function (done) { + const cache = createCache([]); + + networkAclHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Network ACLs found') + done(); + }); + }); + + it('should UNKNOWN if unable to describe network ACLs', function (done) { + const cache = createErrorCache(); + + networkAclHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Network ACLs:') + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/networkAclInboundTraffic.js b/plugins/aws/ec2/networkAclInboundTraffic.js new file mode 100644 index 000000000..2d4c2e306 --- /dev/null +++ b/plugins/aws/ec2/networkAclInboundTraffic.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unrestricted Network ACL Inbound Traffic', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensures that no Amazon Network ACL allows inbound/ingress traffic to remote administration ports.', + more_info: 'Amazon Network ACL should not allow inbound/ingress traffic to remote administration ports to avoid unauthorized access at the subnet level.', + recommended_action: 'Update Network ACL to allow inbound/ingress traffic to specific port ranges only', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html', + apis: ['EC2:describeNetworkAcls', 'STS:getCallerIdentity'], + compliance: { + cis1: '5.1 Ensure no Network ACLs allow ingress from 0.0.0.0/0 to remote server administration ports', + }, + realtime_triggers: ['ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:DeleteNetworkAcl'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeNetworkAcls = helpers.addSource(cache, source, + ['ec2', 'describeNetworkAcls', region]); + + if (!describeNetworkAcls) return rcb(); + + if (describeNetworkAcls.err || !describeNetworkAcls.data) { + helpers.addResult(results, 3, + `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); + return rcb(); + } + + if (!describeNetworkAcls.data.length) { + helpers.addResult(results, 0, + 'No Network ACLs found', region); + return rcb(); + } + + describeNetworkAcls.data.forEach(acl =>{ + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:network-acl/${acl.NetworkAclId}`; + var unrestrictedAcl = false; + + if (acl.Entries && acl.Entries.length) { + for (var entry of acl.Entries) { + if (!entry.Egress && disAllowedPorRange(entry.PortRange) && entry.RuleAction.toUpperCase() === 'ALLOW' && entry.CidrBlock==='0.0.0.0/0') { + unrestrictedAcl = true; + break; + } + } + } + + if (!unrestrictedAcl) { + helpers.addResult(results, 0, + `Network ACL "${acl.NetworkAclId}" does not allow unrestricted access`, + region, resource); + } else { + helpers.addResult(results, 2, + `Network ACL "${acl.NetworkAclId}" allows unrestricted access`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + +function disAllowedPorRange(range) { + const allowedPorts = [22, 3389, 80, 443, 53]; + + if (!range) return true; + + for (const port of allowedPorts) { + if (port >= range.From && port <= range.To) return true; + } + + return false; +} diff --git a/plugins/aws/ec2/networkAclInboundTraffic.spec.js b/plugins/aws/ec2/networkAclInboundTraffic.spec.js new file mode 100644 index 000000000..08fb7bc07 --- /dev/null +++ b/plugins/aws/ec2/networkAclInboundTraffic.spec.js @@ -0,0 +1,189 @@ +var expect = require('chai').expect; +const networkAclInboundTraffic = require('./networkAclInboundTraffic'); + +const describeNetworkAcls = [ + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": false, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + "PortRange": { + "From": 0, //No open remote admin port in range 0-10 + "To": 10 + }, + }, + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + }, + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": false, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + "PortRange": { + "From": 0, // Fail because of SSH + "To": 100 + }, + }, + + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + }, + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, //Pass because egress + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + "PortRange": { + "From": 0, + "To": 100 + }, + }, + + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + } +]; + +const createCache = (networkAcls) => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + data: networkAcls + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + err: { + message: 'error describing Network ACLs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('networkAclInboundTraffic', function () { + describe('run', function () { + it('should PASS if network ACL does not allow unrestricted access', function (done) { + const cache = createCache([describeNetworkAcls[0]]); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if network ACL allows unrestricted access egress is disabled', function (done) { + const cache = createCache([describeNetworkAcls[1]]); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if network ACL has no ingress rules', function (done) { + const cache = createCache([describeNetworkAcls[2]]); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no network ACLs found', function (done) { + const cache = createCache([]); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe network ACLs', function (done) { + const cache = createErrorCache(); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe network ACLs response is not found', function (done) { + const cache = createNullCache(); + + networkAclInboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/networkAclOutboundTraffic.js b/plugins/aws/ec2/networkAclOutboundTraffic.js new file mode 100644 index 000000000..2fca2b311 --- /dev/null +++ b/plugins/aws/ec2/networkAclOutboundTraffic.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unrestricted Network ACL Outbound Traffic', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that no Amazon Network ACL allows outbound/egress traffic to all ports.', + more_info: 'Amazon Network ACL should not allow outbound/egress traffic to all ports to avoid unauthorized access at the subnet level.', + recommended_action: 'Update Network ACL to allow outbound/egress traffic to specific port ranges only', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html', + apis: ['EC2:describeNetworkAcls', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:DeleteNetworkAcl'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeNetworkAcls = helpers.addSource(cache, source, + ['ec2', 'describeNetworkAcls', region]); + + if (!describeNetworkAcls) return rcb(); + + if (describeNetworkAcls.err || !describeNetworkAcls.data) { + helpers.addResult(results, 3, + `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); + return rcb(); + } + + if (!describeNetworkAcls.data.length) { + helpers.addResult(results, 0, + 'No Network ACLs found', region); + return rcb(); + } + + describeNetworkAcls.data.forEach(acl =>{ + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:network-acl/${acl.NetworkAclId}`; + var unrestrictedAcl = false; + + if (acl.Entries && acl.Entries.length) { + for (var entry of acl.Entries) { + if (entry.Egress && entry.RuleAction.toUpperCase() === 'ALLOW' && !entry.PortRange) { + unrestrictedAcl = true; + break; + } + } + } + + if (!unrestrictedAcl) { + helpers.addResult(results, 0, + `Network ACL "${acl.NetworkAclId}" does not allow unrestricted access`, + region, resource); + } else { + helpers.addResult(results, 2, + `Network ACL "${acl.NetworkAclId}" allows unrestricted access`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/networkAclOutboundTraffic.spec.js b/plugins/aws/ec2/networkAclOutboundTraffic.spec.js new file mode 100644 index 000000000..2b144805f --- /dev/null +++ b/plugins/aws/ec2/networkAclOutboundTraffic.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +const networkAclOutboundTraffic = require('./networkAclOutboundTraffic'); + +const describeNetworkAcls = [ + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + "PortRange": { + "From": 0, + "To": 200 + }, + }, + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + }, + { + "Associations": [ + { + "NetworkAclAssociationId": "aclassoc-3ddddf7f", + "NetworkAclId": "acl-65603818", + "SubnetId": "subnet-06aa0f60" + } + ], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, + "Protocol": "-1", + "RuleAction": "allow", + "RuleNumber": 100, + }, + ], + "IsDefault": true, + "NetworkAclId": "acl-65603818", + "Tags": [], + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333" + } +]; + +const createCache = (networkAcls) => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + data: networkAcls + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': { + err: { + message: 'error describing Network ACLs' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeNetworkAcls: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('networkAclOutboundTraffic', function () { + describe('run', function () { + it('should PASS if network ACL does not allow unrestricted access', function (done) { + const cache = createCache([describeNetworkAcls[0]]); + + networkAclOutboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if network ACL allows unrestricted access egress is disabled', function (done) { + const cache = createCache([describeNetworkAcls[1]]); + + networkAclOutboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no network ACLs found', function (done) { + const cache = createCache([]); + + networkAclOutboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe network ACLs', function (done) { + const cache = createErrorCache(); + + networkAclOutboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe network ACLs response is not found', function (done) { + const cache = createNullCache(); + + networkAclOutboundTraffic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/oldAmi.js b/plugins/aws/ec2/oldAmi.js new file mode 100644 index 000000000..64df9a9ce --- /dev/null +++ b/plugins/aws/ec2/oldAmi.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Old Amazon Machine Images', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Amazon Machine Images (AMIs) are not older than a specified number of days.', + more_info: 'Amazon Machine Images that are too old may contain outdated software, security vulnerabilities, or deprecated configurations. Regularly updating and replacing old AMIs helps maintain security and operational efficiency.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html', + recommended_action: 'Review and replace AMIs that are older than the specified threshold with newer versions.', + apis: ['EC2:describeImages'], + settings: { + ami_age_fail: { + name: 'AMI Age Fail', + description: 'Return a failing result when AMI exceeds this number of days', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 90 + } + }, + realtime_triggers: ['ec2:CreateImage', 'ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + ami_age_fail: parseInt(settings.ami_age_fail || this.settings.ami_age_fail.default), + }; + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + 'Unable to query for AMIs: ' + helpers.addError(describeImages), region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, 'No AMIs found', region); + return rcb(); + } + + var now = new Date(); + + for (var ami of describeImages.data) { + if (!ami.ImageId) continue; + + const arn = 'arn:' + awsOrGov + ':ec2:' + region + '::image/' + ami.ImageId; + + if (!ami.CreationDate) { + helpers.addResult(results, 3, + 'AMI does not have a creation date', region, arn); + continue; + } + + var creationDate = new Date(ami.CreationDate); + var difference = helpers.daysBetween(creationDate, now); + + if (difference > config.ami_age_fail) { + helpers.addResult(results, 2, + `AMI is ${Math.floor(difference)} days old`, region, arn); + } else { + helpers.addResult(results, 0, + `AMI is ${Math.floor(difference)} days old`, region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/oldAmi.spec.js b/plugins/aws/ec2/oldAmi.spec.js new file mode 100644 index 000000000..330191929 --- /dev/null +++ b/plugins/aws/ec2/oldAmi.spec.js @@ -0,0 +1,108 @@ +var expect = require('chai').expect; +const oldAmi = require('./oldAmi'); + +const describeImages = [ + { + ImageId: 'ami-046b09f5340dfd8gb', + CreationDate: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString() // 100 days old + }, + { + ImageId: 'ami-046b09f5340dfd8gc', + CreationDate: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString() // 70 days old + }, + { + ImageId: 'ami-046b09f5340dfd8gd', + CreationDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days old + }, + { + ImageId: 'ami-046b09f5340dfd8ge', + // No CreationDate + } +]; + +const createCache = (images) => { + return { + ec2: { + describeImages: { + 'us-east-1': { + data: images + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing AMIs' + } + }, + }, + }, + }; +}; + +describe('oldAmi', function () { + describe('run', function () { + + it('should return UNKNOWN if unable to query for AMIs', function (done) { + const cache = createErrorCache(); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for AMIs'); + done(); + }); + }); + + it('should return PASS if no AMIs found', function (done) { + const cache = createCache([]); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No AMIs found'); + done(); + }); + }); + + it('should return FAIL if AMI is older than fail threshold (90 days)', function (done) { + const cache = createCache([describeImages[0]]); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('100 days old'); + done(); + }); + }); + + it('should return PASS if AMI is newer than warn threshold', function (done) { + const cache = createCache([describeImages[2]]); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('30 days old'); + done(); + }); + }); + + it('should return UNKNOWN if AMI does not have a creation date', function (done) { + const cache = createCache([describeImages[3]]); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI does not have a creation date'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ec2/openAllPortsProtocols.js b/plugins/aws/ec2/openAllPortsProtocols.js index 00b975777..ae9cd7ddf 100644 --- a/plugins/aws/ec2/openAllPortsProtocols.js +++ b/plugins/aws/ec2/openAllPortsProtocols.js @@ -4,11 +4,27 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open All Ports Protocols', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if security group has all ports or protocols open to the public', more_info: 'Security groups should be created on a per-service basis and avoid allowing all ports or protocols.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Modify the security group to specify a specific port and protocol to allow.', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, compliance: { hipaa: 'HIPAA requires strict access controls to networks and services ' + 'processing sensitive data. Security groups are the built-in ' + @@ -18,11 +34,21 @@ module.exports = { 'Security groups should be properly secured to prevent access to ' + 'backend services.' }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeSecurityGroups = helpers.addSource(cache, source, @@ -41,12 +67,14 @@ module.exports = { return rcb(); } - var found = false; var groups = describeSecurityGroups.data; + if (config.ec2_skip_unused_groups) { + var usedGroups = helpers.getUsedSecurityGroups(cache, results, region, rcb); + } for (var g in groups) { var strings = []; - var resource = 'arn:aws:ec2:' + region + ':' + + var resource = `arn:${awsOrGov}:ec2:` + region + ':' + groups[g].OwnerId + ':security-group/' + groups[g].GroupId; @@ -60,13 +88,11 @@ module.exports = { if (!permission.FromPort && (!permission.ToPort || permission.ToPort === 65535)) { var string = 'all ports open to 0.0.0.0/0'; if (strings.indexOf(string) === -1) strings.push(string); - found = true; } if (permission.IpProtocol === '-1') { var stringO = 'all protocols open to 0.0.0.0/0'; if (strings.indexOf(stringO) === -1) strings.push(stringO); - found = true; } } } @@ -78,31 +104,38 @@ module.exports = { if (!permission.FromPort && (!permission.ToPort || permission.ToPort === 65535)) { var stringV6 = 'all ports open to ::/0'; if (strings.indexOf(stringV6) === -1) strings.push(stringV6); - found = true; } if (permission.IpProtocol === '-1') { var stringP = 'all protocols open to ::/0'; if (strings.indexOf(stringP) === -1) strings.push(stringP); - found = true; } } } } if (strings.length) { - helpers.addResult(results, 2, - 'Security group: ' + groups[g].GroupId + - ' (' + groups[g].GroupName + - ') has ' + strings.join(' and '), region, - resource); + if (config.ec2_skip_unused_groups && groups[g].GroupId && usedGroups && + usedGroups.length && !usedGroups.includes(groups[g].GroupId)) { + helpers.addResult(results, 1, `Security Group: ${groups[g].GroupId} is not in use`, + region, resource); + } else if ( config.check_network_interface) { + var resultString = `Security group:${groups[g].GroupId} (${groups[g].GroupName}) has ${strings.join(' and ')}`; + helpers.checkNetworkInterface(groups[g].GroupId, groups[g].GroupName, resultString, region, results, resource, cache); + } else { + helpers.addResult(results, 2, + 'Security group: ' + groups[g].GroupId + + ' (' + groups[g].GroupName + + ') has ' + strings.join(' and '), region, + resource); + } + } else { + helpers.addResult(results, 0, + `Security group: ${groups[g].GroupId} (${groups[g].GroupName}) does not have all ports or protocols open to the public`, + region, resource); } } - if (!found) { - helpers.addResult(results, 0, 'No public open ports found', region); - } - rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/ec2/openAllPortsProtocols.spec.js b/plugins/aws/ec2/openAllPortsProtocols.spec.js new file mode 100644 index 000000000..791ce54ae --- /dev/null +++ b/plugins/aws/ec2/openAllPortsProtocols.spec.js @@ -0,0 +1,348 @@ +var expect = require('chai').expect; +const openAllPortsProtocols = require('./openAllPortsProtocols'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [ + { + "IpProtocol": "-1", + "IpRanges": [], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [ + { + "GroupId": "sg-aa941691", + "UserId": "111122223333" + } + ] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissions": [{ + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg2", + "IpPermissions": [{ + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-001", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-001639e564442dfec" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + + +const createCache = (groups, interfaces, functions) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + data: interfaces + }, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + data: functions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: { + message: 'error describing network interfaces' + }, + }, + } + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing lambda functions' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + } + } + }; +}; + +describe('openAllPortsProtocols', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has all ports open to 0.0.0.0/0 and all ports open to ::/0', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openAllPortsProtocols.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openAllPortsProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createErrorCache(); + openAllPortsProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openAllPortsProtocols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsProtocols.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openAllPortsProtocolsEgress.js b/plugins/aws/ec2/openAllPortsProtocolsEgress.js new file mode 100644 index 000000000..9efaf9fd7 --- /dev/null +++ b/plugins/aws/ec2/openAllPortsProtocolsEgress.js @@ -0,0 +1,125 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open All Ports Protocols Egress', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if security group has all outbound ports or protocols open to the public', + more_info: 'Security groups should be created on a per-service basis and avoid allowing all ports or protocols in order to implement the Principle of Least Privilege (POLP) and reduce the attack surface.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Modify the security group tp restrict access to only those IP addresses and/or IP ranges that require it.', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + if (config.ec2_skip_unused_groups) { + var usedGroups = helpers.getUsedSecurityGroups(cache, results, region, rcb); + } + + let strings = []; + for (let group of describeSecurityGroups.data){ + strings = []; + let resource = `arn:${awsOrGov}:ec2:` + region + ':' + group.OwnerId + ':security-group/' + group.GroupId; + for (let permission of group.IpPermissionsEgress){ + for (let range of permission.IpRanges) { + if (range.CidrIp === '0.0.0.0/0') { + if (!permission.FromPort && !permission.ToPort) { + var string = 'all ports open to 0.0.0.0/0'; + if (strings.indexOf(string) === -1) strings.push(string); + } + + if (permission.IpProtocol === '-1') { + var stringO = 'all protocols open to 0.0.0.0/0'; + if (strings.indexOf(stringO) === -1) strings.push(stringO); + } + } + } + for (var rangeV6 of permission.Ipv6Ranges) { + + if (rangeV6.CidrIpv6 === '::/0') { + if (!permission.FromPort && !permission.ToPort ) { + var stringV6 = 'all ports open to ::/0'; + if (strings.indexOf(stringV6) === -1) strings.push(stringV6); + } + + if (permission.IpProtocol === '-1') { + var stringP = 'all protocols open to ::/0'; + if (strings.indexOf(stringP) === -1) strings.push(stringP); + } + } + } + } + if (strings.length) { + if (config.ec2_skip_unused_groups && group.GroupId && usedGroups && + usedGroups.length && !usedGroups.includes(group.GroupId)) { + helpers.addResult(results, 1, `Security Group: ${group.GroupId} is not in use`, + region, resource); + } else if ( config.check_network_interface) { + var resultString = `Security group:${group.GroupId} (${group.GroupName}) has ${strings.join(' and ')}`; + helpers.checkNetworkInterface(group.GroupId, group.GroupName, resultString, region, results, resource, cache); + } else { + helpers.addResult(results, 2, + 'Security group: ' + group.GroupId + + ' (' + group.GroupName + + ') has ' + strings.join(' and '), region, + resource); + } + } else { + helpers.addResult(results, 0, + `Security group: ${group.GroupId} (${group.GroupName}) does not have all ports or protocols open to the public`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/openAllPortsProtocolsEgress.spec.js b/plugins/aws/ec2/openAllPortsProtocolsEgress.spec.js new file mode 100644 index 000000000..07815e77c --- /dev/null +++ b/plugins/aws/ec2/openAllPortsProtocolsEgress.spec.js @@ -0,0 +1,347 @@ +var expect = require('chai').expect; +const openAllPortsEgress = require('./openAllPortsProtocolsEgress'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [ + { + "GroupId": "sg-aa941691", + "UserId": "111122223333" + } + ] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissions": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissionsEgress": [{ + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-001639e564442dfec", + "IpPermissions": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg2", + "IpPermissionsEgress": [{ + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-001639e5", + "IpPermissions": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-001639e564442dfec" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + + +const createCache = (groups, interfaces, functions) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + data: interfaces + }, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + data: functions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: { + message: 'error describing network interfaces' + }, + }, + } + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing lambda functions' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + } + } + }; +}; + +describe('openAllPortsEgress', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsEgress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has all ports open to 0.0.0.0/0 and all ports open to ::/0', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsEgress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openAllPortsEgress.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openAllPortsEgress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createErrorCache(); + openAllPortsEgress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openAllPortsEgress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openAllPortsEgress.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/openCIFS.js b/plugins/aws/ec2/openCIFS.js index cfb1b582c..45272ace8 100644 --- a/plugins/aws/ec2/openCIFS.js +++ b/plugins/aws/ec2/openCIFS.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open CIFS', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if UDP port 445 for CIFS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as CIFS should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict UDP port 445 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openCIFSReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCIFSReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openCIFS.spec.js b/plugins/aws/ec2/openCIFS.spec.js new file mode 100644 index 000000000..5acf3ee44 --- /dev/null +++ b/plugins/aws/ec2/openCIFS.spec.js @@ -0,0 +1,267 @@ +var expect = require('chai').expect; +const openCIFS = require('./openCIFS'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "SSH", + "GroupName": "SSH-Access", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "udp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-043778823f73431a7", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openCIFS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], []); + openCIFS.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if security group has CIFS UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCIFS.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openCassandraClient.js b/plugins/aws/ec2/openCassandraClient.js new file mode 100644 index 000000000..24c706b52 --- /dev/null +++ b/plugins/aws/ec2/openCassandraClient.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Cassandra Client', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 9042 for Cassandra Client is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Client should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 9042 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openCassandraClientReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCassandraClientReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [9042] + }; + + var service = 'Cassandra Client'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openCassandraClient'; + var protocol = 'tcp'; + var port = 9042; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; diff --git a/plugins/aws/ec2/openCassandraClient.spec.js b/plugins/aws/ec2/openCassandraClient.spec.js new file mode 100644 index 000000000..aa2b75777 --- /dev/null +++ b/plugins/aws/ec2/openCassandraClient.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openCassandraClient = require('./openCassandraClient'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openCassandraClient', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Client TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openCassandraClient.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraClient.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openCassandraInternode.js b/plugins/aws/ec2/openCassandraInternode.js new file mode 100644 index 000000000..6621a171e --- /dev/null +++ b/plugins/aws/ec2/openCassandraInternode.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Cassandra Internode', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 7000 for Cassandra Internode is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Internode should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 7001 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openCassandraInternodeReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCassandraInternodeReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [7000] + }; + + var service = 'Cassandra Internode'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openCassandraInternode'; + var protocol = 'tcp'; + var port = 7000; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; diff --git a/plugins/aws/ec2/openCassandraInternode.spec.js b/plugins/aws/ec2/openCassandraInternode.spec.js new file mode 100644 index 000000000..3f0116b4c --- /dev/null +++ b/plugins/aws/ec2/openCassandraInternode.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openCassandraInternode = require('./openCassandraInternode'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openCassandraInternode', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Internode TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openCassandraInternode.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraInternode.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openCassandraMonitoring.js b/plugins/aws/ec2/openCassandraMonitoring.js new file mode 100644 index 000000000..539f69a67 --- /dev/null +++ b/plugins/aws/ec2/openCassandraMonitoring.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Cassandra Monitoring', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 7199 for Cassandra Monitoring is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Client should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 7199 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openCassandraMonitoringReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCassandraMonitoringReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress','ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [7199] + }; + + var service = 'Cassandra Monitoring'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openCassandraMonitoring'; + var protocol = 'tcp'; + var port = 7199; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; diff --git a/plugins/aws/ec2/openCassandraMonitoring.spec.js b/plugins/aws/ec2/openCassandraMonitoring.spec.js new file mode 100644 index 000000000..d6cc86d86 --- /dev/null +++ b/plugins/aws/ec2/openCassandraMonitoring.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openCassandraMonitoring = require('./openCassandraMonitoring'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openCassandraMonitoring', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Monitoring TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openCassandraMonitoring.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraMonitoring.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openCassandraThrift.js b/plugins/aws/ec2/openCassandraThrift.js new file mode 100644 index 000000000..b17595bf4 --- /dev/null +++ b/plugins/aws/ec2/openCassandraThrift.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Cassandra Thrift', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 9160 for Cassandra Thrift is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Client should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 9160 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openCassandraThriftReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCassandraThriftReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress','ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [9160] + }; + + var service = 'Cassandra Thrift'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openCassandraThrift'; + var protocol = 'tcp'; + var port = 9160; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; diff --git a/plugins/aws/ec2/openCassandraThrift.spec.js b/plugins/aws/ec2/openCassandraThrift.spec.js new file mode 100644 index 000000000..465fabd1f --- /dev/null +++ b/plugins/aws/ec2/openCassandraThrift.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openCassandraThrift = require('./openCassandraThrift'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openCassandraThrift', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Thrift TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openCassandraThrift.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCassandraThrift.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openCustomPorts.js b/plugins/aws/ec2/openCustomPorts.js new file mode 100644 index 000000000..997379cbe --- /dev/null +++ b/plugins/aws/ec2/openCustomPorts.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Custom Ports', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensure that defined custom ports are not open to public.', + more_info: 'Security groups should restrict access to ports from known networks.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html', + recommended_action: 'Modify the security group to ensure the defined custom ports are not exposed publicly', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + restricted_open_ports: { + name: 'Restricted Open Ports', + description: 'Comma separated list of ports/port-ranges that should be restricted and not publicly open. Example: tcp:80,tcp:443,tcp:80-443', + regex: '[a-zA-Z0-9,:]', + default: '' + }, + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var restricted_open_ports = settings.restricted_open_ports || this.settings.restricted_open_ports.default; + + if (!restricted_open_ports.length) return callback(); + + restricted_open_ports = restricted_open_ports.split(','); + + var ports = {}; + restricted_open_ports.forEach(port => { + var [protocol, portNo] = port.split(':'); + if (ports[protocol]) { + ports[protocol].push(portNo); + } else { + ports[protocol] = [portNo]; + } + }); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + `Unable to query for security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, 'custom', region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/openCustomPorts.spec.js b/plugins/aws/ec2/openCustomPorts.spec.js new file mode 100644 index 000000000..d47c29327 --- /dev/null +++ b/plugins/aws/ec2/openCustomPorts.spec.js @@ -0,0 +1,350 @@ +var expect = require('chai').expect; +const openCustomPorts = require('./openCustomPorts'); + +const describeSecurityGroups = [ + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissions": [{ + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 160, + "UserIdGroupPairs": [] + }], + "OwnerId": "12345654321", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "launch-wizard-1 created 2020-08-10T14:28:09.271+05:00", + "GroupName": "launch-wizard-1", + "IpPermissions": [ + { + "FromPort": 80, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 80, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "12345654321", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "launch-wizard-1 created 2020-08-10T14:28:09.271+05:00", + "GroupName": "launch-wizard-1", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + ], + "OwnerId": "12345654321", + "GroupId": "sg-02e2c70cd463dcafe", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +] + + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-02e2c70cd463dca29" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (groups, interfaces, functions) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + data: interfaces + }, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + data: functions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: { + message: 'error describing network interfaces' + }, + }, + } + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing lambda functions' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + } + } + }; +}; + +describe('openCustomPorts', function () { + describe('run', function () { + it('should FAIL if security group has open ports', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:0-500,tcp:33-39,tcp:55' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if security group does not have open ports', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:25,tcp:26' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openCustomPorts.run(cache, {ec2_skip_unused_groups: 'true', restricted_open_ports: 'tcp:25,tcp:26'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe security groups', function (done) { + const cache = createErrorCache(); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe security groups response not found', function (done) { + const cache = createNullCache(); + openCustomPorts.run(cache, { restricted_open_ports: 'tcp:22' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openCustomPorts.run(cache, {check_network_interface:'true', restricted_open_ports: 'tcp:22'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/openDNS.js b/plugins/aws/ec2/openDNS.js index 2b2f0ad36..e78fbaae5 100644 --- a/plugins/aws/ec2/openDNS.js +++ b/plugins/aws/ec2/openDNS.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open DNS', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP or UDP port 53 for DNS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as DNS should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP and UDP port 53 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openDNSReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openDNSReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,8 +88,7 @@ module.exports = { helpers.addResult(results, 0, 'No security groups present', region); return rcb(); } - - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openDNS.spec.js b/plugins/aws/ec2/openDNS.spec.js new file mode 100644 index 000000000..9f8a39b85 --- /dev/null +++ b/plugins/aws/ec2/openDNS.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openDNS = require('./openDNS'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openDNS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has DNS TCP or UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openDNS.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDNS.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openDocker.js b/plugins/aws/ec2/openDocker.js index 1b8330db3..50fa4bcd3 100644 --- a/plugins/aws/ec2/openDocker.js +++ b/plugins/aws/ec2/openDocker.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Docker', category: 'EC2', + domain: 'Compute', + severity: 'Critical', description: 'Determine if Docker port 2375 or 2376 is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Docker should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP ports 2375 and 2376 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openDockerReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openDockerReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress', 'EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress', 'ec2:RevokeSecurityGroupIngress'], rollback: ['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress','ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function() { diff --git a/plugins/aws/ec2/openDocker.spec.js b/plugins/aws/ec2/openDocker.spec.js new file mode 100644 index 000000000..7bece8b97 --- /dev/null +++ b/plugins/aws/ec2/openDocker.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openDocker = require('./openDocker'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-001639e564442dfec", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openDocker', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Docker TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openDocker.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openDocker.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openElasticsearch.js b/plugins/aws/ec2/openElasticsearch.js index 5d70b1bb2..d5a101421 100644 --- a/plugins/aws/ec2/openElasticsearch.js +++ b/plugins/aws/ec2/openElasticsearch.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Elasticsearch', category: 'EC2', - description: 'Determine if TCP port 9200 for Elasticsearch is open to the public', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 9200 or 9300 for Elasticsearch is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Elasticsearch should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 9200 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openElasticsearchReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openElasticsearchReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,15 +50,23 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); var ports = { - 'tcp': [9200] + 'tcp': [9200, 9300] }; var service = 'Elasticsearch'; @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ @@ -62,17 +100,29 @@ module.exports = { var putCall = this.actions.remediate; var pluginName = 'openElasticsearch'; var protocol = 'tcp'; - var port = 9200; + var ports = [9200,9300]; + var actions = []; + var errors = []; + + async.each(ports,function(port, cb) { + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)){ + actions.push(action); + } - helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { - if (error && (error.length || Object.keys(error).length)) { - remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + cb(); + }); + }, function() { + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); settings.remediation_file = remediation_file; - return callback(error, null); + return callback(errors, null); } else { - remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; settings.remediation_file = remediation_file; - return callback(null, action); + return callback(null, actions); } }); } diff --git a/plugins/aws/ec2/openElasticsearch.spec.js b/plugins/aws/ec2/openElasticsearch.spec.js new file mode 100644 index 000000000..d3144ad9a --- /dev/null +++ b/plugins/aws/ec2/openElasticsearch.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openElasticsearch = require('./openElasticsearch'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openElasticsearch', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Elasticsearch TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openElasticsearch.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openElasticsearch.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openFTP.js b/plugins/aws/ec2/openFTP.js index 24698980d..da5e82443 100644 --- a/plugins/aws/ec2/openFTP.js +++ b/plugins/aws/ec2/openFTP.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open FTP', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 20 or 21 for FTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as FTP should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP ports 20 and 21 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openFTPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openFTPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openFTP.spec.js b/plugins/aws/ec2/openFTP.spec.js new file mode 100644 index 000000000..e79fab7e2 --- /dev/null +++ b/plugins/aws/ec2/openFTP.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openFTP = require('./openFTP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openFTP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has FTP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openFTP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openFTP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/openHTTP.js b/plugins/aws/ec2/openHTTP.js new file mode 100644 index 000000000..6105d1003 --- /dev/null +++ b/plugins/aws/ec2/openHTTP.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open HTTP', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 80 for HTTP is open to the public', + more_info: 'While some ports are required to be open to the public to function properly, more sensitive services such as HTTP should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 80 to known IP addresses', + apis: ['EC2:describeSecurityGroups','EC2:describeNetworkInterfaces'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + + run: function(cache, settings, callback) { + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var ports = { + 'tcp': [80] + }; + + var service = 'HTTP'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/ec2/openHTTP.spec.js b/plugins/aws/ec2/openHTTP.spec.js new file mode 100644 index 000000000..3d1d423ac --- /dev/null +++ b/plugins/aws/ec2/openHTTP.spec.js @@ -0,0 +1,264 @@ +var expect = require('chai').expect; +const openHTTP = require('./openHTTP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 80, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openHTTP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has HTTP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]] ); + openHTTP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/aws/ec2/openHTTPS.js b/plugins/aws/ec2/openHTTPS.js new file mode 100644 index 000000000..22ede86fc --- /dev/null +++ b/plugins/aws/ec2/openHTTPS.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open HTTPS', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 443 for HTTPS is open to the public', + more_info: 'While some ports are required to be open to the public to function properly, more sensitive services such as HTTPS should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 443 to known IP addresses.', + apis: ['EC2:describeSecurityGroups','EC2:describeNetworkInterfaces'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var ports = { + 'tcp': [443] + }; + + var service = 'HTTPS'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/ec2/openHTTPS.spec.js b/plugins/aws/ec2/openHTTPS.spec.js new file mode 100644 index 000000000..0292ef9f9 --- /dev/null +++ b/plugins/aws/ec2/openHTTPS.spec.js @@ -0,0 +1,270 @@ +var expect = require('chai').expect; +const openHTTPS = require('./openHTTPS'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 443, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openHTTPS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if security group has HTTPS TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups present') + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security groups') + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]]); + openHTTPS.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/aws/ec2/openHadoopNameNode.js b/plugins/aws/ec2/openHadoopNameNode.js index 486a2f295..1564040f3 100644 --- a/plugins/aws/ec2/openHadoopNameNode.js +++ b/plugins/aws/ec2/openHadoopNameNode.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Hadoop HDFS NameNode Metadata Service', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 8020 for HDFS NameNode metadata service is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 8020 to known IP addresses for Hadoop/HDFS', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openHadoopNameNodeReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openHadoopNameNodeReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openHadoopNameNode.spec.js b/plugins/aws/ec2/openHadoopNameNode.spec.js new file mode 100644 index 000000000..dd4a04759 --- /dev/null +++ b/plugins/aws/ec2/openHadoopNameNode.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openHadoopNameNode = require('./openHadoopNameNode'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openHadoopNameNode', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has HDFSNameNodeMetadataService TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openHadoopNameNode.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNode.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openHadoopNameNodeWebUI.js b/plugins/aws/ec2/openHadoopNameNodeWebUI.js index 6f7038fc3..0382abc89 100644 --- a/plugins/aws/ec2/openHadoopNameNodeWebUI.js +++ b/plugins/aws/ec2/openHadoopNameNodeWebUI.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Hadoop HDFS NameNode WebUI', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 50070 and 50470 to known IP addresses for Hadoop/HDFS', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openHadoopNameNodeWebUIReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openHadoopNameNodeWebUIReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openHadoopNameNodeWebUI.spec.js b/plugins/aws/ec2/openHadoopNameNodeWebUI.spec.js new file mode 100644 index 000000000..4fccf892a --- /dev/null +++ b/plugins/aws/ec2/openHadoopNameNodeWebUI.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openHadoopNameNodeWebUI = require('./openHadoopNameNodeWebUI'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('openHadoopNameNodeWebUI', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has NameNodeWebUI TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openHadoopNameNodeWebUI.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openHadoopNameNodeWebUI.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openInternalWeb.js b/plugins/aws/ec2/openInternalWeb.js new file mode 100644 index 000000000..7c5a16cd7 --- /dev/null +++ b/plugins/aws/ec2/openInternalWeb.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Internal Web', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 8080 for internal web is open to the public', + more_info: 'Internal web port 8080 is used for web applications and proxy services. Allowing Inbound traffic from any IP address to TCP port 8080 is vulnerable to exploits like backdoor trojan attacks. It is a best practice to block port 8080 from the public internet.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 8080 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openKibanaReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openKibanaReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [8080] + }; + + var service = 'InternalWeb'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openInternalWeb'; + var protocol = 'tcp'; + var port = 8080; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + + } +}; diff --git a/plugins/aws/ec2/openInternalWeb.spec.js b/plugins/aws/ec2/openInternalWeb.spec.js new file mode 100644 index 000000000..03dc229d3 --- /dev/null +++ b/plugins/aws/ec2/openInternalWeb.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openInternalWeb = require('./openInternalWeb'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openInternalWeb', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Internal Web TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openInternalWeb.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openInternalWeb.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openKibana.js b/plugins/aws/ec2/openKibana.js index c04e49a6a..499e4139f 100644 --- a/plugins/aws/ec2/openKibana.js +++ b/plugins/aws/ec2/openKibana.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Kibana', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 5601 for Kibana is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Kibana should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 5601 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openKibanaReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openKibanaReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openKibana.spec.js b/plugins/aws/ec2/openKibana.spec.js new file mode 100644 index 000000000..22aaf76f5 --- /dev/null +++ b/plugins/aws/ec2/openKibana.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openKibana = require('./openKibana'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openKibana', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Kibana TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openKibana.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openKibana.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openLDAP.js b/plugins/aws/ec2/openLDAP.js new file mode 100644 index 000000000..0ba0856e1 --- /dev/null +++ b/plugins/aws/ec2/openLDAP.js @@ -0,0 +1,119 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open LDAP', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP or UDP port 389 for LDAP is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as LDAP should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP or UDP port 389 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openLDAPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openLDAPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [389], + 'udp': [389] + }; + + var service = 'LDAP'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openLDAP'; + var protocol = ['tcp','udp']; + var port = 389; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + + } +}; diff --git a/plugins/aws/ec2/openLDAP.spec.js b/plugins/aws/ec2/openLDAP.spec.js new file mode 100644 index 000000000..b46e66cee --- /dev/null +++ b/plugins/aws/ec2/openLDAP.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openLDAP = require('./openLDAP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openLDAP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has LDAP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openLDAP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/aws/ec2/openLDAPS.js b/plugins/aws/ec2/openLDAPS.js new file mode 100644 index 000000000..b921bad7b --- /dev/null +++ b/plugins/aws/ec2/openLDAPS.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open LDAPS', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 636 for LDAP SSL is open to the public', + more_info: 'LDAP SSL port 636 is used for Secure LDAP authentication. Allowing Inbound traffic from any IP address to TCP port 636 is vulnerable to DoS attacks. It is a best practice to block port 636 from the public internet.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 636 to known IP addresses.', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openLDAPSSLReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openLDAPSSLReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [636] + }; + + var service = 'LDAPS'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openLDAPS'; + var protocol = 'tcp'; + var port = 636; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + + } +}; diff --git a/plugins/aws/ec2/openLDAPS.spec.js b/plugins/aws/ec2/openLDAPS.spec.js new file mode 100644 index 000000000..6d90ec6da --- /dev/null +++ b/plugins/aws/ec2/openLDAPS.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openLDAPS = require('./openLDAPS'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openLDAPS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has LDAP SSL TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openLDAPS.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openLDAPS.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/aws/ec2/openMemcached.js b/plugins/aws/ec2/openMemcached.js new file mode 100644 index 000000000..ef60f3e49 --- /dev/null +++ b/plugins/aws/ec2/openMemcached.js @@ -0,0 +1,213 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Memcached', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP or UDP port 11211 for Memcached is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Memcached should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP and UDP port 11211 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions', 'EC2:describeSubnets', 'EC2:describeRouteTables','ElastiCache:describeCacheClusters','ElastiCache:describeCacheSubnetGroups' ], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + ignore_groups_with_private_clusters: { + name: 'Ignore security groups which are associated with clusters in private subnet', + description: 'When set to true, pass all the security groups associated with clusters that are in private subnet', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openMemcachedReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openMemcachedReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + ignore_groups_with_private_clusters: settings.ignore_groups_with_private_clusters|| this.settings.ignore_groups_with_private_clusters.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.ignore_groups_with_private_clusters = (config.ignore_groups_with_private_clusters == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'udp': [11211], + 'tcp': [11211] + }; + + var service = 'Memcached'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + if (!describeSecurityGroups) return rcb(); + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + if (!config.ignore_groups_with_private_clusters) { + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + } else { + var subnetGroup; + var subnetRouteTableMap; + var privateSubnets = []; + var publicClusterSecurityGroups = []; + var privateClusterSecurityGroups = []; + var describeClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeClusters || describeClusters.err || !describeClusters.data ) { + helpers.addResult(results, 3, + 'Unable to query for clusters: ' + helpers.addError(describeClusters), region); + return rcb(); + } else { + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data ) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + return rcb(); + } + + if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + return rcb(); + } else { + subnetRouteTableMap = helpers.getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = helpers.getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + } + + for ( var cluster of describeClusters.data) { + subnetGroup = cluster.CacheSubnetGroupName; + var describeCacheSubnetGroups = helpers.addSource(cache, source, ['elasticache','describeCacheSubnetGroups', region, subnetGroup]); + + if (!describeCacheSubnetGroups || describeCacheSubnetGroups.err || !describeCacheSubnetGroups.data || !describeCacheSubnetGroups.data.CacheSubnetGroups) continue; + var clusterSubnets = describeCacheSubnetGroups.data.CacheSubnetGroups; + var allPrivate = clusterSubnets[0].Subnets.every(subnet => privateSubnets.includes(subnet.SubnetIdentifier)); + var groupIds = cluster.SecurityGroups.map(group => group.SecurityGroupId); + + if (allPrivate) { + privateClusterSecurityGroups.push(...groupIds.filter(groupId => !privateClusterSecurityGroups.includes(groupId))); + } else { + publicClusterSecurityGroups.push(...groupIds.filter(groupId => !publicClusterSecurityGroups.includes(groupId))); + } + } + if (privateClusterSecurityGroups.length > 0) { + var privateGroupNames = []; + privateClusterSecurityGroups = privateClusterSecurityGroups.filter(groupId => !publicClusterSecurityGroups.includes(groupId)); + privateClusterSecurityGroups.forEach(groupId => { + var securityGroupData = describeSecurityGroups.data.find(group => group.GroupId === groupId); + if (securityGroupData) { + privateGroupNames.push({ + groupId: groupId, + name: securityGroupData.GroupName, + description: securityGroupData.Description, + OwnerId: securityGroupData.OwnerId + }); + } + }); + privateGroupNames.forEach(privateGroup => { + var resource = `arn:aws:ec2:${region}:${privateGroup.OwnerId}:security-group/${privateGroup.groupId}`; + helpers.addResult(results, 0, `Security group ${privateGroup.groupId} (${privateGroup.name}) has all private clusters attached`, region, resource); + }); + } + var filteredSecurityGroups = describeSecurityGroups.data.filter(function(group) { + return !privateClusterSecurityGroups.includes(group.GroupId); + }); + if (!filteredSecurityGroups.length) { + return rcb(); + } else { + helpers.findOpenPorts(filteredSecurityGroups, ports, service, region, results, cache, config, rcb, settings); + } + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openMemcached'; + var protocols = ['tcp','udp']; + var port = 11211; + var errors = []; + var actions = []; + + async.each(protocols,function(protocol, cb) { + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)){ + actions.push(action); + } + + cb(); + }); + }, function() { + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); + settings.remediation_file = remediation_file; + return callback(errors, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; + settings.remediation_file = remediation_file; + return callback(null, actions); + } + }); + } +}; diff --git a/plugins/aws/ec2/openMemcached.spec.js b/plugins/aws/ec2/openMemcached.spec.js new file mode 100644 index 000000000..7e6b96f57 --- /dev/null +++ b/plugins/aws/ec2/openMemcached.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openMemcached = require('./openMemcached'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openMemcached', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Memcached TCP or UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openMemcached.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMemcached.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openMongoDB.js b/plugins/aws/ec2/openMongoDB.js new file mode 100644 index 000000000..d522adb8b --- /dev/null +++ b/plugins/aws/ec2/openMongoDB.js @@ -0,0 +1,130 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open MongoDB', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 27017 or 27018 or 27019 for MongoDB is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MongoDB should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 27017 or 27018 or 27019 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openMongoDBReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openMongoDBReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [27017, 27018, 27019] + }; + + var service = 'MongoDB'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openMongoDB'; + var protocol = 'tcp'; + var ports = [27017, 27018, 27019]; + var actions = []; + var errors = []; + + async.each(ports,function(port, cb) { + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)){ + actions.push(action); + } + + cb(); + }); + }, function() { + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors; + settings.remediation_file = remediation_file; + return callback(errors, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; + settings.remediation_file = remediation_file; + return callback(null, actions); + } + }); + } +}; diff --git a/plugins/aws/ec2/openMongoDB.spec.js b/plugins/aws/ec2/openMongoDB.spec.js new file mode 100644 index 000000000..e32efb736 --- /dev/null +++ b/plugins/aws/ec2/openMongoDB.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openMongoDB = require('./openMongoDB'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openMongoDB', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has MongoDB TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openMongoDB.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMongoDB.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openMySQL.js b/plugins/aws/ec2/openMySQL.js index a17ef76c8..172102748 100644 --- a/plugins/aws/ec2/openMySQL.js +++ b/plugins/aws/ec2/openMySQL.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open MySQL', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 4333 or 3306 for MySQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MySQL should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP ports 4333 and 3306 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openMySQLReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openMySQLReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openMySQL.spec.js b/plugins/aws/ec2/openMySQL.spec.js new file mode 100644 index 000000000..7850e4e89 --- /dev/null +++ b/plugins/aws/ec2/openMySQL.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openMySQL = require('./openMySQL'); + + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openMySQL', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has MySQL TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openMySQL.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openMySQL.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openNetBIOS.js b/plugins/aws/ec2/openNetBIOS.js index 86548dcb0..723603f36 100644 --- a/plugins/aws/ec2/openNetBIOS.js +++ b/plugins/aws/ec2/openNetBIOS.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open NetBIOS', category: 'EC2', - description: 'Determine if UDP port 137 or 138 for NetBIOS is open to the public', + domain: 'Compute', + severity: 'High', + description: 'Determine if UDP port 137 or 138 or 139 for NetBIOS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as NetBIOS should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict UDP ports 137 and 138 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openNetBIOSReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openNetBIOSReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,15 +50,23 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); var ports = { - 'udp': [137, 138] + 'udp': [137, 138, 139] }; var service = 'NetBIOS'; @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ @@ -62,7 +100,7 @@ module.exports = { var putCall = this.actions.remediate; var pluginName = 'openNetBIOS'; var protocol = 'udp'; - var ports = [137,138]; + var ports = [137,138,139]; var actions = []; var errors = []; diff --git a/plugins/aws/ec2/openNetBIOS.spec.js b/plugins/aws/ec2/openNetBIOS.spec.js new file mode 100644 index 000000000..767278675 --- /dev/null +++ b/plugins/aws/ec2/openNetBIOS.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openNetBIOS = require('./openNetBIOS'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "udp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "udp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openNetBIOS', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has NetBIOS UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openNetBIOS.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openNetBIOS.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openOracle.js b/plugins/aws/ec2/openOracle.js index 28a46b8de..97548afab 100644 --- a/plugins/aws/ec2/openOracle.js +++ b/plugins/aws/ec2/openOracle.js @@ -4,16 +4,46 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Oracle', category: 'EC2', - description: 'Determine if TCP port 1521 for Oracle is open to the public', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP ports 1521 , 2483 for Oracle is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open \ to the public to function properly, more sensitive services such as Oracle \ should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', - recommended_action: 'Restrict TCP ports 1521 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + recommended_action: 'Restrict TCP ports 1521, 2483 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openOracleReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openOracleReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -22,15 +52,23 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); var ports = { - 'tcp': [1521] + 'tcp': [1521, 2483] }; var service = 'Oracle'; @@ -52,7 +90,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ @@ -64,17 +102,29 @@ module.exports = { var putCall = this.actions.remediate; var pluginName = 'openOracle'; var protocol = 'tcp'; - var port = 1521; + var ports = [1521, 2483]; + var actions = []; + var errors = []; + + async.each(ports,function(port, cb) { + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)){ + actions.push(action); + } - helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { - if (error && (error.length || Object.keys(error).length)) { - remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + cb(); + }); + }, function() { + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); settings.remediation_file = remediation_file; - return callback(error, null); + return callback(errors, null); } else { - remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; settings.remediation_file = remediation_file; - return callback(null, action); + return callback(null, actions); } }); } diff --git a/plugins/aws/ec2/openOracle.spec.js b/plugins/aws/ec2/openOracle.spec.js new file mode 100644 index 000000000..132e08584 --- /dev/null +++ b/plugins/aws/ec2/openOracle.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openOracle = require('./openOracle'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openOracle', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Oracle TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openOracle.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracle.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openOracleAutoDataWarehouse.js b/plugins/aws/ec2/openOracleAutoDataWarehouse.js new file mode 100644 index 000000000..e76dfc277 --- /dev/null +++ b/plugins/aws/ec2/openOracleAutoDataWarehouse.js @@ -0,0 +1,119 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Oracle Auto Data Warehouse', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 1522 for Oracle Auto Data Warehouse is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open \ + to the public to function properly, more sensitive services such as Oracle Auto Data Warehouse \ + should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP ports 1522 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202006020730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openOracleAutoDataWarehouseReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openOracleAutoDataWarehouseReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [1522] + }; + + var service = 'Oracle Auto Data Warehouse'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openOracleAutoDataWarehouse'; + var protocol = 'tcp'; + var port = 1522; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/openOracleAutoDataWarehouse.spec.js b/plugins/aws/ec2/openOracleAutoDataWarehouse.spec.js new file mode 100644 index 000000000..5e30fea80 --- /dev/null +++ b/plugins/aws/ec2/openOracleAutoDataWarehouse.spec.js @@ -0,0 +1,318 @@ +var expect = require('chai').expect; +const openOracleAutoDataWarehouse = require('./openOracleAutoDataWarehouse'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; +describe('openOracleAutoDataWarehouse', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Oracle Auto Data Warehouse TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openOracleAutoDataWarehouse.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openOracleAutoDataWarehouse.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openPostgreSQL.js b/plugins/aws/ec2/openPostgreSQL.js index aae4ad253..a83c1e93c 100644 --- a/plugins/aws/ec2/openPostgreSQL.js +++ b/plugins/aws/ec2/openPostgreSQL.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open PostgreSQL', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 5432 for PostgreSQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as PostgreSQL should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 5432 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openPostgreSQLReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openPostgreSQLReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openPostgreSQL.spec.js b/plugins/aws/ec2/openPostgreSQL.spec.js new file mode 100644 index 000000000..c7b115880 --- /dev/null +++ b/plugins/aws/ec2/openPostgreSQL.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openPostgreSQL = require('./openPostgreSQL'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openPostgreSQL', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]],[describeNetworkInterfaces[0]], [listFunctions[0]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has PostgreSQL TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openPostgreSQL.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openPostgreSQL.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openRDP.js b/plugins/aws/ec2/openRDP.js index 6a83709d9..4a1a801f9 100644 --- a/plugins/aws/ec2/openRDP.js +++ b/plugins/aws/ec2/openRDP.js @@ -4,17 +4,47 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open RDP', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 3389 for RDP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RDP should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 3389 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, compliance: { cis1: '4.2 Ensure no security groups allow ingress from 0.0.0.0/0 to port 3389' }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openRDPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openRDPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -23,9 +53,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -53,7 +91,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openRDP.spec.js b/plugins/aws/ec2/openRDP.spec.js new file mode 100644 index 000000000..1ed0b058e --- /dev/null +++ b/plugins/aws/ec2/openRDP.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openRDP = require('./openRDP'); + + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openRDP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has RDP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openRDP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRDP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openRPC.js b/plugins/aws/ec2/openRPC.js index fe00d1ae9..8064ee008 100644 --- a/plugins/aws/ec2/openRPC.js +++ b/plugins/aws/ec2/openRPC.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open RPC', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 135 for RPC is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RPC should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 135 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openRPCReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openRPCReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openRPC.spec.js b/plugins/aws/ec2/openRPC.spec.js new file mode 100644 index 000000000..fcef651e0 --- /dev/null +++ b/plugins/aws/ec2/openRPC.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openRPC = require('./openRPC'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('openRPC', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has RPC TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openRPC.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRPC.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openRedis.js b/plugins/aws/ec2/openRedis.js new file mode 100644 index 000000000..54b73032b --- /dev/null +++ b/plugins/aws/ec2/openRedis.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open Redis', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if TCP port 6379 for Redis is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Redis should be restricted to known IP addresses.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict TCP port 6379 to known IP addresses', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openRedisReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openRedisReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'tcp': [6379] + }; + + var service = 'Redis'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openRedis'; + var protocol = 'tcp'; + var port = 6379; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + } + }); + } +}; diff --git a/plugins/aws/ec2/openRedis.spec.js b/plugins/aws/ec2/openRedis.spec.js new file mode 100644 index 000000000..834c6d30b --- /dev/null +++ b/plugins/aws/ec2/openRedis.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openRedis = require('./openRedis'); + + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openRedis', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Redis TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openRedis.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openRedis.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openSMBoTCP.js b/plugins/aws/ec2/openSMBoTCP.js index 859a9d263..94516e1c2 100644 --- a/plugins/aws/ec2/openSMBoTCP.js +++ b/plugins/aws/ec2/openSMBoTCP.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open SMBoTCP', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 445 for Windows SMB over TCP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMB should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 445 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSMBoTCPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSMBoTCPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openSMBoTCP.spec.js b/plugins/aws/ec2/openSMBoTCP.spec.js new file mode 100644 index 000000000..95e8ffee0 --- /dev/null +++ b/plugins/aws/ec2/openSMBoTCP.spec.js @@ -0,0 +1,321 @@ +var expect = require('chai').expect; +const openSMBoTCP = require('./openSMBoTCP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('openSMBoTCP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has SMBoTCP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSMBoTCP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMBoTCP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openSMTP.js b/plugins/aws/ec2/openSMTP.js index 77a57bd13..544e8f342 100644 --- a/plugins/aws/ec2/openSMTP.js +++ b/plugins/aws/ec2/openSMTP.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open SMTP', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 25 for SMTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMTP should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 25 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSMTPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSMTPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openSMTP.spec.js b/plugins/aws/ec2/openSMTP.spec.js new file mode 100644 index 000000000..b73ccd3e5 --- /dev/null +++ b/plugins/aws/ec2/openSMTP.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openSMTP = require('./openSMTP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openSMTP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has SMTP TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSMTP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSMTP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openSNMP.js b/plugins/aws/ec2/openSNMP.js new file mode 100644 index 000000000..3e6ad24c8 --- /dev/null +++ b/plugins/aws/ec2/openSNMP.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open SNMP', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Determine if UDP port 161 for SNMP is open to the public', + more_info: 'SNMP UDP 161 used by various devices and applications for logging events, monitoring and management. Allowing Inbound traffic from any external IP address on port 161 is vulnerable to DoS attack. It is a best practice to block port 161 completely unless explicitly required.', + link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', + recommended_action: 'Restrict UDP port 161 to known IP addresses.', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, + remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', + remediation_min_version: '202209040730', + apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSNMPReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSNMPReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: { + remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], + rollback: ['EC2:authorizeSecurityGroupIngress'] + }, + permissions: { + remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], + rollback:['ec2:AuthorizeSecurityGroupIngress'] + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var ports = { + 'udp': [161] + }; + + var service = 'SNMP'; + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'openSNMP'; + var protocol = 'udp'; + var port = [161]; + + helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = error; + settings.remediation_file = remediation_file; + return callback(error, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + settings.remediation_file = remediation_file; + return callback(null, action); + + } + }); + } +}; diff --git a/plugins/aws/ec2/openSNMP.spec.js b/plugins/aws/ec2/openSNMP.spec.js new file mode 100644 index 000000000..ed51274e8 --- /dev/null +++ b/plugins/aws/ec2/openSNMP.spec.js @@ -0,0 +1,319 @@ +var expect = require('chai').expect; +const openSNMP = require('./openSNMP'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "udp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "udp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openSNMP', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has SNMP UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSNMP.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSNMP.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openSQLServer.js b/plugins/aws/ec2/openSQLServer.js index 47c707f3a..db2a05522 100644 --- a/plugins/aws/ec2/openSQLServer.js +++ b/plugins/aws/ec2/openSQLServer.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open SQL Server', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 1433 or UDP port 1434 for SQL Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SQL server should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 1433 and UDP port 1434 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSQLServerReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSQLServerReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -51,7 +89,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openSQLServer.spec.js b/plugins/aws/ec2/openSQLServer.spec.js new file mode 100644 index 000000000..3d7916a58 --- /dev/null +++ b/plugins/aws/ec2/openSQLServer.spec.js @@ -0,0 +1,318 @@ +var expect = require('chai').expect; +const openSQLServer = require('./openSQLServer'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openSQLServer', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has SQL Server TCP port or UDP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSQLServer.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSQLServer.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/openSSH.js b/plugins/aws/ec2/openSSH.js index d5d472171..854801d76 100644 --- a/plugins/aws/ec2/openSSH.js +++ b/plugins/aws/ec2/openSSH.js @@ -4,17 +4,47 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open SSH', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 22 for SSH is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SSH should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 22 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, compliance: { cis1: '4.1 Ensure no security groups allow ingress from 0.0.0.0/0 to port 22' }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSSHReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSSHReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -23,9 +53,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -53,7 +91,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ @@ -76,6 +114,7 @@ module.exports = { remediation_file['post_remediate']['actions'][pluginName][resource] = action; settings.remediation_file = remediation_file; return callback(null, action); + } }); } diff --git a/plugins/aws/ec2/openSSH.spec.js b/plugins/aws/ec2/openSSH.spec.js new file mode 100644 index 000000000..a540d509c --- /dev/null +++ b/plugins/aws/ec2/openSSH.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openSSH = require('./openSSH'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openSSH', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has SSH TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSSH.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSSH.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openSalt.js b/plugins/aws/ec2/openSalt.js index 3b7ea5b6a..f8eea1bc0 100644 --- a/plugins/aws/ec2/openSalt.js +++ b/plugins/aws/ec2/openSalt.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Salt', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP ports 4505 or 4506 for the Salt master are open to the public', more_info: 'Active Salt vulnerabilities, CVE-2020-11651 and CVE-2020-11652 are exploiting Salt instances exposed to the internet. These ports should be closed immediately.', link: 'https://help.saltstack.com/hc/en-us/articles/360043056331-New-SaltStack-Release-Critical-Vulnerability', recommended_action: 'Restrict TCP ports 4505 and 4506 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openSaltReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSaltReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openSalt.spec.js b/plugins/aws/ec2/openSalt.spec.js new file mode 100644 index 000000000..0873810e6 --- /dev/null +++ b/plugins/aws/ec2/openSalt.spec.js @@ -0,0 +1,321 @@ +var expect = require('chai').expect; +const openSalt = require('./openSalt'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('openSalt', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Salt TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openSalt.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openSalt.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openTelnet.js b/plugins/aws/ec2/openTelnet.js index 69c61902f..329768c7f 100644 --- a/plugins/aws/ec2/openTelnet.js +++ b/plugins/aws/ec2/openTelnet.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open Telnet', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 23 for Telnet is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Telnet should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 23 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openTelnetReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openTelnetReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openTelnet.spec.js b/plugins/aws/ec2/openTelnet.spec.js new file mode 100644 index 000000000..46657e4ee --- /dev/null +++ b/plugins/aws/ec2/openTelnet.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openTelnet = require('./openTelnet'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openTelnet', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has Telnet TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openTelnet.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openTelnet.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openVNCClient.js b/plugins/aws/ec2/openVNCClient.js index c9d8063ed..a6d402721 100644 --- a/plugins/aws/ec2/openVNCClient.js +++ b/plugins/aws/ec2/openVNCClient.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open VNC Client', category: 'EC2', + domain: 'Compute', + severity: 'Critical', description: 'Determine if TCP port 5500 for VNC Client is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Client should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 5500 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openVNCClientReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openVNCClientReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openVNCClient.spec.js b/plugins/aws/ec2/openVNCClient.spec.js new file mode 100644 index 000000000..4cd8667f8 --- /dev/null +++ b/plugins/aws/ec2/openVNCClient.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openVNCClient = require('./openVNCClient'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openVNCClient', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has VNC Client TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openVNCClient.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCClient.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/openVNCServer.js b/plugins/aws/ec2/openVNCServer.js index 161916686..7262d3d87 100644 --- a/plugins/aws/ec2/openVNCServer.js +++ b/plugins/aws/ec2/openVNCServer.js @@ -4,14 +4,44 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Open VNC Server', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Determine if TCP port 5900 for VNC Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Server should be restricted to known IP addresses.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/authorizing-access-to-an-instance.html', recommended_action: 'Restrict TCP port 5900 to known IP addresses', - apis: ['EC2:describeSecurityGroups'], + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + settings: { + ec2_skip_unused_groups: { + name: 'EC2 Skip Unused Groups', + description: 'When set to true, skip checking ports for unused security groups and produce a WARN result', + regex: '^(true|false)$', + default: 'false', + }, + check_network_interface: { + name: 'Check Associated ENI', + description: 'When set to true, checks elastic network interfaces associated to the security group and returns FAIL if both the security group and ENI are publicly exposed', + regex: '^(true|false)$', + default: 'false', + } + }, remediation_description: 'The impacted security group rule will be deleted if no input is provided. Otherwise, any input will replace the open CIDR rule.', remediation_min_version: '202006020730', apis_remediate: ['EC2:describeSecurityGroups'], + remediation_inputs: { + openVNCServerReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'Comma separated list of IPv4 CIDR block used to replace the open IP rule', + regex: '^(([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2])),)*([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openVNCServerReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'Comma separated list of IPv6 CIDR block used to replace the open IP rule', + regex: '^s*(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])),)*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: { remediate: ['EC2:authorizeSecurityGroupIngress','EC2:revokeSecurityGroupIngress'], rollback: ['EC2:authorizeSecurityGroupIngress'] @@ -20,9 +50,17 @@ module.exports = { remediate: ['ec2:AuthorizeSecurityGroupIngress','ec2:RevokeSecurityGroupIngress'], rollback:['ec2:AuthorizeSecurityGroupIngress'] }, - realtime_triggers: ['ec2:AuthorizeSecurityGroupIngress'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], run: function(cache, settings, callback) { + var config = { + ec2_skip_unused_groups: settings.ec2_skip_unused_groups || this.settings.ec2_skip_unused_groups.default, + check_network_interface: settings.check_network_interface || this.settings.check_network_interface.default, + }; + + config.ec2_skip_unused_groups = (config.ec2_skip_unused_groups == 'true'); + config.check_network_interface = (config.check_network_interface == 'true'); + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -50,7 +88,7 @@ module.exports = { return rcb(); } - helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results); + helpers.findOpenPorts(describeSecurityGroups.data, ports, service, region, results, cache, config, rcb, settings); rcb(); }, function(){ diff --git a/plugins/aws/ec2/openVNCServer.spec.js b/plugins/aws/ec2/openVNCServer.spec.js new file mode 100644 index 000000000..65cdb2a46 --- /dev/null +++ b/plugins/aws/ec2/openVNCServer.spec.js @@ -0,0 +1,320 @@ +var expect = require('chai').expect; +const openVNCServer = require('./openVNCServer'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02e2c70cd463dca29", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Master group for Elastic MapReduce created on 2020-08-31T17:07:19.819Z", + "GroupName": "ElasticMapReduce-master", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + "UserIdGroupPairs": [ + { + "GroupId": "sg-02e2c70cd463dca29", + "UserId": "111122223333" + } + ] + }, + { + "FromPort": 8443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "72.21.196.64/29" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 8443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-001639e564442dfec", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +] + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + +const createCache = (securityGroups, networkInterfaces, functions, securityGroupsErr, networkInterfacesErr, functionsErr) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: securityGroupsErr, + data: securityGroups + } + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: networkInterfacesErr, + data: networkInterfaces + } + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: functionsErr, + data: functions + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('openVNCServer', function () { + describe('run', function () { + it('should PASS if no public open ports found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if security group has VNC Server TCP port open to public', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if security group is unused', function (done) { + const cache = createCache([describeSecurityGroups[2]], [describeNetworkInterfaces[0]], []); + openVNCServer.run(cache, {ec2_skip_unused_groups: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createCache(null, { message: 'Unable to describe security groups'}); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if open port security group attached to the network interface has no public IP associated', function (done) { + const cache = createCache([describeSecurityGroups[1]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + openVNCServer.run(cache, {check_network_interface:'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/outdatedAmiInUse.js b/plugins/aws/ec2/outdatedAmiInUse.js new file mode 100644 index 000000000..53d83fb9e --- /dev/null +++ b/plugins/aws/ec2/outdatedAmiInUse.js @@ -0,0 +1,142 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Outdated Amazon Machine Images', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that deprecated Amazon Machine Images are not in use.', + more_info: 'Deprecated Amazon Machine Images should not be used to make an instance.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ami-deprecate.html', + recommended_action: 'Delete the instances using deprecated AMIs', + apis: ['EC2:describeImages', 'EC2:describeInstances', 'AutoScaling:describeLaunchConfigurations', + 'EC2:describeLaunchTemplates', 'EC2:describeLaunchTemplateVersions','STS:getCallerIdentity'], + realtime_triggers: ['ec2:RunInstances', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + + const regions = helpers.regions(settings); + const acctRegion = helpers.defaultRegion(settings); + const awsOrGov = helpers.defaultPartition(settings); + const accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + const describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + `Unable to query for AMIs: ${helpers.addError(describeImages)}`, + region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, + 'No Amazon Machine Images found', region); + return rcb(); + } + + const describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + const describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + const describeLaunchTemplates = helpers.addSource(cache, source, + ['ec2', 'describeLaunchTemplates', region]); + + if (!describeInstances || describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + `Unable to query EC2 instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeLaunchConfigurations || describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query Auto Scaling launch configurations: ${helpers.addError(describeLaunchConfigurations)}`, region); + return rcb(); + } + + if (!describeLaunchTemplates || describeLaunchTemplates.err || !describeLaunchTemplates.data) { + helpers.addResult(results, 3, + `Unable to query EC2 launch templates: ${helpers.addError(describeLaunchTemplates)}`, region); + return rcb(); + } + + let found = false; + describeImages.data.forEach(image => { + if (!image.ImageId) return; + + if (image.DeprecationTime && new Date(image.DeprecationTime) < new Date()) { + found = true; + let amiInUse = false; + + if (describeInstances.data.length) { + for (const instance of describeInstances.data) { + if (instance.Instances && instance.Instances.length) { + for (const element of instance.Instances) { + if (element.ImageId && image.ImageId == element.ImageId) { + amiInUse = true; + break; + } + } + } + if (amiInUse) break; + } + } + + if (!amiInUse && describeLaunchConfigurations.data.length) { + for (const config of describeLaunchConfigurations.data) { + if (config.ImageId && image.ImageId == config.ImageId) { + amiInUse = true; + break; + } + } + } + + if (!amiInUse && describeLaunchTemplates.data.length) { + for (const template of describeLaunchConfigurations.data) { + if (template.LaunchTemplateDId) { + var describeLaunchTemplateVersions = helpers.addSource(cache, source, + ['ec2', 'describeLaunchTemplateVersions', region, template.LaunchTemplateId]); + + if (template.DefaultVersionNumber && + describeLaunchTemplateVersions && + describeLaunchTemplateVersions.data && + describeLaunchTemplateVersions.data.LaunchTemplateVersions) { + let templateVersion = describeLaunchTemplateVersions.data.LaunchTemplateVersions.find(version => version.VersionNumber == template.DefaultVersionNumber); + if (templateVersion && templateVersion.LaunchTemplateData && templateVersion.LaunchTemplateData.ImageId && + image.ImageId == templateVersion.LaunchTemplateData.ImageId) { + amiInUse = true; + break; + } + } + } + } + } + + const resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:image/${image.ImageId}`; + const status = amiInUse ? 2: 0; + helpers.addResult(results, status, + `Deprecated Amazon Machine Image "${image.ImageId}" is ${amiInUse ? '': 'not '}in use`, + region, resource); + } + }); + + if (!found) { + helpers.addResult(results, 0, + 'No deprecated Amazon Machine Image found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/outdatedAmiInUse.spec.js b/plugins/aws/ec2/outdatedAmiInUse.spec.js new file mode 100644 index 000000000..85f4e189f --- /dev/null +++ b/plugins/aws/ec2/outdatedAmiInUse.spec.js @@ -0,0 +1,340 @@ +var expect = require('chai').expect; +const outdatedAmiInUse = require('./outdatedAmiInUse'); + +const describeImages = [ + { + "Architecture": "x86_64", + "CreationDate": "2020-10-14T22:59:03.000Z", + "ImageId": "ami-026c295331eb10e50", + "ImageLocation": "111122223333/test-32", + "ImageType": "machine", + "Public": false, + "OwnerId": "111122223333", + "PlatformDetails": "Linux/UNIX", + "State": "available", + "Name": "test-32" + }, + { + "Architecture": "x86_64", + "CreationDate": "2020-10-14T22:59:03.000Z", + "ImageId": "ami-026c295331eb10e50", + "ImageLocation": "111122223333/test-32", + "ImageType": "machine", + "Public": false, + "OwnerId": "111122223333", + "PlatformDetails": "Linux/UNIX", + "State": "available", + "Name": "test-32", + "DeprecationTime": '2021-07-27T14:59:00.000Z' + }, + { + "Architecture": "x86_64", + "CreationDate": "2020-10-14T22:59:03.000Z", + "ImageId": "ami-00000000000000000", + "ImageLocation": "111122223333/test-32", + "ImageType": "machine", + "Public": false, + "OwnerId": "111122223333", + "PlatformDetails": "Linux/UNIX", + "UsageOperation": "RunInstances", + "State": "available", + "Name": "test-32", + "DeprecationTime": '2021-07-27T14:59:00.000Z' + } +]; + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-026c295331eb10e50", + "InstanceId": "i-023c9bc2aed01cc5e", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-09ff2e14445b8c226" + } + ] + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "test-32", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:79298288-6ac0-4031-bf3c-05aa13c64bbc:launchConfigurationName/test-32", + "ImageId": "ami-026c295331eb10e50", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-099a21ef57db4bad1" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/EMR_EC2_DefaultRole", + "CreatedTime": "2020-10-14T23:22:41.431Z", + "EbsOptimized": false + }, +]; + +const describeLaunchTemplates = [ + { + "LaunchTemplateId": "lt-0219ac0443364d22d", + "LaunchTemplateName": "test2-lt", + "CreateTime": "2021-07-19T12:32:41.000Z", + "CreatedBy": "arn:aws:iam::111122223333:user/test", + "DefaultVersionNumber": 4, + "LatestVersionNumber": 4, + "Tags": [] + }, + { + "LaunchTemplateId": "lt-03e19cc8c28396619", + "LaunchTemplateName": "test-lt", + "CreateTime": "2021-08-18T21:46:24.000Z", + "CreatedBy": "arn:aws:iam::111122223333:user/test", + "DefaultVersionNumber": 1, + "LatestVersionNumber": 1, + "Tags": [] + } +] + +const describeLaunchTemplateVersions = [ + { + "LaunchTemplateVersions": [ + { + "LaunchTemplateId": "lt-03e19cc8c28396619", + "LaunchTemplateName": "test2-lt", + "VersionNumber": 2, + "CreateTime": "2021-08-18T21:46:24.000Z", + "CreatedBy": "arn:aws:iam::111122223333:user/test", + "DefaultVersion": true, + "LaunchTemplateData": { + "BlockDeviceMappings": [], + "NetworkInterfaces": [], + "ImageId": "ami-04f5bebeff7a62793", + "TagSpecifications": [], + "ElasticGpuSpecifications": [], + "ElasticInferenceAccelerators": [], + "SecurityGroupIds": [ + "sg-043778823f73431a7" + ], + "SecurityGroups": [], + "LicenseSpecifications": [] + } + } + ] + }, + { + "LaunchTemplateVersions": [ + { + "LaunchTemplateId": "lt-03e19cc8c28396619", + "LaunchTemplateName": "test-lt", + "VersionNumber": 1, + "CreateTime": "2021-08-18T21:46:24.000Z", + "CreatedBy": "arn:aws:iam::111122223333:user/test", + "DefaultVersion": true, + "LaunchTemplateData": { + "BlockDeviceMappings": [], + "NetworkInterfaces": [], + "ImageId": "ami-04f5bebeff7a62793", + "TagSpecifications": [], + "ElasticGpuSpecifications": [], + "ElasticInferenceAccelerators": [], + "SecurityGroupIds": [ + "sg-043778823f73431a7" + ], + "SecurityGroups": [], + "LicenseSpecifications": [] + } + } + ] + } +] + + +const currentDate = new Date().getFullYear(); +const oldDate = new Date().setFullYear(currentDate - 1); +const upComingDate = new Date().setFullYear(currentDate + 1); + + +const createCache = (images, instances, launchConfig, launchTemplate, LaunchTemplateVersions) => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + data: images + }, + }, + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + describeLaunchTemplates: { + 'us-east-1': { + data: launchTemplate + }, + }, + describeLaunchTemplateVersions: { + 'us-east-1': { + data: LaunchTemplateVersions + }, + } + }, + autoscaling:{ + describeLaunchConfigurations: { + 'us-east-1': { + data: launchConfig + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing EC2 AMIs' + }, + }, + }, + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing EC2 instances' + }, + }, + }, + describeLaunchTemplates: { + 'us-east-1': { + err: { + message: 'error describing launch templates' + }, + }, + }, + describeLaunchTemplateVersions: { + 'us-east-1': { + err: { + message: 'error describing launch template versions' + }, + }, + } + }, + autoscaling: { + describeLaunchConfigurations:{ + 'us-east-1': { + err: { + message: 'error describing Auto Scaling launch configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + }, + describeInstances: { + 'us-east-1': null, + }, + describeLaunchTemplates: { + 'us-east-1': null, + }, + describeLaunchTemplateVersions: { + 'us-east-1': null, + }, + }, + autoscaling: { + describeLaunchConfigurations:{ + 'us-east-1': null, + }, + }, + }; +}; + +describe('outdatedAmiInUse', function () { + describe('run', function () { + it('should PASS if Amazon Machine Images does not have deprecation time set', function (done) { + const cache = createCache([describeImages[0]], [describeInstances[0]], [describeLaunchConfigurations[0]], describeLaunchTemplates[0], describeLaunchTemplateVersions[0]); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + it('should PASS if Amazon Machine Image is not deprecated yet', function (done) { + describeImages[2].DeprecationTime = upComingDate; + const cache = createCache([describeImages[2]], [describeInstances[0]], describeLaunchConfigurations[0], describeLaunchTemplates[0], describeLaunchTemplateVersions[0]); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should Fail if deprecated Amazon Machine Image is in use', function (done) { + describeImages[1].DeprecationTime = oldDate; + const cache = createCache([describeImages[1]], [describeInstances[0]], describeLaunchConfigurations[0], describeLaunchTemplates[0], describeLaunchTemplateVersions[0]); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Amazon Machine Image is deprecated and not in use', function (done) { + describeImages[2].DeprecationTime = oldDate; + const cache = createCache([describeImages[2]], [describeInstances[0]], describeLaunchConfigurations[0], describeLaunchTemplates[0], describeLaunchTemplateVersions[0]); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Amazon Machine Images found', function (done) { + const cache = createCache([]); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe images', function (done) { + const cache = createErrorCache(); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe images response not found', function (done) { + const cache = createNullCache(); + outdatedAmiInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/overlappingSecurityGroups.js b/plugins/aws/ec2/overlappingSecurityGroups.js index 93130db11..f9e522adf 100644 --- a/plugins/aws/ec2/overlappingSecurityGroups.js +++ b/plugins/aws/ec2/overlappingSecurityGroups.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Overlapping Security Groups', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if EC2 instances have security groups that share the same rules', more_info: 'Overlapping security group rules make managing EC2 instance access much more difficult. ' + 'If a rule is removed from one security group, the access may still remain in another, ' + @@ -12,11 +14,13 @@ module.exports = { recommended_action: 'Structure security groups to provide a single category of access and do not ' + 'duplicate rules across groups used by the same instances.', apis: ['EC2:describeInstances', 'EC2:describeSecurityGroups'], + realtime_triggers: ['ec2:RunInstances', 'ec2:ModifyInstanceAttribute', 'ec2:ModifySecurityGroupRules', 'ec2:TerminateInstances'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeSecurityGroups = helpers.addSource(cache, source, @@ -116,7 +120,7 @@ module.exports = { var compOp2 = otherGroupId + '/' + groupId + ruleStr; // arn:aws:ec2:region:account-id:instance/instance-id - var arn = 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + instanceId; + var arn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instanceId; if (!overlaps[arn]) { overlaps[arn] = [compOp1]; diff --git a/plugins/aws/ec2/overlappingSecurityGroups.spec.js b/plugins/aws/ec2/overlappingSecurityGroups.spec.js new file mode 100644 index 000000000..3b66a6a61 --- /dev/null +++ b/plugins/aws/ec2/overlappingSecurityGroups.spec.js @@ -0,0 +1,588 @@ +var expect = require('chai').expect; +const overlappingSecurityGroups = require('./overlappingSecurityGroups'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + ], + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462548", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462549", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462550", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462551", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462552", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462553", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462554", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462555", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462556", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462557", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462558", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462559", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462560", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462561", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462562", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462563", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462564", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462565", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462566", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462567", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "default", + "GroupId": "sg-aa941692" + } + ], + }, + ], + }, +]; + +const describeSecurityGroups = [ + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissions": [{ + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 30, + "UserIdGroupPairs": [{ + GroupId: 'sg-0b5f2771716acfee4', + GroupName: 'spec-test-sg' + }] + }], + "OwnerId": "12345654321", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "Allows SSh access to developer", + "GroupName": "spec-test-sg", + "IpPermissions": [{ + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 30, + "UserIdGroupPairs": [{ + GroupId: 'sg-0b5f2771716acfee4', + GroupName: 'spec-test-sg' + }] + }], + "OwnerId": "12345654321", + "GroupId": "sg-aa941692", + "IpPermissionsEgress": [ + { + "FromPort": 25, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [ + { + "CidrIpv6": "::/0" + } + ], + "PrefixListIds": [], + "ToPort": 25, + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, +]; + +const createCache = (groups, instances) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeInstances: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('overlappingSecurityGroups', function () { + describe('run', function () { + it('should PASS if no overlapping instance security groups found', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeInstances[0]]); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if instance has overlapping security group rules via groups', function (done) { + const cache = createCache(describeSecurityGroups, [describeInstances[0]]); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should WARN if more than 20 instances have overlapping security groups', function (done) { + const cache = createCache(describeSecurityGroups, describeInstances); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache(describeSecurityGroups, []); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe security groups', function (done) { + const cache = createErrorCache(); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe instances', function (done) { + const cache = createCache(describeSecurityGroups); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + overlappingSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/overutilizedEC2Instance.js b/plugins/aws/ec2/overutilizedEC2Instance.js new file mode 100644 index 000000000..9ad7dd42b --- /dev/null +++ b/plugins/aws/ec2/overutilizedEC2Instance.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EC2 CPU Alarm Threshold Exceeded', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensure EC2 instances do not exceed the alarm threshold for CPU utilization.', + more_info: 'Excessive CPU utilization can indicate performance issues or the need for capacity optimization.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/viewing_metrics_with_cloudwatch.html#ec2-cloudwatch-metrics', + recommended_action: 'Investigate the cause of high CPU utilization and consider optimizing or scaling resources.', + apis: ['EC2:describeInstances', 'CloudWatch:getEc2MetricStatistics'], + settings: { + ec2_cpu_threshold_fail: { + name: 'EC2 CPU Threshold Fail', + description: 'Return a failing result when consumed EC2 instance cpu threshold equals or exceeds this percentage', + regex: '^(100|[1-9][0-9]?)$', + default: '90' + } + }, + realtime_triggers: ['ec2:RunInstances', 'ec2:ModifyInstanceAttribute', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var ec2_cpu_threshold_fail = settings.ec2_cpu_threshold_fail || this.settings.ec2_cpu_threshold_fail.default; + + async.each(regions.ec2, function(region, rcb) { + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult( + results, 3, + `Unable to query for EC2 instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + describeInstances.data.forEach(reservation => { + let accountId = reservation.OwnerId; + reservation.Instances.forEach(instance => { + if (!instance.InstanceId) return; + let resource = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + instance.InstanceId; + var getMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getEc2MetricStatistics', region, instance.InstanceId]); + + if (!getMetricStatistics || getMetricStatistics.err || + !getMetricStatistics.data || !getMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3, + `Unable to query for CPU metric statistics: ${helpers.addError(getMetricStatistics)}`, region, resource); + return; + } + + if (!getMetricStatistics.data.Datapoints.length) { + helpers.addResult(results, 0, + 'CPU metric statistics are not available', region, resource); + } else { + var cpuDatapoints = getMetricStatistics.data.Datapoints; + var cpuUtilization = cpuDatapoints[cpuDatapoints.length - 1].Average; + if (cpuUtilization >= ec2_cpu_threshold_fail) { + helpers.addResult(results, 2, + `EC2 instance has current CPU utilization of ${cpuUtilization}% which exceeds the CPU threshold`, region, resource); + } else { + helpers.addResult(results, 0, + `EC2 instance has current CPU utilization of ${cpuUtilization}% which does not exceed the CPU threshold`, region, resource); + } + } + }); + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/overutilizedEC2Instance.spec.js b/plugins/aws/ec2/overutilizedEC2Instance.spec.js new file mode 100644 index 000000000..96333a3a9 --- /dev/null +++ b/plugins/aws/ec2/overutilizedEC2Instance.spec.js @@ -0,0 +1,215 @@ +const expect = require('chai').expect; +const overutilizedEC2Instance = require('./overutilizedEC2Instance'); + +const describeInstances = [ + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0a985f18de454c879", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-18T22:48:08.000Z", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-role-1", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "Tags": [ + { + "Key": "app-tier", + "Value": "app-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-087ce52925d75c272" + }, + { + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-036d7bf13e0bfe836", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-ec2-role-2", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-1323e23rede231231" + } +]; + + +const ec2MetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 4.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 3.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 6.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 2.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 1.333, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 94.99, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 90.70, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 99.20, + "Unit": "Percent" + }, + ] + } +] + +const createCache = (instance, metrics) => { + if (instance && instance.length) var id = instance[0].Instances[0].InstanceId; + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: instance, + }, + }, + }, + cloudwatch: { + getEc2MetricStatistics: { + 'us-east-1': { + [id]: { + data: metrics + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + cloudwatch: { + getEc2MetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getEc2MetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('overutilizesEC2Instance', function () { + describe('run', function () { + it('should PASS if the EC2 Instance cpu utilization is less than 90 percent', function (done) { + const cache = createCache([describeInstances[0]], ec2MetricStatistics[0]); + overutilizedEC2Instance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if the EC2 Instance cpu utilization is more than 90 percent', function (done) { + const cache = createCache([describeInstances[1]], ec2MetricStatistics[1]); + overutilizedEC2Instance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no EC2 Instance found', function (done) { + const cache = createCache([]); + overutilizedEC2Instance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EC2 instances found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EC2 Instance', function (done) { + const cache = createErrorCache(); + overutilizedEC2Instance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EC2 instances: '); + done(); + }); + }); + + it('should not return any results if describe EC2 Instance response not found', function (done) { + const cache = createNullCache(); + overutilizedEC2Instance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/publicAmi.js b/plugins/aws/ec2/publicAmi.js index 1ae7b4599..54c679397 100644 --- a/plugins/aws/ec2/publicAmi.js +++ b/plugins/aws/ec2/publicAmi.js @@ -4,16 +4,21 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Public AMI', category: 'EC2', + domain: 'Compute', + severity: 'High', description: 'Checks for publicly shared AMIs', more_info: 'Accidentally sharing AMIs allows any AWS user to launch an EC2 instance using the image as a base. This can potentially expose sensitive information stored on the host.', link: 'http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html', recommended_action: 'Convert the public AMI a private image.', apis: ['EC2:describeImages'], + realtime_triggers: ['ec2:CreateImage', 'ec2:ResetImageAttribute', 'ec2:ModifyImageAttribute', 'ec2:DeregisterImage'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.ec2, function(region, rcb){ var describeImages = helpers.addSource(cache, source, @@ -42,7 +47,7 @@ module.exports = { found = true; helpers.addResult(results, 1, 'AMI is public', region, - 'arn:aws:ec2:' + region + '::image/' + image.ImageId); + `arn:${awsOrGov}:ec2:` + region + '::image/' + image.ImageId); } } diff --git a/plugins/aws/ec2/publicAmi.spec.js b/plugins/aws/ec2/publicAmi.spec.js new file mode 100644 index 000000000..00c206510 --- /dev/null +++ b/plugins/aws/ec2/publicAmi.spec.js @@ -0,0 +1,122 @@ +var expect = require('chai').expect; +const publicAmi = require('./publicAmi'); + +const describeImages = [ + { + ImageId: 'ami-0b8afcbfa2e909c96', + OwnerId: '111122223333', + State: 'available', + Public: false, + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: true + } + }], + }, + { + ImageId: 'ami-0b8afcbfa2e909c96', + OwnerId: '111122223333', + State: 'available', + Public: true, + BlockDeviceMappings: [{ + DeviceName: '/dev/xvda', + Ebs: { + DeleteOnTermination: true, + SnapshotId: 'snap-06889b875f1df9e9f', + VolumeSize: 8, + VolumeType: 'gp2', + Encrypted: true + } + }], + }, +] + +const createCache = (images) => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + data: images + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + err: { + message: 'error describing images' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeImages: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('publicAmi', function () { + describe('run', function () { + it('should PASS if no public AMIs found', function (done) { + const cache = createCache([describeImages[0]]); + publicAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if AMI is public', function (done) { + const cache = createCache([describeImages[1]]); + publicAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no AMIs found', function (done) { + const cache = createCache([]); + publicAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe images', function (done) { + const cache = createErrorCache(); + publicAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe images response not found', function (done) { + const cache = createNullCache(); + publicAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/publicIpAddress.js b/plugins/aws/ec2/publicIpAddress.js new file mode 100644 index 000000000..e767b9db2 --- /dev/null +++ b/plugins/aws/ec2/publicIpAddress.js @@ -0,0 +1,98 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Public IP Address EC2 Instances', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensures that EC2 instances do not have public IP address attached.', + more_info: 'EC2 instances should not have a public IP address attached in order to block public access to the instances.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-instance-addressing.html', + recommended_action: 'Remove the public IP address from the EC2 instances to block public access to the instance', + apis: ['EC2:describeInstances', 'STS:getCallerIdentity', 'EC2:describeSecurityGroups'], + realtime_triggers: ['ec2:RunInstances','ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + `Unable to query for EC2 instances: ${helpers.addError(describeInstances)}`, + region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, `Unable to query security groups: ${helpers.addError(describeSecurityGroups)}`, region); + return rcb(); + } + + var openSgs = []; + + for (var group of describeSecurityGroups.data) { + for (var permissions of group.IpPermissions) { + for (var range of permissions.IpRanges) { + if (range.CidrIp === '0.0.0.0/0') openSgs.push(group.GroupId); + } + for (var v6range of permissions.Ipv6Ranges) { + if (v6range.CidrIpv6 === '::/0') openSgs.push(group.GroupId); + } + } + } + + describeInstances.data.forEach(function(instance){ + if (!instance.Instances || !instance.Instances.length) { + helpers.addResult(results, 0, + 'EC2 instance description is not found', region); + return; + } + + instance.Instances.forEach(function(element){ + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:instance/${element.InstanceId}`; + var openSg = false; + for (var sg of element.SecurityGroups) { + if (openSgs.includes(sg.GroupId)) openSg = true; + } + + if (element.PublicIpAddress && element.PublicIpAddress.length && openSg) { + helpers.addResult(results, 2, + `EC2 instance "${element.InstanceId}" has a public IP address attached`, + region, resource); + } else if (element.PublicIpAddress && element.PublicIpAddress.length && !openSg) { + helpers.addResult(results, 0, + `EC2 instance "${element.InstanceId}" has a public IP address attached but attached security group is not open to public`, + region, resource); + } else { + helpers.addResult(results, 0, + `EC2 instance "${element.InstanceId}" does not have a public IP address attached`, + region, resource); + } + }); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/publicIpAddress.spec.js b/plugins/aws/ec2/publicIpAddress.spec.js new file mode 100644 index 000000000..e34bcec24 --- /dev/null +++ b/plugins/aws/ec2/publicIpAddress.spec.js @@ -0,0 +1,251 @@ +var expect = require('chai').expect; +const publicIpAddress = require('./publicIpAddress'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "PublicDnsName": "ec2-54-204-209-252.compute-1.amazonaws.com", + "PublicIpAddress": "54.204.209.252", + "NetworkInterfaces": [ + { + "Groups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-08dd2e14445b8c801" + } + ], + "Ipv6Addresses": [], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-08dd2e14445b8c801" + } + ] + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + }, + { + "Instances": [ + { + "NetworkInterfaces": [ + { + "Groups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-09ff2e14445b8c226" + } + ], + "Ipv6Addresses": [], + "Status": "in-use", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-09ff2e14445b8c226" + } + ] + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + }, + { + "Groups": [], + "Instances": [], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + }, + { + "Instances": [ + { + "NetworkInterfaces": [ + { + "Groups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-08dd2e14445b8c801" + } + ], + "Ipv6Addresses": [], + "Status": "in-use", + "SubnetId": "subnet-673a9a46", + "VpcId": "vpc-99de2fe4", + "InterfaceType": "interface" + } + ], + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-08dd2e14445b8c801" + } + ] + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + }, +]; + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "launch-wizard-2", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + }, + ], + "OwnerId": "560213429563", + "GroupId": "sg-08dd2e14445b8c801", + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "default VPC security group", + "GroupName": "sg-09ff2e14445b8c226", + "IpPermissions": [ + { + "FromPort": 0, + "IpProtocol": "tcp", + "IpRanges": [], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 65535, + }, + ], + "OwnerId": "560213429563", + "GroupId": "sg-aa941691", + "VpcId": "vpc-99de2fe4" + } +]; + +const createCache = (instances, sgs) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + describeSecurityGroups: { + 'us-east-1': { + data: sgs + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing EC2 instances' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + } + }, + }; +}; + +describe('publicIpAddress', function () { + describe('run', function () { + it('should PASS if EC2 instance does not have public IP address attached', function (done) { + const cache = createCache([describeInstances[1]], describeSecurityGroups); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if EC2 instance has public IP address attached but attached security group is not open to public', function (done) { + const cache = createCache([describeInstances[3]], describeSecurityGroups); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EC2 instance has public IP address attached', function (done) { + const cache = createCache([describeInstances[0]], describeSecurityGroups); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no EC2 instances found', function (done) { + const cache = createCache([]); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if EC2 instance description is not found', function (done) { + const cache = createCache([describeInstances[2]], describeSecurityGroups); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EC2 instances', function (done) { + const cache = createErrorCache(); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe instances response is not found', function (done) { + const cache = createNullCache(); + publicIpAddress.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/securityGroupRfc1918.js b/plugins/aws/ec2/securityGroupRfc1918.js new file mode 100644 index 000000000..abeab2151 --- /dev/null +++ b/plugins/aws/ec2/securityGroupRfc1918.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Open RFC 1918', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures EC2 security groups are configured to deny inbound traffic from RFC-1918 CIDRs', + more_info: 'RFC-1918 IP addresses are considered reserved private addresses and should not be used in security groups.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Subnets.html', + recommended_action: 'Modify the security group to deny private reserved addresses for inbound traffic', + apis: ['EC2:describeSecurityGroups'], + settings: { + private_cidrs: { + name: 'EC2 RFC 1918 CIDR Addresses', + description: 'A comma-separated list of CIDRs that indicates reserved private addresses', + regex: '^(?:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}/(?:[0-9]|[1-2][0-9]|3[0-2])(?:,(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}/(?:[0-9]|[1-2][0-9]|3[0-2]))*)?$', + default: '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' + } + }, + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:AuthorizeSecurityGroupIngress', 'ec2:RevokeSecurityGroupIngress', 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var private_cidrs = settings.private_cidrs || this.settings.private_cidrs.default; + private_cidrs = private_cidrs.split(','); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', region); + return rcb(); + } + + for (var g in describeSecurityGroups.data) { + var group = describeSecurityGroups.data[g]; + var resource = 'arn:' + awsOrGov + ':ec2:' + region + ':' + group.OwnerId + ':security-group/' + group.GroupId; + var privateCidrsFound = []; + + if (!group.IpPermissions || !group.IpPermissions.length) { + helpers.addResult(results, 0, + 'Security group :' + group.GroupName + ': does not have any IP permissions', region, resource); + continue; + } + + for (var p in group.IpPermissions) { + var permission = group.IpPermissions[p]; + + for (var r in permission.IpRanges) { + var cidrIp = permission.IpRanges[r].CidrIp; + + if (cidrIp && private_cidrs.includes(cidrIp)) { + if (!privateCidrsFound.includes(cidrIp)) { + privateCidrsFound.push(cidrIp); + } + } + } + } + if (!privateCidrsFound.length) { + helpers.addResult(results, 0, + 'Security group "' + group.GroupName + '" is not configured to allow inbound access from any source IP address within any reserved private addresses', + region, resource); + } else { + helpers.addResult(results, 2, + 'Security group "' + group.GroupName + '" is configured to allow inbound access from any source IP address within these reserved private addresses: ' + privateCidrsFound.join(', '), + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + + } +}; diff --git a/plugins/aws/ec2/securityGroupRfc1918.spec.js b/plugins/aws/ec2/securityGroupRfc1918.spec.js new file mode 100644 index 000000000..f835e5b70 --- /dev/null +++ b/plugins/aws/ec2/securityGroupRfc1918.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +const securityGroupRfc1918 = require('./securityGroupRfc1918'); + +const describeSecurityGroups = [ + { + "Description": "launch-wizard-4 created 2020-08-25T07:21:35.823+05:00", + "GroupName": "launch-wizard-4", + "IpPermissions": [ + { + "FromPort": 22, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + }, + { + "CidrIp": "10.0.0.0/8" + }, + { + "CidrIp": "172.16.0.0/12" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 22, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-0174d5e394e23015e", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "launch-wizard-3 created 2020-08-22T00:48:22.981+05:00", + "GroupName": "launch-wizard-3", + "IpPermissions": [ + { + "FromPort": 22, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 22, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-047e6cc36b13ec60e", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + { + "Description": "launch-wizard-3 created 2020-08-22T00:48:22.981+05:00", + "GroupName": "launch-wizard-3", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-047e6cc36b13ec60e", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } +] + +const createCache = (securityGroups) => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': { + data: securityGroups + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeSecurityGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('securityGroupRfc1918', function () { + describe('run', function () { + it('should FAIL if security group allows any reserved private address', function (done) { + const cache = createCache([describeSecurityGroups[0]]); + securityGroupRfc1918.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if security group does not allow any reserved private address', function (done) { + const cache = createCache([describeSecurityGroups[1]]); + securityGroupRfc1918.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if security group does not have Ip permissions configured', function (done) { + const cache = createCache([describeSecurityGroups[2]]); + securityGroupRfc1918.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to describe security groups', function (done) { + const cache = createNullCache(); + securityGroupRfc1918.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while describing security groups', function (done) { + const cache = createErrorCache(); + securityGroupRfc1918.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/securityGroupsHasTags.js b/plugins/aws/ec2/securityGroupsHasTags.js new file mode 100644 index 000000000..431c0587c --- /dev/null +++ b/plugins/aws/ec2/securityGroupsHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Security Group Has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that AWS Security Groups have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://aws.amazon.com/about-aws/whats-new/2021/07/amazon-ec2-adds-resource-identifiers-tags-vpc-security-groups-rules/', + recommended_action: 'Update Security Group and add Tags', + apis: ['EC2:describeSecurityGroups'], + realtime_triggers: ['ec2:CreateSecurityGroup', 'ec2:AddTags', 'ec2:DeleteTags','ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + for (var sg of describeSecurityGroups.data) { + const arn = `arn:${awsOrGov}:ec2:${region}:${sg.OwnerId}:security-group/${sg.GroupId}`; + if (!sg.Tags || !sg.Tags.length) { + helpers.addResult(results, 2, 'Security group does not have tags', region, arn); + } else { + helpers.addResult(results, 0, 'Security group has tags', region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/securityGroupsHasTags.spec.js b/plugins/aws/ec2/securityGroupsHasTags.spec.js new file mode 100644 index 000000000..dae8f0cba --- /dev/null +++ b/plugins/aws/ec2/securityGroupsHasTags.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +const securityGroupsHasTags = require('./securityGroupsHasTags'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4", + "Tags": [] + }, + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [], + "VpcId": "vpc-99de2fe4", + "Tags": [{key: "Key", value: "value"}] + }, +]; + +const createCache = (groups) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups, + err: null, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + } + }, + }; +}; + +describe('securityGroupsHasTags', function () { + describe('run', function () { + it('should PASS if default security group has Tags', function (done) { + const cache = createCache([describeSecurityGroups[1]]); + securityGroupsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if default security group does not Tags', function (done) { + const cache = createCache([describeSecurityGroups[0]]); + securityGroupsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + securityGroupsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe security groups', function (done) { + const cache = createErrorCache(); + securityGroupsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/subnetIpAvailability.js b/plugins/aws/ec2/subnetIpAvailability.js index c43d8b5db..4adb49689 100644 --- a/plugins/aws/ec2/subnetIpAvailability.js +++ b/plugins/aws/ec2/subnetIpAvailability.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Subnet IP Availability', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if a subnet is at risk of running out of IP addresses', more_info: 'Subnets have finite IP addresses. Running out of IP addresses could prevent resources from launching.', recommended_action: 'Add a new subnet with larger CIDR block and migrate resources.', @@ -23,6 +25,7 @@ module.exports = { default: 75 } }, + realtime_triggers: ['ec2:CreateSubnet', 'ec2:DeleteSubnet'], run: function(cache, settings, callback) { var config = { @@ -37,6 +40,7 @@ module.exports = { var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); async.each(regions.ec2, function(region, rcb){ @@ -56,22 +60,26 @@ module.exports = { return rcb(); } - for(var i in describeSubnets.data){ - var subnetSize = helpers.cidrSize(describeSubnets.data[i].CidrBlock); - var consumedIPs = subnetSize - describeSubnets.data[i].AvailableIpAddressCount; - var percentageConsumed = Math.ceil((consumedIPs / subnetSize) * 100); - var subnetArn = 'arn:aws:ec2:' + region + ':' + accountId + ':subnet/' + describeSubnets.data[i].SubnetId; + for (var i in describeSubnets.data){ + if (describeSubnets.data[i] && describeSubnets.data[i].CidrBlock) { + var subnetSize = helpers.cidrSize(describeSubnets.data[i].CidrBlock); + var consumedIPs = subnetSize - describeSubnets.data[i].AvailableIpAddressCount; + var percentageConsumed = Math.ceil((consumedIPs / subnetSize) * 100); + var subnetArn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':subnet/' + describeSubnets.data[i].SubnetId; - var returnMsg = 'Subnet ' + describeSubnets.data[i].SubnetId - + ' is using ' + consumedIPs + ' of ' - + subnetSize + ' (' + percentageConsumed + '%) available IPs.'; + var returnMsg = 'Subnet ' + describeSubnets.data[i].SubnetId + + ' is using ' + consumedIPs + ' of ' + + subnetSize + ' (' + percentageConsumed + '%) available IPs.'; - if (percentageConsumed >= config.subnet_ip_availability_percentage_fail) { - helpers.addResult(results, 2, returnMsg, region, subnetArn, custom); - } else if (percentageConsumed >= config.subnet_ip_availability_percentage_warn) { - helpers.addResult(results, 1, returnMsg, region, subnetArn, custom); + if (percentageConsumed >= config.subnet_ip_availability_percentage_fail) { + helpers.addResult(results, 2, returnMsg, region, subnetArn, custom); + } else if (percentageConsumed >= config.subnet_ip_availability_percentage_warn) { + helpers.addResult(results, 1, returnMsg, region, subnetArn, custom); + } else { + helpers.addResult(results, 0, returnMsg, region, subnetArn, custom); + } } else { - helpers.addResult(results, 0, returnMsg, region, subnetArn, custom); + helpers.addResult(results, 3, 'No CIDR data found', region); } } rcb(); diff --git a/plugins/aws/ec2/subnetIpAvailability.spec.js b/plugins/aws/ec2/subnetIpAvailability.spec.js new file mode 100644 index 000000000..447cce06b --- /dev/null +++ b/plugins/aws/ec2/subnetIpAvailability.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +const subnetIpAvailability = require('./subnetIpAvailability'); + +const describeSubnets = [ + { + "AvailabilityZone": "us-east-1b", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 1500, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1b", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 1000, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1b", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 200, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-aac6b3e7" + }, +]; + + +const createCache = (subnets) => { + return { + ec2:{ + describeSubnets: { + 'us-east-1': { + data: subnets + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSubnets: { + 'us-east-1': { + err: { + message: 'error describing subnets' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSubnets: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('subnetIpAvailability', function () { + describe('run', function () { + it('should PASS if subnet is using IPs less than the defined warn percentage', function (done) { + const cache = createCache([describeSubnets[0]]); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if subnet is using IPs within the defined warn percentage', function (done) { + const cache = createCache([describeSubnets[1]]); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL subnet is using IPs more than the defined fail percentage', function (done) { + const cache = createCache([describeSubnets[2]]); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no subnets found', function (done) { + const cache = createCache([]); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe subnets', function (done) { + const cache = createErrorCache(); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe subnets response not found', function (done) { + const cache = createNullCache(); + subnetIpAvailability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/unassociatedElasticIp.js b/plugins/aws/ec2/unassociatedElasticIp.js new file mode 100644 index 000000000..23ca9de79 --- /dev/null +++ b/plugins/aws/ec2/unassociatedElasticIp.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unassociated Elastic IP Addresses', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures all EIPs are allocated to a resource to avoid accidental usage or reuse and to save costs', + more_info: 'EIPs should be deleted if they are not in use to avoid extra charges.', + recommended_action: 'Delete the unassociated Elastic IP', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html', + apis: ['EC2:describeAddresses', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:AllocateAddress','ec2:ReleaseAddress'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + var describeAddresses = helpers.addSource(cache, source, + ['ec2', 'describeAddresses', region]); + + if (!describeAddresses) return rcb(); + + if (describeAddresses.err || !describeAddresses.data) { + helpers.addResult(results, 3, + `Unable to query for Elastic IP Addresses: ${helpers.addError(describeAddresses)}`, region); + return rcb(); + } + + if (!describeAddresses.data.length) { + helpers.addResult(results, 0, 'No Elastic IP Addresses found', region); + return rcb(); + } + + describeAddresses.data.forEach(function(elasticIp){ + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:eip/${elasticIp.AllocationId}`; + + if (elasticIp.AssociationId) { + helpers.addResult(results, 0, `Elastic IP address ${elasticIp.AllocationId} is associated to a resource`, + region, resource); + } else { + helpers.addResult(results, 2, `Elastic IP address ${elasticIp.AllocationId} is not associated to any resource`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/unassociatedElasticIp.spec.js b/plugins/aws/ec2/unassociatedElasticIp.spec.js new file mode 100644 index 000000000..bd1903a68 --- /dev/null +++ b/plugins/aws/ec2/unassociatedElasticIp.spec.js @@ -0,0 +1,109 @@ +var expect = require('chai').expect; +const unassociatedElasticIp = require('./unassociatedElasticIp'); + +const describeAddresses = [ + { + "InstanceId": "i-03afb9daa31f31bb0", + "PublicIp": "18.235.251.59", + "AllocationId": "eipalloc-00806f12608a4e3ca", + "AssociationId": "eipassoc-03c6b0f3a49c40072", + "Domain": "vpc", + "NetworkInterfaceId": "eni-0a53de7b449ed51e0", + "NetworkInterfaceOwnerId": "111122223333", + "PrivateIpAddress": "172.31.54.187", + "PublicIpv4Pool": "amazon", + "NetworkBorderGroup": "us-east-1" + }, + { + "PublicIp": "18.235.251.59", + "AllocationId": "eipalloc-00806f12608a4e3ca", + "Domain": "vpc", + "PublicIpv4Pool": "amazon", + "NetworkBorderGroup": "us-east-1" + } +] + +const createCache = (addresses) => { + return { + ec2: { + describeAddresses: { + 'us-east-1': { + data: addresses + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeAddresses: { + 'us-east-1': { + err: { + message: 'error describing ec2 instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeAddresses: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('unassociatedElasticIp', function () { + describe('run', function () { + it('should PASS if Elastic IP address is associated to a resource', function (done) { + const cache = createCache([describeAddresses[0]]); + unassociatedElasticIp.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Elastic IP address is not associated to any resource', function (done) { + const cache = createCache([describeAddresses[1]]); + unassociatedElasticIp.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Elastic IP address found', function (done) { + const cache = createCache([]); + unassociatedElasticIp.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to fetch Elastic IP addresses', function (done) { + const cache = createNullCache(); + unassociatedElasticIp.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error describing Elastic IP addresses', function (done) { + const cache = createErrorCache(); + unassociatedElasticIp.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/unusedAmi.js b/plugins/aws/ec2/unusedAmi.js new file mode 100644 index 000000000..7957ca005 --- /dev/null +++ b/plugins/aws/ec2/unusedAmi.js @@ -0,0 +1,127 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused Amazon Machine Images', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that all Amazon Machine Images are in use to ensure cost optimization.', + more_info: 'All unused/deregistered Amazon Machine Images should be deleted to avoid extraneous cost.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html', + recommended_action: 'Delete the unused/deregistered AMIs', + apis: ['EC2:describeImages', 'EC2:describeInstances', 'EC2:describeLaunchTemplates', 'EC2:describeLaunchTemplateVersions', + 'AutoScaling:describeLaunchConfigurations', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateImage','ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var usedAmis = []; + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + `Unable to query for AMIs: ${helpers.addError(describeImages)}`, + region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, + 'No Amazon Machine Images found', region); + return rcb(); + } + + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + var describeLaunchConfigurations = helpers.addSource(cache, source, + ['autoscaling', 'describeLaunchConfigurations', region]); + + var describeLaunchTemplates = helpers.addSource(cache, source, + ['ec2', 'describeLaunchTemplates', region]); + + if (!describeInstances || describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + `Unable to query EC2 instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeLaunchConfigurations || describeLaunchConfigurations.err || !describeLaunchConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query Auto Scaling launch configurations: ${helpers.addError(describeLaunchConfigurations)}`, region); + return rcb(); + } + + if (!describeLaunchTemplates || describeLaunchTemplates.err || !describeLaunchTemplates.data) { + helpers.addResult(results, 3, + `Unable to query EC2 launch templates: ${helpers.addError(describeLaunchTemplates)}`, region); + return rcb(); + } + + describeLaunchTemplates.data.forEach(template=>{ + var describeLaunchTemplateVersions = helpers.addSource(cache, source, + ['ec2', 'describeLaunchTemplateVersions', region, template.LaunchTemplateId]); + + if (describeLaunchTemplateVersions && + describeLaunchTemplateVersions.data && + describeLaunchTemplateVersions.data.LaunchTemplateVersions) { + let templateVersion = describeLaunchTemplateVersions.data.LaunchTemplateVersions.find(version => version.VersionNumber == template.DefaultVersionNumber); + let imageId = (templateVersion && templateVersion.LaunchTemplateData && templateVersion.LaunchTemplateData.ImageId) ? + templateVersion.LaunchTemplateData.ImageId : null; + if (imageId && !usedAmis.includes(imageId)) usedAmis.push(imageId); + } + }); + + if (describeInstances.data.length) { + describeInstances.data.forEach(instance => { + if (instance.Instances && instance.Instances.length) { + instance.Instances.forEach(element => { + if (element.ImageId && !usedAmis.includes(element.ImageId)) { + usedAmis.push(element.ImageId); + } + }); + } + }); + } + + if (describeLaunchConfigurations.data.length) { + describeLaunchConfigurations.data.forEach(config => { + if (config.ImageId && !usedAmis.includes(config.ImageId)) { + usedAmis.push(config.ImageId); + } + }); + } + + describeImages.data.forEach(image => { + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:image/${image.ImageId}`; + + if (usedAmis.includes(image.ImageId)) { + helpers.addResult(results, 0, + `Amazon Machine Image "${image.ImageId}" is in use`, + region, resource); + } else { + helpers.addResult(results, 2, + `Amazon Machine Image "${image.ImageId}" is not in use`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/unusedAmi.spec.js b/plugins/aws/ec2/unusedAmi.spec.js new file mode 100644 index 000000000..ed42e5cd0 --- /dev/null +++ b/plugins/aws/ec2/unusedAmi.spec.js @@ -0,0 +1,290 @@ +var expect = require('chai').expect; +const unusedAmi = require('./unusedAmi'); + +const describeImages = [ + { + "Architecture": "x86_64", + "CreationDate": "2020-10-14T22:59:03.000Z", + "ImageId": "ami-026c295331eb10e50", + "ImageLocation": "111122223333/test-32", + "ImageType": "machine", + "Public": false, + "OwnerId": "111122223333", + "PlatformDetails": "Linux/UNIX", + "State": "available", + "Name": "test-32" + }, + { + "Architecture": "x86_64", + "CreationDate": "2020-10-14T22:59:03.000Z", + "ImageId": "ami-00000000000000000", + "ImageLocation": "111122223333/test-32", + "ImageType": "machine", + "Public": false, + "OwnerId": "111122223333", + "PlatformDetails": "Linux/UNIX", + "UsageOperation": "RunInstances", + "State": "available", + "Name": "test-32" + } +]; + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-026c295331eb10e50", + "InstanceId": "i-023c9bc2aed01cc5e", + "InstanceType": "t2.micro", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-09ff2e14445b8c226" + } + ] + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-0c8617b20269c4de0" + } +]; + +const describeLaunchConfigurations = [ + { + "LaunchConfigurationName": "test-32", + "LaunchConfigurationARN": "arn:aws:autoscaling:us-east-1:111122223333:launchConfiguration:79298288-6ac0-4031-bf3c-05aa13c64bbc:launchConfigurationName/test-32", + "ImageId": "ami-026c295331eb10e50", + "KeyName": "auto-scaling-test-instance", + "SecurityGroups": [ + "sg-099a21ef57db4bad1" + ], + "ClassicLinkVPCSecurityGroups": [], + "UserData": "", + "InstanceType": "t2.micro", + "KernelId": "", + "RamdiskId": "", + "BlockDeviceMappings": [], + "InstanceMonitoring": { + "Enabled": false + }, + "IamInstanceProfile": "arn:aws:iam::111122223333:instance-profile/EMR_EC2_DefaultRole", + "CreatedTime": "2020-10-14T23:22:41.431Z", + "EbsOptimized": false + }, +]; + +const describeLaunchTemplates = [ + { + "LaunchTemplateId": "lt-0219ac0443364d22d", + "LaunchTemplateName": "Test-lt", + "CreateTime": "2021-07-19T12:32:41.000Z", + "CreatedBy": "arn:aws:iam::111111111111:user/user", + "DefaultVersionNumber": 4, + "LatestVersionNumber": 4, + "Tags": [] + } +] + +const describeLaunchTemplateVersions = [ + { + "LaunchTemplateVersions": [ + { + "LaunchTemplateId": "lt-0219ac0443364d22d", + "LaunchTemplateName": "Test-lt", + "VersionNumber": 4, + "CreateTime": "2021-07-19T13:58:15.000Z", + "CreatedBy": "arn:aws:iam::111111111111:user/user", + "DefaultVersion": true, + "LaunchTemplateData": { + "EbsOptimized": false, + "BlockDeviceMappings": [], + "NetworkInterfaces": [], + "ImageId": "ami-026c295331eb10e50", + "InstanceType": "t1.micro", + "KeyName": "user-kp", + "TagSpecifications": [], + "ElasticGpuSpecifications": [], + "ElasticInferenceAccelerators": [], + "SecurityGroupIds": [ + "sg-043778823f73431a7", + "sg-02e2c70cd463dca29" + ], + "SecurityGroups": [], + "LicenseSpecifications": [] + } + }, + ] + }, +] + +const createCache = (images, instances, launchConfig, launchTemplate, launchTemplateVersion) => { + return { + ec2:{ + describeImages: { + 'us-east-1': { + data: images + }, + }, + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + describeLaunchTemplates: { + 'us-east-1': { + data: launchTemplate + }, + }, + describeLaunchTemplateVersions: { + 'us-east-1': { + "lt-0219ac0443364d22d": { + data: launchTemplateVersion + } + }, + }, + }, + autoscaling:{ + describeLaunchConfigurations: { + 'us-east-1': { + data: launchConfig + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing EC2 AMIs' + }, + }, + }, + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing EC2 instances' + }, + }, + }, + describeLaunchTemplates: { + 'us-east-1': { + err: { + message: 'error describing launch templates' + } + }, + }, + describeLaunchTemplateVersions: { + 'us-east-1': { + err: { + message: 'error describing launch template versions' + } + }, + }, + }, + autoscaling: { + describeLaunchConfigurations:{ + 'us-east-1': { + err: { + message: 'error describing Auto Scaling launch configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null, + }, + describeInstances: { + 'us-east-1': null, + }, + describeLaunchTemplates: { + 'us-east-1': null, + }, + describeLaunchTemplateVersions: { + 'us-east-1': null, + }, + }, + autoscaling: { + describeLaunchConfigurations:{ + 'us-east-1': null, + }, + }, + }; +}; + +describe('unusedAmi', function () { + describe('run', function () { + it('should PASS if Amazon Machine Image is in use', function (done) { + const cache = createCache([describeImages[0]], [describeInstances[0]], describeLaunchConfigurations[0], [describeLaunchTemplates[0]], describeLaunchTemplateVersions[0]); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Amazon Machine Image is used by launch template', function (done) { + const cache = createCache([describeImages[0]], [], [], [describeLaunchTemplates[0]], describeLaunchTemplateVersions[0]); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Amazon Machine Image is not in use', function (done) { + const cache = createCache([describeImages[1]], [describeInstances[0]], describeLaunchConfigurations[0], [describeLaunchTemplates[0]], describeLaunchTemplateVersions[0]); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Amazon Machine Image is not in use ', function (done) { + const cache = createCache([describeImages[1]], [], [], []); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Amazon Machine Images found', function (done) { + const cache = createCache([]); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe images', function (done) { + const cache = createErrorCache(); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe images response not found', function (done) { + const cache = createNullCache(); + unusedAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/unusedEni.js b/plugins/aws/ec2/unusedEni.js new file mode 100644 index 000000000..388bb30b8 --- /dev/null +++ b/plugins/aws/ec2/unusedEni.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused Elastic Network Interfaces', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that unused AWS Elastic Network Interfaces (ENIs) are removed.', + more_info: 'Unused AWS ENIs should be removed to follow best practices and to avoid reaching the service limit.', + recommended_action: 'Delete the unused AWS Elastic Network Interfaces', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html', + apis: ['EC2:describeNetworkInterfaces', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateNetworkInterface','ec2:DeleteNetworkInterface'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb) { + var describeNetworkInterfaces = helpers.addSource(cache, source, + ['ec2', 'describeNetworkInterfaces', region]); + + if (!describeNetworkInterfaces) return rcb(); + + if (describeNetworkInterfaces.err || !describeNetworkInterfaces.data) { + helpers.addResult(results, 3, + `Unable to query AWS ENIs: ${helpers.addError(describeNetworkInterfaces)}`, region); + return rcb(); + } + + if (!describeNetworkInterfaces.data.length) { + helpers.addResult(results, 0, 'No AWS ENIs found', region); + return rcb(); + } + + describeNetworkInterfaces.data.forEach(function(eni){ + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:network-interface/${eni.NetworkInterfaceId}`; + + if (eni.Status && eni.Status === 'in-use') { + helpers.addResult(results, 0, + `AWS ENI "${eni.NetworkInterfaceId}" is in use`, + region, resource); + } else { + helpers.addResult(results, 2, + `AWS ENI "${eni.NetworkInterfaceId}" is not in use`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/unusedEni.spec.js b/plugins/aws/ec2/unusedEni.spec.js new file mode 100644 index 000000000..64e0c235c --- /dev/null +++ b/plugins/aws/ec2/unusedEni.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +const unusedEni = require('./unusedEni'); + +const describeNetworkInterfaces = [ + { + "Attachment": { + "AttachTime": "2020-10-14T22:57:33.000Z", + "AttachmentId": "eni-attach-0aa55688e0a7acc05", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "InstanceId": "i-02cd6ecf4fb6f634d", + "InstanceOwnerId": "112233445566", + "Status": "attached" + }, + "AvailabilityZone": "us-east-1e", + "Description": "", + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "06:77:f1:13:ed:e3", + "NetworkInterfaceId": "eni-0984f74e07528ea22", + "OwnerId": "112233445566", + "Status": "in-use" + }, + { + "Association": { + "IpOwnerId": "amazon-elb", + "PublicDnsName": "ec2-52-44-135-154.compute-1.amazonaws.com", + "PublicIp": "52.44.135.154" + }, + "Attachment": { + "AttachTime": "2020-10-24T05:21:37.000Z", + "AttachmentId": "eni-attach-05ba877d1ad0030a5", + "DeleteOnTermination": false, + "DeviceIndex": 1, + "InstanceOwnerId": "amazon-elb", + "Status": "attached" + }, + "AvailabilityZone": "us-east-1b", + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:07:6f:a1:5b:bf", + "NetworkInterfaceId": "eni-0f8fe766438d3a131", + "OwnerId": "112233445566", + "Status": "available", + } +]; + +const createCache = (eni) => { + return { + ec2:{ + describeNetworkInterfaces: { + 'us-east-1': { + data: eni + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeNetworkInterfaces: { + 'us-east-1': { + err: { + message: 'error describing AWS Elastic Network Interfaces' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('unusedEni', function () { + describe('run', function () { + it('should PASS if AWS ENI is in use', function (done) { + const cache = createCache([describeNetworkInterfaces[0]]); + unusedEni.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if AWS ENI is not in use', function (done) { + const cache = createCache([describeNetworkInterfaces[1]]); + unusedEni.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no AWS ENIs found', function (done) { + const cache = createCache([]); + unusedEni.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe AWS ENIs', function (done) { + const cache = createErrorCache(); + unusedEni.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe network interfaces response not found', function (done) { + const cache = createNullCache(); + unusedEni.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/unusedSecurityGroups.js b/plugins/aws/ec2/unusedSecurityGroups.js new file mode 100644 index 000000000..bf58e28b8 --- /dev/null +++ b/plugins/aws/ec2/unusedSecurityGroups.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused Security Groups', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Identify and remove unused EC2 security groups.', + more_info: 'Keeping the number of security groups to a minimum makes the management easier and helps to avoid reaching the service limit.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html', + recommended_action: 'Remove security groups that are not being used.', + apis: ['EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'Lambda:listFunctions'], + realtime_triggers: ['ec2:CreateSecurityGroup','ec2:DeleteSecurityGroup','ec2:RunInstances','ec2:ModifyInstanceAttribute', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + + if (!describeSecurityGroups) return rcb(); + + if (describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + return rcb(); + } + + if (!describeSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups present', region); + return rcb(); + } + + var groups = describeSecurityGroups.data; + var usedGroups = helpers.getUsedSecurityGroups(cache, results, region); + if (usedGroups && usedGroups.length && usedGroups[0] === 'Error') return rcb(); + for (var g in groups) { + var resource = `arn:${awsOrGov}:ec2:` + region + ':' + groups[g].OwnerId + ':security-group/' + + groups[g].GroupId; + if (groups[g].GroupId && usedGroups && usedGroups.includes(groups[g].GroupId)) { + helpers.addResult(results, 0, 'Security group is being used', region, resource); + } else { + helpers.addResult(results, 2, 'Security group is not being used', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/unusedSecurityGroups.spec.js b/plugins/aws/ec2/unusedSecurityGroups.spec.js new file mode 100644 index 000000000..70afb8067 --- /dev/null +++ b/plugins/aws/ec2/unusedSecurityGroups.spec.js @@ -0,0 +1,254 @@ +var expect = require('chai').expect; +const unusedSecurityGroups = require('./unusedSecurityGroups'); + +const describeSecurityGroups = [ + { + "Description": "default VPC security group", + "GroupName": "default", + "IpPermissions": [ + { + "IpProtocol": "-1", + "IpRanges": [], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [ + { + "GroupId": "sg-aa941691", + "UserId": "111122223333" + } + ] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-aa941691", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + }, + +]; + +const describeNetworkInterfaces = [ + { + "AvailabilityZone": "us-east-1b", + "Description": "RDSNetworkInterface", + "Groups": [ + { + "GroupName": "default", + "GroupId": "sg-aa941691" + }, + { + "GroupName": "HTTP-Access", + "GroupId": "sg-02e2c70cd463dca29" + }, + ], + "InterfaceType": "interface", + "Ipv6Addresses": [], + "MacAddress": "12:95:7b:ae:63:91", + "NetworkInterfaceId": "eni-0681cbf0930452492", + "OwnerId": "111122223333", + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52", + "PrivateIpAddresses": [ + { + "Primary": true, + "PrivateDnsName": "ip-172-31-93-52.ec2.internal", + "PrivateIpAddress": "172.31.93.52" + } + ], + "Ipv4Prefixes": [], + "Ipv6Prefixes": [], + "RequesterId": "amazon-rds", + "RequesterManaged": true, + "SourceDestCheck": true, + "Status": "available", + "SubnetId": "subnet-673a9a46", + "TagSet": [], + "VpcId": "vpc-99de2fe4" + }, +]; + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::111122223333:role/lambda-role", + "Handler": "index.handler", + "CodeSize": 304, + "Description": "", + "Timeout": 3, + "MemorySize": 128, + "LastModified": "2020-12-23T06:58:12.289+0000", + "CodeSha256": "1LbkWTlxbeGxWCDcSB1hyIcv/HzJ6W3w6sibCRvjfAU=", + "Version": "$LATEST", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + "Environment": { + "Variables": { + "password": "fastabc123", + "key": "AQICA=" + } + }, + "KMSKeyArn": null, + "TracingConfig": { + "Mode": "Active" + }, + "MasterArn": null, + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952", + "State": null, + "StateReason": null, + "StateReasonCode": null, + "LastUpdateStatus": null, + "LastUpdateStatusReason": null, + "LastUpdateStatusReasonCode": null, + "PackageType": "Zip", + "SigningProfileVersionArn": null, + "SigningJobArn": null + }, +] + + +const createCache = (groups, interfaces, functions) => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + data: groups + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + data: interfaces + }, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': { + data: functions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': { + err: { + message: 'error describing security groups' + }, + }, + }, + describeNetworkInterfaces: { + 'us-east-1': { + err: { + message: 'error describing network interfaces' + }, + }, + } + }, + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing lambda functions' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeSecurityGroups: { + 'us-east-1': null, + }, + describeNetworkInterfaces: { + 'us-east-1': null, + }, + }, + lambda: { + listFunctions: { + 'us-east-1': null, + } + } + }; +}; + +describe('unusedSecurityGroups', function () { + describe('run', function () { + it('should PASS if Security group is being used', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeNetworkInterfaces[0]], [listFunctions[0]]); + unusedSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security group is being used'); + done(); + }); + }); + + it('should FAIL if Security group is not being used', function (done) { + const cache = createCache([describeSecurityGroups[0]], [describeSecurityGroups[0]], [listFunctions[0]]); + unusedSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group is not being used'); + done(); + }); + }); + + it('should PASS if no security groups found', function (done) { + const cache = createCache([]); + unusedSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups present'); + done(); + }); + }); + + it('should UNKNWON Unable to query for security groups', function (done) { + const cache = createErrorCache(); + unusedSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security groups'); + done(); + }); + }); + + it('should not return anything if describe security groups response not found', function (done) { + const cache = createNullCache(); + unusedSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/unusedVirtualPrivateGateway.js b/plugins/aws/ec2/unusedVirtualPrivateGateway.js new file mode 100644 index 000000000..21f79156a --- /dev/null +++ b/plugins/aws/ec2/unusedVirtualPrivateGateway.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused Virtual Private Gateway', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that unused Virtual Private Gateways (VGWs) are removed.', + more_info: 'Unused VGWs should be remove to follow best practices and to avoid reaching the service limit.', + link: 'https://docs.aws.amazon.com/vpn/latest/s2svpn/delete-vpn.html', + recommended_action: 'Remove the unused Virtual Private Gateways (VGWs)', + apis: ['EC2:describeVpnGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpnGateway', 'ec2:DeleteVpnGateway','ec2:AttachVpnGateway','ec2:DetachVpnGateway'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeVpnGateways = helpers.addSource(cache, source, + ['ec2', 'describeVpnGateways', region]); + + if (!describeVpnGateways) return rcb(); + + if (describeVpnGateways.err || !describeVpnGateways.data) { + helpers.addResult(results, 3, + `Unable to query for Virtual Private Gateways: ${helpers.addError(describeVpnGateways)}`, + region); + return rcb(); + } + + if (!describeVpnGateways.data.length) { + helpers.addResult(results, 0, + 'No Virtual Private Gateways found', region); + return rcb(); + } + + describeVpnGateways.data.forEach(function(vpn){ + var resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:vpn-gateway/${vpn.VpnGatewayId}`; + var vpnAttached = false; + if (vpn.VpcAttachments && vpn.VpcAttachments.length) { + for (var v in vpn.VpcAttachments) { + var attachment = vpn.VpcAttachments[v]; + if (attachment.State && attachment.State === 'attached') { + vpnAttached = true; + break; + } + } + } + + if (vpnAttached) { + helpers.addResult(results, 0, + `Virtual Private Gateway "${vpn.VpnGatewayId}" is in use`, + region, resource); + } else { + helpers.addResult(results, 2, + `Virtual Private Gateway "${vpn.VpnGatewayId}" is not in use`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/unusedVirtualPrivateGateway.spec.js b/plugins/aws/ec2/unusedVirtualPrivateGateway.spec.js new file mode 100644 index 000000000..106786175 --- /dev/null +++ b/plugins/aws/ec2/unusedVirtualPrivateGateway.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +const unusedVGW = require('./unusedVirtualPrivateGateway'); + +const describeVpnGateways = [ + { + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [ + { + "State": "attached", + "VpcId": "vpc-99de2fe4" + } + ], + "VpnGatewayId": "vgw-049df54387fd42105", + "AmazonSideAsn": 64512, + "Tags": [ + { + "Key": "Name", + "Value": "test-69" + } + ] + }, + { + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [ + { + "State": "detached", + "VpcId": "vpc-99de2fe4" + } + ], + "VpnGatewayId": "vgw-049df54387fd42105", + "AmazonSideAsn": 64512, + "Tags": [ + { + "Key": "Name", + "Value": "test-69" + } + ] + }, + { + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [], + "VpnGatewayId": "vgw-049df54387fd42105", + "AmazonSideAsn": 64512, + "Tags": [ + { + "Key": "Name", + "Value": "test-69" + } + ] + } +]; + +const createCache = (describeVpnGateways) => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': { + data: describeVpnGateways + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': { + err: { + message: 'error describing Virtual Private Gateways' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('unusedVGW', function () { + describe('run', function () { + it('should PASS if Virtual Private Gateway is in use', function (done) { + const cache = createCache([describeVpnGateways[0]]); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Virtual Private Gateway is not in use', function (done) { + const cache = createCache([describeVpnGateways[1]]); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Virtual Private Gateway does not have any VPC attachment', function (done) { + const cache = createCache([describeVpnGateways[2]]); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No Virtual Private Gateways found', function (done) { + const cache = createCache([]); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Virtual Private Gateways', function (done) { + const cache = createErrorCache(); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Virtual Private Gateways response not found', function (done) { + const cache = createNullCache(); + unusedVGW.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/unusedVpcInternetGateways.js b/plugins/aws/ec2/unusedVpcInternetGateways.js new file mode 100644 index 000000000..42a8c20d3 --- /dev/null +++ b/plugins/aws/ec2/unusedVpcInternetGateways.js @@ -0,0 +1,94 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused VPC Internet Gateways', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that unused VPC Internet Gateways and Egress-Only Internet Gateways are removed.', + more_info: 'Unused VPC Internet Gateways and Egress-Only Internet Gateways must be removed to avoid reaching the internet gateway limit.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html', + recommended_action: 'Remove the unused/detached Internet Gateways and Egress-Only Internet Gateways', + apis: ['EC2:describeInternetGateways', 'EC2:describeEgressOnlyInternetGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateInternetGateway', 'ec2:DeleteInternetGateway','ec2:AttachInternetGateway','ec2:DetachInternetGateway','ec2:CreateEgressOnlyInternetGateway','ec2:DeleteEgressOnlyInternetGateway'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + async.parallel([ + function(lcb){ + var describeInternetGateways = helpers.addSource(cache, source, + ['ec2', 'describeInternetGateways', region]); + + if (!describeInternetGateways) return lcb(); + + if (describeInternetGateways.err || !describeInternetGateways.data) { + helpers.addResult(results, 3, + `Unable to query for Internet Gateways: ${helpers.addError(describeInternetGateways)}`, region); + return lcb(); + } + + if (!describeInternetGateways.data.length) { + helpers.addResult(results, 0, 'No Internet Gateways found', region); + } + + let resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:internet-gateway`; + loopGWForResults(describeInternetGateways, results, region, resource); + + lcb(); + }, + function(lcb){ + var describeEgressOnlyInternetGateways = helpers.addSource(cache, source, + ['ec2', 'describeEgressOnlyInternetGateways', region]); + + if (!describeEgressOnlyInternetGateways) return lcb(); + + if (describeEgressOnlyInternetGateways.err || !describeEgressOnlyInternetGateways.data) { + helpers.addResult(results, 3, + `Unable to query for Egress-Only Internet Gateways: ${helpers.addError(describeEgressOnlyInternetGateways)}`, + region); + return lcb(); + } + + if (!describeEgressOnlyInternetGateways.data.length) { + helpers.addResult(results, 0, 'No Egress-Only Internet Gateways found', region); + } + + let resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:egress-only-internet-gateway`; + loopGWForResults(describeEgressOnlyInternetGateways, results, region, resource, 'Egress-Only'); + + lcb(); + } + ], function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; + +function loopGWForResults(gateways, results, region, resource, type = '') { + gateways.data.forEach(function(gateway){ + let gatewayId = gateway.EgressOnlyInternetGatewayId || gateway.InternetGatewayId; + resource = `${resource}/${gatewayId}`; + + if (gateway.Attachments && gateway.Attachments.length) { + helpers.addResult(results, 0, + `${type} Internet Gateway "${gatewayId}" is in use`, + region, resource); + } else { + helpers.addResult(results, 2, + `${type} Internet Gateway "${gatewayId}" in not in use`, + region, resource); + } + }); +} \ No newline at end of file diff --git a/plugins/aws/ec2/unusedVpcInternetGateways.spec.js b/plugins/aws/ec2/unusedVpcInternetGateways.spec.js new file mode 100644 index 000000000..e00aa806c --- /dev/null +++ b/plugins/aws/ec2/unusedVpcInternetGateways.spec.js @@ -0,0 +1,192 @@ +var expect = require('chai').expect; +const unusedVpcInternetGateways = require('./unusedVpcInternetGateways'); + +const describeInternetGateways = [ + { + "Attachments": [ + { + "State": "available", + "VpcId": "vpc-99de2fe4" + } + ], + "InternetGatewayId": "igw-7f3e1a04", + "OwnerId": "111122223333", + "Tags": [] + }, + { + "Attachments": [], + "InternetGatewayId": "igw-0a82fd444d2c310d1", + "OwnerId": "111122223333", + "Tags": [ + { + "Key": "Name", + "Value": "test-64" + } + ] + } +]; + +const describeEgressOnlyInternetGateways = [ + { + "Attachments": [ + { + "State": "attached", + "VpcId": "vpc-99de2fe4" + } + ], + "EgressOnlyInternetGatewayId": "eigw-05eff80eabd1ea8e0", + "Tags": [ + { + "Key": "Name", + "Value": "test-64-egress" + } + ] + }, + { + "Attachments": [], + "EgressOnlyInternetGatewayId": "eigw-05eff80eabd1ea8e0", + "Tags": [ + { + "Key": "Name", + "Value": "test-64-egress" + } + ] + } +]; + + +const createCache = (ig, eig) => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': { + data: ig + }, + }, + describeEgressOnlyInternetGateways: { + 'us-east-1': { + data: eig + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': { + err: { + message: 'error describing Internet Gateways' + }, + }, + }, + describeEgressOnlyInternetGateways: { + 'us-east-1': { + err: { + message: 'error describing Egress-Only Internet Gateways' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInternetGateways: { + 'us-east-1': null, + }, + describeEgressOnlyInternetGateways: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('unusedVpcInternetGateways', function () { + describe('run', function () { + it('should PASS if Internet Gateway is in use', function (done) { + const cache = createCache([describeInternetGateways[0]], [describeEgressOnlyInternetGateways[0]]); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Egress-Only Internet Gateway is in use', function (done) { + const cache = createCache([describeInternetGateways[0]], [describeEgressOnlyInternetGateways[0]]); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Internet Gateway is not in use', function (done) { + const cache = createCache([describeInternetGateways[1]], [describeEgressOnlyInternetGateways[0]]); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Egress-Only Internet Gateway is not in use', function (done) { + const cache = createCache([describeInternetGateways[1]], [describeEgressOnlyInternetGateways[1]]); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Internet Gateway', function (done) { + const cache = createErrorCache(); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Egress-Only Internet Gateway', function (done) { + const cache = createErrorCache(); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Internet Gateways found', function (done) { + const cache = createCache([]); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Egress-Only Internet Gateways found', function (done) { + const cache = createCache([], []); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should not return anything if describe internet gateways response is not found', function (done) { + const cache = createNullCache(); + unusedVpcInternetGateways.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/vpcElasticIpLimit.js b/plugins/aws/ec2/vpcElasticIpLimit.js index 57774e703..190c2c146 100644 --- a/plugins/aws/ec2/vpcElasticIpLimit.js +++ b/plugins/aws/ec2/vpcElasticIpLimit.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'VPC Elastic IP Limit', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Determine if the number of allocated VPC EIPs is close to the AWS per-account limit', more_info: 'AWS limits accounts to certain numbers of resources. Exceeding those limits could prevent resources from launching.', recommended_action: 'Contact AWS support to increase the number of EIPs available', diff --git a/plugins/aws/ec2/vpcElasticIpLimit.spec.js b/plugins/aws/ec2/vpcElasticIpLimit.spec.js new file mode 100644 index 000000000..045c152a9 --- /dev/null +++ b/plugins/aws/ec2/vpcElasticIpLimit.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +const vpcElasticIpLimit = require('./vpcElasticIpLimit'); + +const describeAccountAttributes = [ + [ + { + "AttributeName": "max-elastic-ips", + "AttributeValues": [ + { + "AttributeValue": "5" + } + ] + }, + ] +]; + +const describeAddresses = [ + { + "PublicIp": "52.73.207.255", + "AllocationId": "eipalloc-012a1de6c78e459ba", + "AssociationId": "eipassoc-058f91ade38b552c3", + "Domain": "vpc", + "NetworkInterfaceId": "eni-0c9ee96ca599e524f", + "NetworkInterfaceOwnerId": "111122223333", + "PrivateIpAddress": "172.31.56.42", + "PublicIpv4Pool": "amazon", + "NetworkBorderGroup": "us-east-1" + } +]; + +const createCache = (attributes, addresses) => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': { + data: attributes + }, + }, + describeAddresses: { + 'us-east-1': { + data: addresses + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': { + err: { + message: 'error describing account attributes' + }, + }, + }, + describeAddresses: { + 'us-east-1': { + err: { + message: 'error describing addresses' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': null, + }, + describeAddresses: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('vpcElasticIpLimit', function () { + describe('run', function () { + it('should PASS if account is using VPC Elastic IPs less than the defined warn percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0]]); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if account is using VPC Elastic IPs within the defined warn percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0]]); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if account is using VPC Elastic IPs within the defined fail percentage', function (done) { + const cache = createCache(describeAccountAttributes[0],[describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0],describeAddresses[0]]); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no addresses found', function (done) { + const cache = createCache(describeAccountAttributes[0], []); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to describe account attributes', function (done) { + const cache = createErrorCache(); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should FAIL if unable to describe addresses', function (done) { + const cache = createCache([]); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe account attributes response is not found', function (done) { + const cache = createNullCache(); + vpcElasticIpLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ec2/vpcEndpointAcceptance.js b/plugins/aws/ec2/vpcEndpointAcceptance.js index 9d0530bd7..e9730200f 100644 --- a/plugins/aws/ec2/vpcEndpointAcceptance.js +++ b/plugins/aws/ec2/vpcEndpointAcceptance.js @@ -4,16 +4,34 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'VPC PrivateLink Endpoint Acceptance Required', category: 'EC2', + domain: 'Compute', + severity: 'Medium', description: 'Ensures VPC PrivateLink endpoints require acceptance', more_info: 'VPC PrivateLink endpoints should be configured to require acceptance so that access to the endpoint is controlled on a case-by-case basis.', recommended_action: 'Update the VPC PrivateLink endpoint to require acceptance', link: 'https://docs.aws.amazon.com/vpc/latest/userguide/accept-reject-endpoint-requests.html', - apis: ['EC2:describeVpcEndpointServices'], + apis: ['EC2:describeVpcEndpointServices', 'EC2:describeVpcEndpointServicePermissions'], + settings: { + allow_blank_whitelisted_principals: { + name: 'Allow If no Whitelisted Principals found', + description: 'When set to true, VPC PrivateLink endpoints having zero/blank whitelisted pricipals will PASS', + regex: '^(true|false)$', + default: 'false' + }, + }, + realtime_triggers: ['ec2:CreateVpcEndpointServiceConfiguration', 'ec2:ModifyVpcEndpointServiceConfiguration', 'ec2:DeleteVpcEndpointServiceConfiguration'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var defaultPartition = helpers.defaultPartition(settings); + + var config = { + allow_blank_whitelisted_principals: settings.allow_blank_whitelisted_principals || this.settings.allow_blank_whitelisted_principals.default + }; + + config.allow_blank_whitelisted_principals = (config.allow_blank_whitelisted_principals == 'true'); async.each(regions.ec2, function(region, rcb){ var describeVpcEndpointServices = helpers.addSource(cache, source, @@ -23,29 +41,56 @@ module.exports = { if (describeVpcEndpointServices.err || !describeVpcEndpointServices.data) { helpers.addResult(results, 3, - 'Unable to query for VPC endpoint services: ' + helpers.addError(describeVpcEndpointServices), region); + `Unable to query for VPC endpoint services: ${helpers.addError(describeVpcEndpointServices)}`, region); return rcb(); } describeVpcEndpointServices.data = describeVpcEndpointServices.data.filter(service => service.Owner != 'amazon'); - + if (!describeVpcEndpointServices.data.length) { helpers.addResult(results, 0, 'No user owned VPC endpoint services present', region); return rcb(); } - for (var s in describeVpcEndpointServices.data) { - var service = describeVpcEndpointServices.data[s]; - var resource = service.ServiceName; + for (var service of describeVpcEndpointServices.data) { + if (!service.ServiceId) continue; + + var resource = `arn:${defaultPartition}:ec2:${region}:${service.Owner}:vpc-endpoint-service/${service.ServiceId}`; + if (service.AcceptanceRequired) { helpers.addResult(results, 0, - 'VPC endpoint service ' + (service.ServiceId) + ' requires acceptance by the service owner', + `VPC endpoint service ${service.ServiceId} requires acceptance by the service owner`, region, resource); } else { - helpers.addResult(results, 2, - 'VPC endpoint service ' + (service.ServiceId) + ' does not require acceptance by the service owner', - region, resource); + if (config.allow_blank_whitelisted_principals) { + var describeVpcEndpointServicePermissions = helpers.addSource(cache, source, + ['ec2', 'describeVpcEndpointServicePermissions', region, service.ServiceId]); + + if (!describeVpcEndpointServicePermissions || + describeVpcEndpointServicePermissions.err || + !describeVpcEndpointServicePermissions.data) { + helpers.addResult(results, 3, + `Unable to query VPC endpoint service permissions: ${describeVpcEndpointServicePermissions}`, + region, resource); + continue; + } + + if (!describeVpcEndpointServicePermissions.data.AllowedPrincipals || + !describeVpcEndpointServicePermissions.data.AllowedPrincipals.length) { + helpers.addResult(results, 0, + `VPC endpoint service ${service.ServiceId} does not require acceptance by the service owner but no allowed principals found`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPC endpoint service ${service.ServiceId} does not require acceptance by the service owner for allowed principals`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `VPC endpoint service ${service.ServiceId} does not require acceptance by the service owner`, + region, resource); + } } } @@ -54,4 +99,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/ec2/vpcEndpointAcceptance.spec.js b/plugins/aws/ec2/vpcEndpointAcceptance.spec.js index 62053b723..b6eb54ec0 100644 --- a/plugins/aws/ec2/vpcEndpointAcceptance.spec.js +++ b/plugins/aws/ec2/vpcEndpointAcceptance.spec.js @@ -6,17 +6,17 @@ const vpcEndpointServices = [ "ServiceName": "com.amazonaws.vpce.us-east-1.vpce-svc-09d3a6a098dce6e8c", "ServiceId": "vpce-svc-09d3a6a098dce6e8c", "ServiceType": [ - { - "ServiceType": "Interface" - } + { + "ServiceType": "Interface" + } ], "AvailabilityZones": [ - "us-east-1a", - "us-east-1b" + "us-east-1a", + "us-east-1b" ], - "Owner": "560213429563", + "Owner": "123456654321", "BaseEndpointDnsNames": [ - "vpce-svc-09d3a6a098dce6e8c.us-east-1.vpce.amazonaws.com" + "vpce-svc-09d3a6a098dce6e8c.us-east-1.vpce.amazonaws.com" ], "VpcEndpointPolicySupported": false, "AcceptanceRequired": true, @@ -27,27 +27,42 @@ const vpcEndpointServices = [ "ServiceName": "com.amazonaws.vpce.us-east-1.vpce-svc-09145867a106679a3", "ServiceId": "vpce-svc-09145867a106679a3", "ServiceType": [ - { - "ServiceType": "Interface" - } + { + "ServiceType": "Interface" + } ], "AvailabilityZones": [ - "us-east-1a", - "us-east-1b", - "us-east-1c" + "us-east-1a", + "us-east-1b", + "us-east-1c" ], - "Owner": "560213429563", + "Owner": "123456654321", "BaseEndpointDnsNames": [ - "vpce-svc-09145867a106679a3.us-east-1.vpce.amazonaws.com" + "vpce-svc-09145867a106679a3.us-east-1.vpce.amazonaws.com" ], "VpcEndpointPolicySupported": false, "AcceptanceRequired": false, "ManagesVpcEndpoints": false, "Tags": [] }, -] +]; -const createCache = (ServiceDetails) => { +const describeVpcEndpointServicePermissions = [ + { + "AllowedPrincipals": [ + { + "PrincipalType": 'Account', + "Principal": 'arn:aws:iam::978540733285:root' + } + ] + }, + { + "AllowedPrincipals": [] + }, +]; + +const createCache = (ServiceDetails, servicePermissions) => { + var serviceId = (ServiceDetails && ServiceDetails.length) ? ServiceDetails[0].ServiceId : null; return { ec2: { describeVpcEndpointServices: { @@ -55,6 +70,13 @@ const createCache = (ServiceDetails) => { data: ServiceDetails }, }, + describeVpcEndpointServicePermissions: { + 'us-east-1': { + [serviceId]: { + data: servicePermissions + } + } + } }, }; }; @@ -94,6 +116,15 @@ describe('vpcEndpointAcceptance', function () { }); }); + it('should PASS if VPC endpoint service does not require acceptance by the service owner but no allowed principals found', function (done) { + const cache = createCache([vpcEndpointServices[1]], describeVpcEndpointServicePermissions[1]); + vpcEndpointAcceptance.run(cache, { allow_blank_whitelisted_principals: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + it('should FAIL if VPC endpoint service does not require acceptance by the service owner', function (done) { const cache = createCache([vpcEndpointServices[1]]); vpcEndpointAcceptance.run(cache, {}, (err, results) => { @@ -103,6 +134,15 @@ describe('vpcEndpointAcceptance', function () { }); }); + it('should FAIL if VPC endpoint service does not require acceptance by the service owner for allowed principals', function (done) { + const cache = createCache([vpcEndpointServices[1]], describeVpcEndpointServicePermissions[0]); + vpcEndpointAcceptance.run(cache, { allow_blank_whitelisted_principals: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + it('should PASS if no VPC endpoint service is detected', function (done) { const cache = createCache([]); vpcEndpointAcceptance.run(cache, {}, (err, results) => { diff --git a/plugins/aws/ec2/vpcEndpointCrossAccount.js b/plugins/aws/ec2/vpcEndpointCrossAccount.js new file mode 100644 index 000000000..23e747145 --- /dev/null +++ b/plugins/aws/ec2/vpcEndpointCrossAccount.js @@ -0,0 +1,93 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPC Endpoint Cross Account Access', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Amazon VPC endpoints do not allow unknown cross account access.', + more_info: 'VPC endpoints should not allow unknown cross account access to avoid any unsigned requests made to the services inside VPC.', + recommended_action: 'Update VPC endpoint access policy in order to remove untrusted cross account access', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-endpoints-access.html', + apis: ['EC2:describeVpcEndpoints', 'STS:getCallerIdentity'], + settings: { + vpc_trusted_cross_account_arns: { + name: 'VPC Trusted Cross Account ARNs', + description: 'A comma-separated list of trusted cross account ARNs i.e. \'arn:aws:iam::000111222333:user/user1,arn:aws:iam::000111222333:user/user2\'', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['ec2:CreateVpcEndpoint', 'ec2:ModifyVpcEndpoint', 'ec2:DeleteVpcEndpoint'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var vpc_trusted_cross_account_arns = settings.vpc_trusted_cross_account_arns || this.settings.vpc_trusted_cross_account_arns.default; + vpc_trusted_cross_account_arns = vpc_trusted_cross_account_arns.split(','); + + async.each(regions.ec2, function(region, rcb){ + var describeVpcEndpoints = helpers.addSource(cache, source, + ['ec2', 'describeVpcEndpoints', region]); + + if (!describeVpcEndpoints) return rcb(); + + if (describeVpcEndpoints.err || !describeVpcEndpoints.data) { + helpers.addResult(results, 3, + `Unable to query for VPC endpoints: ${helpers.addError(describeVpcEndpoints)}`, region); + return rcb(); + } + + if (!describeVpcEndpoints.data.length) { + helpers.addResult(results, 0, + 'No VPC endpoins found', region); + return rcb(); + } + + describeVpcEndpoints.data.forEach(endpoint =>{ + if (!endpoint.VpcEndpointId) return; + + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:vpc-endpoint/${endpoint.VpcEndpointId}`; + var unallowedCrossAccounts = []; + + if (endpoint.PolicyDocument) { + var statements = helpers.normalizePolicyDocument(endpoint.PolicyDocument); + + for (var statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && statement.Principal) { + var principals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + + for (var principal of principals) { + if (!vpc_trusted_cross_account_arns.includes(principal) && + !unallowedCrossAccounts.includes(principal)) { + unallowedCrossAccounts.push(principal); + } + } + } + } + } + + if (!unallowedCrossAccounts.length) { + helpers.addResult(results, 0, + `VPC endpoint ${endpoint.VpcEndpointId} does not allow unknown cross account access`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPC endpoint ${endpoint.VpcEndpointId} allows cross account access to these principals: ${unallowedCrossAccounts.join(', ')}`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/vpcEndpointCrossAccount.spec.js b/plugins/aws/ec2/vpcEndpointCrossAccount.spec.js new file mode 100644 index 000000000..1f9b080c2 --- /dev/null +++ b/plugins/aws/ec2/vpcEndpointCrossAccount.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +const vpcEndpointCrossAccount = require('./vpcEndpointCrossAccount'); + +const describeVpcEndpoints = [ + { + "VpcEndpointId": "vpce-0cabb01596dba926e", + "VpcEndpointType": "Interface", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.lambda", + "State": "available", + "PolicyDocument": "{\n \"Id\": \"Policy1607050387464\",\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": {\n \"AWS\": [\n \"arn:aws:iam::111222333444:root\",\n \"arn:aws:iam::111222333444:root\"\n ]\n }\n }\n ]\n}","RouteTableIds": [], + "SubnetIds": [], + "Groups": [], + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "NetworkInterfaceIds": [], + "DnsEntries": [], + "CreationTimestamp": "2020-12-04T02:26:34.761000+00:00", + "Tags": [], + "OwnerId": "11112222333" + }, + { + "VpcEndpointId": "vpce-0cabb01596dba926e", + "VpcEndpointType": "Interface", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.lambda", + "State": "available", + "PolicyDocument": "{\n \"Id\": \"Policy1607050387464\",\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": {\n \"AWS\": [\n \"arn:aws:sts::111122223333:root\"\n ]\n }\n },\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": {\n \"AWS\": [\n \"arn:aws:iam::111122223333:root\",\n \"arn:aws:iam::111122223333:root\"\n ]\n }\n },\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": {\n \"AWS\": \"*\"\n }\n },\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": \"*\"\n }\n ]\n}", + "RouteTableIds": [], + "SubnetIds": [], + "Groups": [], + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "NetworkInterfaceIds": [], + "DnsEntries": [], + "CreationTimestamp": "2020-12-04T02:26:34.761000+00:00", + "Tags": [], + "OwnerId": "11112222333" + }, + { + "VpcEndpointId": "vpce-0cabb01596dba926e", + "VpcEndpointType": "Interface", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.lambda", + "State": "available", + "PolicyDocument": "{\n \"Id\": \"Policy1607050387464\",\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"Stmt1607050377913\",\n \"Action\": \"s3:*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"arn:aws:s3:::testingparquet\",\n \"Principal\": {\n \"AWS\": [\n \"arn:aws:iam::11112222333:root\",\n ]\n }\n },\n ]\n}", + "RouteTableIds": [], + "SubnetIds": [], + "Groups": [], + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "NetworkInterfaceIds": [], + "DnsEntries": [], + "CreationTimestamp": "2020-12-04T02:26:34.761000+00:00", + "Tags": [], + "OwnerId": "111122223333" + }, +]; + +const createCache = (vpcEndpoints) => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': { + data: vpcEndpoints + }, + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111222333444' + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': { + err: { + message: 'error describing VPC endpoints' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('vpcEndpointCrossAccount', function () { + describe('run', function () { + it('should PASS if VPC endpoint does not allow cross account access', function (done) { + const cache = createCache([describeVpcEndpoints[0]]); + const settings = { vpc_trusted_cross_account_arns: 'arn:aws:iam::11112222333:root' }; + + vpcEndpointCrossAccount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if VPC endpoint allows cross account access', function (done) { + const cache = createCache([describeVpcEndpoints[1]]); + const settings = { vpc_trusted_cross_account_arns: '111122223333' }; + + vpcEndpointCrossAccount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPC endpoints found', function (done) { + const cache = createCache([]); + vpcEndpointCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for VPC endpoints', function (done) { + const cache = createErrorCache(); + vpcEndpointCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results describe VPC endpoints response not found', function (done) { + const cache = createNullCache(); + vpcEndpointCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/vpcEndpointExposed.js b/plugins/aws/ec2/vpcEndpointExposed.js new file mode 100644 index 000000000..2a967a112 --- /dev/null +++ b/plugins/aws/ec2/vpcEndpointExposed.js @@ -0,0 +1,110 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPC Endpoint Exposed', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure Amazon VPC endpoints are not publicly exposed.', + more_info: 'VPC endpoints should not be publicly accessible in order to avoid any unsigned requests made to the services inside VPC.', + recommended_action: 'Update VPC endpoint access policy in order to stop any unsigned requests', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-endpoints-access.html', + apis: ['EC2:describeVpcEndpoints', 'EC2:describeSubnets', 'EC2:describeRouteTables', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpcEndpoint', 'ec2:ModifyVpcEndpoint', 'ec2:DeleteVpcEndpoint'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeVpcEndpoints = helpers.addSource(cache, source, + ['ec2', 'describeVpcEndpoints', region]); + + if (!describeVpcEndpoints) return rcb(); + + if (describeVpcEndpoints.err || !describeVpcEndpoints.data) { + helpers.addResult(results, 3, + 'Unable to query for VPC endpoints: ' + helpers.addError(describeVpcEndpoints), region); + return rcb(); + } + + if (!describeVpcEndpoints.data.length) { + helpers.addResult(results, 0, + 'No VPC endpoins present', region); + return rcb(); + } + + var describeSubnets = helpers.addSource(cache, {}, + ['ec2', 'describeSubnets', region]); + + if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for VPC subnets: ' + helpers.addError(describeSubnets), region); + return rcb(); + } + + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + return rcb(); + } + + var subnetRouteTableMap = helpers.getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + var privateSubnets = helpers.getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + + for (var endpoint of describeVpcEndpoints.data) { + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:vpc-endpoint/${endpoint.VpcEndpointId}`; + if (endpoint.VpcEndpointType && endpoint.VpcEndpointType.toLowerCase() == 'gateway') { + helpers.addResult(results, 0, + `VPC endpoint is of ${endpoint.VpcEndpointId} is of Gateway type`, region, resource); + continue; + } + + if (endpoint.SubnetIds && endpoint.SubnetIds.length) { + if (endpoint.SubnetIds.find(subnetId => privateSubnets.includes(subnetId))) { + helpers.addResult(results, 0, + 'VPC endpoint is behind private subnet', region, resource); + continue; + } + } + + var statements = helpers.normalizePolicyDocument(endpoint.PolicyDocument); + var publicEndpoint = false; + + for (var s in statements) { + var statement = statements[s]; + + if (statement.Effect == 'Allow') { + if (helpers.globalPrincipal(statement.Principal, settings)) { + publicEndpoint = true; + break; + } + } + } + + if (!publicEndpoint) { + helpers.addResult(results, 0, + `VPC endpoint ${endpoint.VpcEndpointId} is not exposed`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPC endpoint ${endpoint.VpcEndpointId} is publicly exposed`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/vpcEndpointExposed.spec.js b/plugins/aws/ec2/vpcEndpointExposed.spec.js new file mode 100644 index 000000000..86b5c8fcf --- /dev/null +++ b/plugins/aws/ec2/vpcEndpointExposed.spec.js @@ -0,0 +1,277 @@ +var expect = require('chai').expect; +const vpcEndpointExposed = require('./vpcEndpointExposed'); + +const vpcEndpoints = [ + { + "VpcEndpointId": "vpce-004441c67cb8fb7f7", + "VpcEndpointType": "Interface", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.s3", + "State": "available", + "PolicyDocument": "{\"Version\":\"2008-10-17\",\"Statement\":[{\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":\"*\",\"Resource\":\"*\"}]}", + "RouteTableIds": [], + "SubnetIds": [], + "Groups": [], + "PrivateDnsEnabled": false, + "RequesterManaged": false, + "NetworkInterfaceIds": [], + "DnsEntries": [], + "CreationTimestamp": "2020-10-23T05:24:02.000Z", + "Tags": [], + "OwnerId": "112233445566" + }, + { + "VpcEndpointId": "vpce-04f94cce3926725f1", + "VpcEndpointType": "Interface", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.athena", + "State": "available", + "PolicyDocument": "{\n \"Statement\": [\n {\n \"Action\": \"*\",\n \"Effect\": \"Allow\",\n \"Resource\": \"*\",\n \"Principal\": \"*\"\n }\n ]\n}", + "RouteTableIds": [], + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc", + "subnet-aac6b3e7" + ], + "Groups": [ + { + "GroupId": "sg-aa941691", + "GroupName": "default" + } + ], + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "NetworkInterfaceIds": [ + "eni-0d830573a71f0adf3", + "eni-091313ac29683c395", + ], + "CreationTimestamp": "2020-10-26T17:39:09.501Z", + "Tags": [], + "OwnerId": "112233445566" + }, + { + "VpcEndpointId": "vpce-004441c67cb8fb7f7", + "VpcEndpointType": "Gateway", + "VpcId": "vpc-99de2fe4", + "ServiceName": "com.amazonaws.us-east-1.s3", + "State": "available", + "PolicyDocument": "{\"Version\":\"2008-10-17\",\"Statement\":[{\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":\"*\",\"Resource\":\"*\"}]}", + "RouteTableIds": [], + "SubnetIds": ["subnet-aac6b3e7"], + "Groups": [], + "PrivateDnsEnabled": false, + "RequesterManaged": false, + "NetworkInterfaceIds": [], + "DnsEntries": [], + "CreationTimestamp": "2020-10-23T05:24:02.000Z", + "Tags": [], + "OwnerId": "112233445566" + }, +]; + +const describeRouteTables = [ + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [], + "VpcId": "vpc-0af5156c", + "OwnerId": "000011112222" + }, + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + }, + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "igw-sedwednkq", + "Origin": "CreateRouteTable", + "State": "active" + } + + ], + "Tags": [], + "VpcId": "vpc-0af515f7", + "OwnerId": "000011112222" + } +]; + +const describeSubnets = [ + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-0af5156c", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3f7", + "VpcId": "vpc-0af515f7", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + } +]; + +const createCache = (vpcEndpoints, subnets, routeTables) => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': { + data: vpcEndpoints + }, + }, + describeSubnets: { + 'us-east-1': { + data: subnets + } + }, + describeRouteTables: { + 'us-east-1': { + data: routeTables + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': { + err: { + message: 'error describing VPC endpoints' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpcEndpoints: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('vpcEndpointExposed', function () { + describe('run', function () { + it('should PASS if VPC endpoint is not exposed', function (done) { + const cache = createCache([vpcEndpoints[0]], [describeSubnets[1]], [describeRouteTables[1]]); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if VPC endpoint is of Gateway type', function (done) { + const cache = createCache([vpcEndpoints[2]], [describeSubnets[1]], [describeRouteTables[1]]); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if VPC endpoint is publicly exposed', function (done) { + const cache = createCache([vpcEndpoints[1]], [describeSubnets[1]], [describeRouteTables[1]]); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if VPC endpoint is behind private subnet', function (done) { + const cache = createCache([vpcEndpoints[1]], [describeSubnets[0]], [describeRouteTables[0]]); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no VPC endpoints found', function (done) { + const cache = createCache([]); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there unable to query for VPC endpoints', function (done) { + const cache = createErrorCache(); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results describe VPC endpoints response not found', function (done) { + const cache = createNullCache(); + vpcEndpointExposed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/vpcHasTags.js b/plugins/aws/ec2/vpcHasTags.js new file mode 100644 index 000000000..e409b7e9b --- /dev/null +++ b/plugins/aws/ec2/vpcHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPC Has Tags', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that AWS VPC have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://aws.amazon.com/about-aws/whats-new/2020/07/amazon-vpc-resources-support-tag-on-create/', + recommended_action: 'Modify VPCs and add new tags', + apis: ['EC2:describeVpcs'], + realtime_triggers: ['ec2:CreateVpc', 'ec2:AddTags', 'ec2:DeleteTags', 'ec2:DeleteVpc'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var awsOrGov = helpers.defaultPartition(settings); + + var regions = helpers.regions(settings); + + async.each(regions.vpc, function(region, rcb){ + var describeVpcs = helpers.addSource(cache, source, ['ec2', 'describeVpcs', region]); + + if (!describeVpcs) return rcb(); + + if (describeVpcs.err || !describeVpcs.data) { + helpers.addResult(results, 3, `Unable to query for VPCs: ${helpers.addError(describeVpcs)}`, region); + return rcb(); + } + + if (!describeVpcs.data.length) { + helpers.addResult(results, 0, 'No VPCs present', region); + return rcb(); + } + + for (var v in describeVpcs.data) { + var vpc = describeVpcs.data[v]; + // arn:${Partition}:ec2:${Region}:${Account}:vpc/${VpcId} + var arn = 'arn:' + awsOrGov + ':ec2:' + region + ':' + vpc.OwnerId + ':vpc/' + vpc.VpcId; + if (!vpc.Tags || !vpc.Tags.length) { + helpers.addResult(results, 2, 'VPC does not have tags', region, arn); + } else { + helpers.addResult(results, 0, 'VPC has tags', region, arn); + } + } + + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/vpcHasTags.spec.js b/plugins/aws/ec2/vpcHasTags.spec.js new file mode 100644 index 000000000..8e0774884 --- /dev/null +++ b/plugins/aws/ec2/vpcHasTags.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var vpcHasTags = require('./vpcHasTags') +const describeVpcs =[ +{ + CidrBlock: '10.10.0.0/16', + DhcpOptionsId: 'dopt-020bdd32klmnb8567f', + State: 'available', + VpcId: 'vpc-0e7a0457ff482f4315', + OwnerId: '10136382434', + InstanceTenancy: 'default', + Ipv6CidrBlockAssociationSet: [], + CidrBlockAssociationSet: [], + IsDefault: true, + Tags: [] + }, + { + CidrBlock: '10.10.0.0/16', + DhcpOptionsId: 'dopt-020bdd32klmnb8567f', + State: 'available', + VpcId: 'vpc-0e7a0457ff482f4315', + OwnerId: '10136382434', + InstanceTenancy: 'default', + Ipv6CidrBlockAssociationSet: [], + CidrBlockAssociationSet: [], + IsDefault: true, + Tags: [{key:"key", value:"value"}] + }, +] + +const createCache = (vpcs) => { + return { + ec2: { + describeVpcs: { + 'us-east-1': { + data: vpcs, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpcs: { + 'us-east-1': { + err: { + message: 'error describing vpcs' + }, + }, + }, + }, + }; +}; + +describe('vpcHasTags', function () { + describe('run', function () { + it('should FAIL if VPC does not have tags', function (done) { + const cache = createCache([describeVpcs[0]]); + vpcHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if vpc has tags', function (done) { + const cache = createCache([describeVpcs[1]]); + vpcHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no vpcs are detected', function (done) { + const cache = createCache([]); + vpcHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for VPCs', function (done) { + const cache = createErrorCache(); + vpcHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/vpcPeeringConnections.js b/plugins/aws/ec2/vpcPeeringConnections.js new file mode 100644 index 000000000..b49b6b93e --- /dev/null +++ b/plugins/aws/ec2/vpcPeeringConnections.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Cross Organization VPC Peering Connections', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that VPC peering communication is only between AWS accounts, members of the same AWS Organization.', + more_info: 'VPC peering communication should be only between AWS accounts to keep organization resources private and isolated.', + recommended_action: 'Update VPC peering connections to allow connections to AWS Accounts, members of the same organization', + link: 'https://docs.aws.amazon.com/vpc/latest/peering/working-with-vpc-peering.html', + apis: ['Organizations:listAccounts', 'EC2:describeVpcPeeringConnections', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpcPeeringConnection', 'ec2:DeleteVpcPeeringConnection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', acctRegion]); + + if (!listAccounts) return callback(null, results, source); + + if (listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query for Organization Accounts: ${helpers.addError(listAccounts)}`, acctRegion); + return callback(null, results, source); + } + + var organizationAccounts = []; + if (listAccounts.data.length) { + listAccounts.data.forEach(account => { + if (account.Arn && account.Id) { + organizationAccounts.push(account.Id); + } + }); + } + + async.each(regions.ec2, function(region, rcb){ + var describeVpcPeeringConnections = helpers.addSource(cache, source, + ['ec2', 'describeVpcPeeringConnections', region]); + + if (!describeVpcPeeringConnections) return rcb(); + + if (describeVpcPeeringConnections.err || !describeVpcPeeringConnections.data) { + helpers.addResult(results, 3, + `Unable to query for VPC peering connections: ${helpers.addError(describeVpcPeeringConnections)}`, region); + return rcb(); + } + + if (!describeVpcPeeringConnections.data.length) { + helpers.addResult(results, 0, + 'No VPC peering connections found', region); + return rcb(); + } + + describeVpcPeeringConnections.data.forEach(connection => { + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:vpc-peering-connection/${connection.VpcPeeringConnectionId}`; + + if (connection.RequesterVpcInfo && + connection.RequesterVpcInfo.OwnerId && + organizationAccounts.includes(connection.RequesterVpcInfo.OwnerId) && + connection.AccepterVpcInfo && + connection.AccepterVpcInfo.OwnerId && + organizationAccounts.includes(connection.AccepterVpcInfo.OwnerId)) { + helpers.addResult(results, 0, + `VPC peering connection "${connection.VpcPeeringConnectionId}" does not allow communication outside organization accounts`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPC peering connection "${connection.VpcPeeringConnectionId}" allows communication outside organization accounts`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/vpcPeeringConnections.spec.js b/plugins/aws/ec2/vpcPeeringConnections.spec.js new file mode 100644 index 000000000..706b8498b --- /dev/null +++ b/plugins/aws/ec2/vpcPeeringConnections.spec.js @@ -0,0 +1,227 @@ +var expect = require('chai').expect; +const vpcPeeringConnections = require('./vpcPeeringConnections'); + +const listAccounts = [ + { + "Id": "112233445566", + "Arn": "arn:aws:organizations::112233445566:account/o-wd9d7dgznf/112233445566", + "Email": "makhtar.pucit@gmail.com", + "Name": "akhtar-practice", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-05T13:50:47.111000+05:00" + }, + { + "Id": "111122223333", + "Arn": "arn:aws:organizations::112233445566:account/o-wd9d7dgznf/112233445566", + "Email": "makhtar.pucit@gmail.com", + "Name": "akhtar-practice", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-05T13:50:47.111000+05:00" + } +]; + +const describeVpcPeeringConnections = [ + { + "AccepterVpcInfo": { + "OwnerId": "112233445566", + "VpcId": "vpc-036273a23dcdba22f", + "Region": "us-east-1" + }, + "ExpirationTime": "2020-12-12T09:18:20+00:00", + "RequesterVpcInfo": { + "CidrBlock": "172.31.0.0/16", + "CidrBlockSet": [ + { + "CidrBlock": "172.31.0.0/16" + } + ], + "OwnerId": "111122223333", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "VpcId": "vpc-99de2fe4", + "Region": "us-east-1" + }, + "Status": { + "Code": "pending-acceptance", + "Message": "Pending Acceptance by 111122223333" + }, + "Tags": [ + { + "Key": "Name", + "Value": "ak-70" + } + ], + "VpcPeeringConnectionId": "pcx-083996487133115e0" + }, + { + "AccepterVpcInfo": { + "OwnerId": "000099998888", + "VpcId": "vpc-036273a23dcdba22f", + "Region": "us-east-1" + }, + "ExpirationTime": "2020-12-12T09:18:20+00:00", + "RequesterVpcInfo": { + "CidrBlock": "172.31.0.0/16", + "CidrBlockSet": [ + { + "CidrBlock": "172.31.0.0/16" + } + ], + "OwnerId": "111122223333", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "VpcId": "vpc-99de2fe4", + "Region": "us-east-1" + }, + "Status": { + "Code": "pending-acceptance", + "Message": "Pending Acceptance by 111122223333" + }, + "Tags": [ + { + "Key": "Name", + "Value": "ak-70" + } + ], + "VpcPeeringConnectionId": "pcx-083996487133115e0" + } +]; + +const createCache = (organizationAccounts, vpcPeeringConnection) => { + return { + organizations: { + listAccounts: { + 'us-east-1': { + data: organizationAccounts + } + } + }, + ec2: { + describeVpcPeeringConnections: { + 'us-east-1': { + data: vpcPeeringConnection + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + organizations: { + listAccounts: { + 'us-east-1': { + err: { + message: 'error listing organization accounts' + }, + }, + }, + }, + ec2: { + describeVpcPeeringConnections: { + 'us-east-1': { + err: { + message: 'error describing VPC peering connections' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + organizations: { + listAccounts: { + 'us-east-1': null, + }, + }, + ec2: { + describeVpcPeeringConnections: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('vpcPeeringConnections', function () { + describe('run', function () { + it('should PASS if VPC peering connection does not allow communication outside organization accounts', function (done) { + const cache = createCache(listAccounts, [describeVpcPeeringConnections[0]]); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if VPC peering connection allows communication outside organization accounts', function (done) { + const cache = createCache(listAccounts, [describeVpcPeeringConnections[1]]); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if No organization accounts found but VPC peering connection allows communication to accounts', function (done) { + const cache = createCache([], [describeVpcPeeringConnections[1]]); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPC peering connections found', function (done) { + const cache = createCache([], []); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list organization accounts', function (done) { + const cache = createErrorCache(); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe VPC peering connections', function (done) { + const cache = createCache([]); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if not return anything if list organization accounts response is not found', function (done) { + const cache = createNullCache(); + + vpcPeeringConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/vpcSubnetInstancesPresent.js b/plugins/aws/ec2/vpcSubnetInstancesPresent.js new file mode 100644 index 000000000..93730787d --- /dev/null +++ b/plugins/aws/ec2/vpcSubnetInstancesPresent.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPC Subnet Instances Present', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that there are instances attached to every subnet.', + more_info: 'All subnets should have instances associated and unused subnets should be removed to avoid reaching the limit.', + recommended_action: 'Update VPC subnets and attach instances to it or remove the unused VPC subnets', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/vpc-endpoints-access.html', + apis: ['EC2:describeInstances', 'EC2:describeSubnets'], + realtime_triggers: ['ec2:RunInstances', 'ec2:CreateSubnet', 'ec2:TerminateInstances','ec2:DeleteSubnet'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ec2, function(region, rcb){ + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + + if (!describeSubnets) return rcb(); + + if (describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + `Unable to query for VPC subnets: ${helpers.addError(describeSubnets)}`, region); + return rcb(); + } + + if (!describeSubnets.data.length) { + helpers.addResult(results, 0, 'No VPC subnets found', region); + return rcb(); + } + + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances || describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, + `Unable to query for EC2 instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + var instanceSubnets = {}; + if (describeInstances.data.length) { + describeInstances.data.forEach(instance => { + if (instance.Instances && instance.Instances.length) { + instance.Instances.forEach(entry => { + if (entry.SubnetId && instanceSubnets[entry.SubnetId]) instanceSubnets[entry.SubnetId] += 1; + else if (entry.SubnetId) instanceSubnets[entry.SubnetId] = 1; + }); + } + }); + } + + describeSubnets.data.forEach(subnet => { + if (subnet.SubnetId && instanceSubnets[subnet.SubnetId]) { + helpers.addResult(results, 0, + `Subnet has ${instanceSubnets[subnet.SubnetId]} instances attached`, + region, subnet.SubnetArn); + } else { + helpers.addResult(results, 2, + 'Subnet does not have any instance attached', + region, subnet.SubnetArn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/vpcSubnetInstancesPresent.spec.js b/plugins/aws/ec2/vpcSubnetInstancesPresent.spec.js new file mode 100644 index 000000000..35a08a887 --- /dev/null +++ b/plugins/aws/ec2/vpcSubnetInstancesPresent.spec.js @@ -0,0 +1,212 @@ +var expect = require('chai').expect; +const vpcSubnetInstancesPresent = require('./vpcSubnetInstancesPresent'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-12-05T18:35:50+00:00", + "Monitoring": { + "State": "disabled" + }, + "Placement": {}, + "PrivateDnsName": "ip-172-31-28-46.ec2.internal", + "PrivateIpAddress": "172.31.28.46", + "ProductCodes": [], + "PublicDnsName": "ec2-18-209-19-81.compute-1.amazonaws.com", + "PublicIpAddress": "18.209.19.81", + "State": {}, + "StateTransitionReason": "", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "BlockDeviceMappings": [], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111122223333:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "NetworkInterfaces": [], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [], + "SourceDestCheck": true, + "Tags": [], + "VirtualizationType": "hvm", + "CpuOptions": {}, + "CapacityReservationSpecification": {}, + "HibernationOptions": {}, + "MetadataOptions": {}, + "EnclaveOptions": {} + } + ], + "OwnerId": "111122223333", + "ReservationId": "r-087ce52925d75c272" + }, +]; + +const describeSubnets = [ + { + "AvailabilityZone": "us-east-1d", + "AvailabilityZoneId": "use1-az6", + "AvailableIpAddressCount": 250, + "CidrBlock": "172.16.0.0/24", + "DefaultForAz": false, + "MapPublicIpOnLaunch": false, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-036273a23dcdba22f", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "Tags": [ + { + "Key": "AWSServiceAccount", + "Value": "697148468905" + } + ], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-027b3e2dbd13be412" + }, + { + "AvailabilityZone": "us-east-1d", + "AvailabilityZoneId": "use1-az6", + "AvailableIpAddressCount": 250, + "CidrBlock": "172.16.0.0/24", + "DefaultForAz": false, + "MapPublicIpOnLaunch": false, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-027b3e2dbd13be412", + "VpcId": "vpc-036273a23dcdba22f", + "OwnerId": "111122223333", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "Tags": [ + { + "Key": "AWSServiceAccount", + "Value": "697148468905" + } + ], + "SubnetArn": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-027b3e2dbd13be412" + } +]; + + +const createCache = (instances, subnets) => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + data: instances + }, + }, + describeSubnets: { + 'us-east-1': { + data: subnets + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + }, + describeSubnets: { + 'us-east-1': { + err: { + message: 'error describing VPC subnets' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeInstances: { + 'us-east-1': null, + }, + describeSubnets: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('vpcSubnetInstancesPresent', function () { + describe('run', function () { + it('should PASS if subnet has instances attached', function (done) { + const cache = createCache([describeInstances[0]], [describeSubnets[0]]); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if subnet does not have any instance attached', function (done) { + const cache = createCache([describeInstances[0]], [describeSubnets[1]]); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPC subnets found', function (done) { + const cache = createCache([], []); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for instances', function (done) { + const cache = createErrorCache(); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for VPC subnets', function (done) { + const cache = createCache([]); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe subnets response not found', function (done) { + const cache = createNullCache(); + vpcSubnetInstancesPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ec2/vpnGatewayInVpc.js b/plugins/aws/ec2/vpnGatewayInVpc.js new file mode 100644 index 000000000..7b15f88a9 --- /dev/null +++ b/plugins/aws/ec2/vpnGatewayInVpc.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Virtual Private Gateway In VPC', + category: 'EC2', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure Virtual Private Gateways are associated with at least one VPC.', + more_info: 'Virtual Private Gateways allow communication between cloud infrastructure and the remote customer network. They help in establishing VPN connection between VPC and the customer gateway. ' + + 'Make sure virtual private gateways are always associated with a VPC to meet security and regulatory compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/vpn/latest/s2svpn/SetUpVPNConnections.html', + recommended_action: 'Check if virtual private gateways have vpc associated', + apis: ['EC2:describeVpnGateways', 'STS:getCallerIdentity'], + realtime_triggers: ['ec2:CreateVpnGateway', 'ec2:AttachVpnGateway', 'ec2:DeattachVpnGateway', 'ec2:DeleteVpnGateway'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeVpnGateways = helpers.addSource(cache, source, + ['ec2', 'describeVpnGateways', region]); + + if (!describeVpnGateways) return rcb(); + + if (describeVpnGateways.err || !describeVpnGateways.data) { + helpers.addResult(results, 3, + `Unable to query for Virtual Private Gateways: ${helpers.addError(describeVpnGateways)}`, + region); + return rcb(); + } + + if (!describeVpnGateways.data.length) { + helpers.addResult(results, 0, + 'No Virtual Private Gateways found', region); + return rcb(); + } + + describeVpnGateways.data.forEach(function(vpn){ + var resource = `arn:${awsOrGov}:vpc:${region}:${accountId}:vpn-gateway/${vpn.VpnGatewayId}`; + if (vpn.VpcAttachments && vpn.VpcAttachments.length) { + let attached = vpn.VpcAttachments.find(attachment => attachment.VpcId && attachment.State && attachment.State.toUpperCase() == 'ATTACHED'); + + if (attached) { + helpers.addResult(results, 0, + 'Virtual Private Gateway is associated with VPC', + region, resource); + } else { + helpers.addResult(results, 2, + 'Virtual Private Gateway is not associated with VPC', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Virtual Private Gateway is not associated with VPC', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ec2/vpnGatewayInVpc.spec.js b/plugins/aws/ec2/vpnGatewayInVpc.spec.js new file mode 100644 index 000000000..63bb3e8f0 --- /dev/null +++ b/plugins/aws/ec2/vpnGatewayInVpc.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +const vpnGatewayInVpc = require('./vpnGatewayInVpc'); + +const describeVpnGateways = [ + { + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [ + { + "State": "attached", + "VpcId": "vpc-99de2fe4" + } + ], + "VpnGatewayId": "vgw-049df54387fd42105", + "AmazonSideAsn": 64512, + "Tags": [ + { + "Key": "Name", + "Value": "test-69" + } + ] + }, + { + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [], + "VpnGatewayId": "vgw-049df54387fd42105", + "AmazonSideAsn": 64512, + "Tags": [ + { + "Key": "Name", + "Value": "test-69" + } + ] + } +]; + +const createCache = (describeVpnGateways) => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': { + data: describeVpnGateways + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': { + err: { + message: 'error describing Virtual Private Gateways' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpnGateways: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('vpnGatewayInVpc', function () { + describe('run', function () { + it('should PASS if Virtual Private Gateway is associated with VPC', function (done) { + const cache = createCache([describeVpnGateways[0]]); + vpnGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Virtual Private Gateway is associated with VPC') + done(); + }); + }); + + it('should FAIL if Virtual Private Gateway is not associated with VPC', function (done) { + const cache = createCache([describeVpnGateways[1]]); + vpnGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Virtual Private Gateway is not associated with VPC') + done(); + }); + }); + + it('should PASS if No Virtual Private Gateways found', function (done) { + const cache = createCache([]); + vpnGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Virtual Private Gateways found') + done(); + }); + }); + + it('should UNKNOWN if unable to describe Virtual Private Gateways', function (done) { + const cache = createErrorCache(); + vpnGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Virtual Private Gateways') + done(); + }); + }); + + it('should not return anything if describe Virtual Private Gateways response not found', function (done) { + const cache = createNullCache(); + vpnGatewayInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/vpnTunnelState.js b/plugins/aws/ec2/vpnTunnelState.js new file mode 100644 index 000000000..87df157c5 --- /dev/null +++ b/plugins/aws/ec2/vpnTunnelState.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'VPN Tunnel State', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that each AWS Virtual Private Network (VPN) connection has all tunnels up.', + more_info: 'AWS Virtual Private Network (VPN) should have tunnels up to ensure network traffic flow over Virtual Private Network.', + link: 'https://docs.aws.amazon.com/vpn/latest/s2svpn/VPNTunnels.html', + recommended_action: 'Establish a successful VPN connection using IKE or IPsec configuration', + apis: ['EC2:describeVpnConnections', 'STS:getCallerIdentity'], + settings: { + enable_vpn_tunnel_state: { + name: 'Enable VPN Tunnel State', + description: 'This is an opt-in plugin. This value should be set to true to enable this plugin', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['ec2:CreateVpnConnection', 'ec2:DeleteVpnConnection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var enable_vpn_tunnel_state = (settings.enable_vpn_tunnel_state || this.settings.enable_vpn_tunnel_state.default); + + if (!enable_vpn_tunnel_state || enable_vpn_tunnel_state == 'false') return callback(null, results, source); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ec2, function(region, rcb){ + var describeVpnConnections = helpers.addSource(cache, source, + ['ec2', 'describeVpnConnections', region]); + + if (!describeVpnConnections) return rcb(); + + if (describeVpnConnections.err || !describeVpnConnections.data) { + helpers.addResult(results, 3, + `Unable to query for VPN connections: ${helpers.addError(describeVpnConnections)}`, + region); + return rcb(); + } + + if (!describeVpnConnections.data.length) { + helpers.addResult(results, 0, + 'No VPN connections found', region); + return rcb(); + } + + for (var vpn of describeVpnConnections.data) { + if (!vpn.VpnConnectionId) continue; + + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:vpn-connection/${vpn.VpnConnectionId}`; + var tunnelDown = false; + + if (vpn.VgwTelemetry && vpn.VgwTelemetry.length) { + for (var vgw of vpn.VgwTelemetry) { + if (vgw.Status && vgw.Status.toUpperCase() === 'DOWN') { + tunnelDown = true; + break; + } + } + } else { + helpers.addResult(results, 2, + `VPN connection "${vpn.VpnConnectionId}" does not have any tunnel configured`, + region, resource); + continue; + } + + if (!tunnelDown) { + helpers.addResult(results, 0, + `VPN connection "${vpn.VpnConnectionId}" has all tunnels UP`, + region, resource); + } else { + helpers.addResult(results, 2, + `VPN connection "${vpn.VpnConnectionId}" has tunnel down`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/vpnTunnelState.spec.js b/plugins/aws/ec2/vpnTunnelState.spec.js new file mode 100644 index 000000000..c4411bf48 --- /dev/null +++ b/plugins/aws/ec2/vpnTunnelState.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +const vpnTunnelState = require('./vpnTunnelState'); + +const describeVpnConnections = [ + { + "CustomerGatewayConfiguration": "", + "CustomerGatewayId": "cgw-04fe197456aa25733", + "Category": "VPN", + "State": "available", + "Type": "ipsec.1", + "VpnConnectionId": "vpn-0f482914f2c2c36ed", + "TransitGatewayId": "tgw-08e8a28e47ca6583b", + "Options": { + "EnableAcceleration": false, + "StaticRoutesOnly": false, + "LocalIpv4NetworkCidr": "0.0.0.0/0", + "RemoteIpv4NetworkCidr": "0.0.0.0/0", + "TunnelInsideIpVersion": "ipv4" + }, + "Routes": [], + "Tags": [ + { + "Key": "Name", + "Value": "akd-33" + } + ], + "VgwTelemetry": [ + { + "AcceptedRouteCount": 0, + "LastStatusChange": "2020-12-29T20:09:58+00:00", + "OutsideIpAddress": "3.231.123.34", + "Status": "UP", + "StatusMessage": "IPSEC IS UP" + }, + { + "AcceptedRouteCount": 0, + "LastStatusChange": "2020-12-29T20:12:32+00:00", + "OutsideIpAddress": "18.210.70.26", + "Status": "UP", + "StatusMessage": "IPSEC IS UP" + } + ] + }, + { + "CustomerGatewayConfiguration": "", + "CustomerGatewayId": "cgw-04fe197456aa25733", + "Category": "VPN", + "State": "available", + "Type": "ipsec.1", + "VpnConnectionId": "vpn-0f482914f2c2c36ed", + "TransitGatewayId": "tgw-08e8a28e47ca6583b", + "Options": { + "EnableAcceleration": false, + "StaticRoutesOnly": false, + "LocalIpv4NetworkCidr": "0.0.0.0/0", + "RemoteIpv4NetworkCidr": "0.0.0.0/0", + "TunnelInsideIpVersion": "ipv4" + }, + "Routes": [], + "Tags": [ + { + "Key": "Name", + "Value": "akd-33" + } + ], + "VgwTelemetry": [ + { + "AcceptedRouteCount": 0, + "LastStatusChange": "2020-12-29T20:09:58+00:00", + "OutsideIpAddress": "3.231.123.34", + "Status": "UP", + "StatusMessage": "IPSEC IS UP" + }, + { + "AcceptedRouteCount": 0, + "LastStatusChange": "2020-12-29T20:12:32+00:00", + "OutsideIpAddress": "18.210.70.26", + "Status": "DOWN", + "StatusMessage": "IPSEC IS DOWN" + } + ] + }, + { + "CustomerGatewayConfiguration": "", + "CustomerGatewayId": "cgw-04fe197456aa25733", + "Category": "VPN", + "State": "available", + "Type": "ipsec.1", + "VpnConnectionId": "vpn-0f482914f2c2c36ed", + "TransitGatewayId": "tgw-08e8a28e47ca6583b", + "Options": { + "EnableAcceleration": false, + "StaticRoutesOnly": false, + "LocalIpv4NetworkCidr": "0.0.0.0/0", + "RemoteIpv4NetworkCidr": "0.0.0.0/0", + "TunnelInsideIpVersion": "ipv4" + }, + "Routes": [], + "Tags": [ + { + "Key": "Name", + "Value": "akd-33" + } + ], + "VgwTelemetry": [] + } +]; + +const createCache = (describeVpnConnections, describeVpnConnectionsErr) => { + return { + ec2: { + describeVpnConnections: { + 'us-east-1': { + err: describeVpnConnectionsErr, + data: describeVpnConnections + } + }, + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeVpnConnections: { + 'us-east-1': null + } + } + }; +}; + +describe('vpnTunnelState', function () { + describe('run', function () { + it('should PASS if VPN connection has all tunnels UP', function (done) { + const cache = createCache([describeVpnConnections[0]], null); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if VPN connection has tunnel down', function (done) { + const cache = createCache([describeVpnConnections[1]], null); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if VPN connection does not have any tunnel configured', function (done) { + const cache = createCache([describeVpnConnections[2]], null); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no VPN connections found', function (done) { + const cache = createCache([], null); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON if unable to query for VPN connections', function (done) { + const cache = createCache([], { message: 'Unable to query for VPN connections'}); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe VPN connections response not found', function (done) { + const cache = createNullCache(); + vpnTunnelState.run(cache, { enable_vpn_tunnel_state: 'true' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/webTierInstanceIamRole.js b/plugins/aws/ec2/webTierInstanceIamRole.js new file mode 100644 index 000000000..42b03a5e0 --- /dev/null +++ b/plugins/aws/ec2/webTierInstanceIamRole.js @@ -0,0 +1,142 @@ +// TODO: MOVE TO EC2 +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web-Tier EC2 Instance IAM Role', + category: 'EC2', + domain: 'Compute', + severity: 'High', + description: 'Ensure IAM roles attached with Web-Tier EC2 instances have IAM policies attached.', + more_info: 'EC2 instances should have IAM roles configured with necessary permission to access other AWS services', + link: 'https://aws.amazon.com/blogs/security/new-attach-an-aws-iam-role-to-an-existing-amazon-ec2-instance-by-using-the-aws-cli/', + recommended_action: 'Modify EC2 instances to attach IAM roles with required IAM policies', + apis: ['EC2:describeInstances', 'IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies'], + settings: { + ec2_web_tier_tag_key: { + name: 'EC2 Web-Tier Tag Key', + description: 'Tag key to indicate Web-Tier EC2 instances', + regex: '^.*$s', + default: '' + }, + }, + realtime_triggers: ['ec2:RunInstances','ec2:AssociateIamInstanceProfile', 'ec2:DisassociateIamInstanceProfile', 'ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var awsOrGov = helpers.defaultPartition(settings); + var config = { + ec2_web_tier_tag_key: settings.ec2_web_tier_tag_key || this.settings.ec2_web_tier_tag_key.default + }; + + if (!config.ec2_web_tier_tag_key.length) return callback(null, results, source); + + async.each(regions.ec2, function(region, rcb){ + var describeInstances = helpers.addSource( + cache, source, ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (describeInstances.err || !describeInstances.data) { + helpers.addResult(results, 3, `Unable to query for instances: ${helpers.addError(describeInstances)}`, region); + return rcb(); + } + + if (!describeInstances.data.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + var describeTags = helpers.addSource( + cache, source, ['ec2', 'describeTags', region]); + + if (!describeTags || describeTags.err || !describeTags.data) { + helpers.addResult(results, 3, `Unable to query for tags: ${helpers.addError(describeTags)}`, region); + return rcb(); + } + + if (!describeTags.data.length) { + helpers.addResult(results, 0, 'No tags found', region); + return rcb(); + } + + async.each(describeInstances.data, function(instance, cb){ + var accountId = instance.OwnerId; + + for (var i in instance.Instances) { + var entry = instance.Instances[i]; + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:instance/${entry.InstanceId}`; + + var tagFound = false; + if (entry.Tags && entry.Tags.length) { + tagFound = entry.Tags.find(tag => tag.Key === config.ec2_web_tier_tag_key); + } + + if (!tagFound) { + helpers.addResult(results, 0, 'Instance does not have Web-Tier tag key', region, resource); + return cb(); + } + + if (!entry.IamInstanceProfile || + !entry.IamInstanceProfile.Arn) { + helpers.addResult(results, 2, + 'Instance does not use an IAM role', region, resource); + } else { + var roleNameArr = entry.IamInstanceProfile.Arn.split('/'); + var roleName = roleNameArr[roleNameArr.length-1]; + + // Get managed policies attached to role + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', region, roleName]); + + + if (!listAttachedRolePolicies || + listAttachedRolePolicies.err || + !listAttachedRolePolicies.data || + !listAttachedRolePolicies.data.AttachedPolicies) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}" ${helpers.addError(listAttachedRolePolicies)}`, region, resource); + return cb(); + } + + if (listAttachedRolePolicies.data.AttachedPolicies.length) { + helpers.addResult(results, 0, + 'IAM role attached with EC2 instance contains policies', region, resource); + return cb(); + } + + // Get inline policies attached to role + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', region, roleName]); + + if (!listRolePolicies || + listRolePolicies.err || + !listRolePolicies.data || + !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}" ${helpers.addError(listRolePolicies)}`, region, resource); + return cb(); + } + + if (listRolePolicies.data.PolicyNames.length) { + helpers.addResult(results, 0, + 'IAM role attached with EC2 instance contains policies', region, resource); + } else { + helpers.addResult(results, 2, + 'IAM role attached with EC2 instance does not contain any policies', region, resource); + } + } + } + + cb(); + }); + + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ec2/webTierInstanceIamRole.spec.js b/plugins/aws/ec2/webTierInstanceIamRole.spec.js new file mode 100644 index 000000000..64b8b6d79 --- /dev/null +++ b/plugins/aws/ec2/webTierInstanceIamRole.spec.js @@ -0,0 +1,399 @@ +var expect = require('chai').expect; +const webTierInstanceIamRole = require('./webTierInstanceIamRole'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-0e5b41e1d67462547", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "LaunchTime": "2020-11-18T22:48:08.000Z", + "SubnetId": "subnet-aac6b3e7", + "VpcId": "vpc-99de2fe4", + "Architecture": "x86_64", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-role-1", + "Id": "AIPAYE32SRU53G7VOI2UM" + }, + "Tags": [ + { + "Key": "web-tier", + "Value": "web-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-087ce52925d75c272" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-036d7bf13e0bfe836", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-ec2-role-2", + "Id": "AIPAYE32SRU5VWPEXDHQE" + }, + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-1323e23rede231231" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-8ee72s82hsn2nw22w", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/test-ec2-role-3", + "Id": "AIPAYE32SRU5VWPKDMWKD" + }, + "Tags": [ + { + "Key": "web-tier", + "Value": "web-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-d23ed2ed23wqe2r45" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0947d2ba12ee1ff75", + "InstanceId": "i-12f34r3refkn34irw", + "InstanceType": "t2.micro", + "KeyName": "auto-scaling-test-instance", + "Tags": [ + { + "Key": "web-tier", + "Value": "web-tier" + } + ], + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-0i09oijmi32e2q3e23" + } +]; + +const describeTags = [ + { + "Key": "web-tier", + "ResourceId": "i-0e5b41e1d67462547", + "ResourceType": "instance", + "Value": "web-tier" + }, + { + "Key": "web-tier", + "ResourceId": "i-8ee72s82hsn2nw22w", + "ResourceType": "instance", + "Value": "web-tier" + }, + { + "Key": "web-tier", + "ResourceId": "i-12f34r3refkn34irw", + "ResourceType": "instance", + "Value": "web-tier" + } +]; + +const listRoles = [ + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-1", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-2", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-2", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-3", + "RoleId": "AROAYE32SRU5734GJYW4F", + "Arn": "arn:aws:iam::111222333444:role/test-role-3", + "CreateDate": "2020-08-20T17:42:55Z", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + { + "PolicyName": "AWSElasticBeanstalkWebTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + }, + { + "PolicyName": "AWSElasticBeanstalkMulticontainerDocker", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker" + }, + { + "PolicyName": "AWSElasticBeanstalkWorkerTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier" + } + ] + }, + { + "PolicyNames": [] + } +]; + +const listAttachedRolePolicies = [ + { + "AttachedPolicies": [ + { + "PolicyName": "AWSElasticBeanstalkWebTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + }, + { + "PolicyName": "AWSElasticBeanstalkMulticontainerDocker", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker" + }, + { + "PolicyName": "AWSElasticBeanstalkWorkerTier", + "PolicyArn": "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier" + } + ] + }, + { + "AttachedPolicies": [] + } +]; + +const createCache = (describeInstances, describeTags, listRoles, listRolePolicies, listAttachedRolePolicies) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: describeInstances + }, + }, + describeTags: { + 'us-east-1': { + data: describeTags + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing instances' + } + }, + }, + describeTags: { + 'us-east-1': { + err: { + message: 'error describing tags' + } + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing roles' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': null + }, + describeTags: { + 'us-east-1': null + } + }, + iam: { + listRoles: { + 'us-east-1': null + }, + listAttachedRolePolicies: { + 'us-east-1': null + }, + listRolePolicies: { + 'us-east-1': null + } + } + }; +}; + +describe('webTierInstanceIamRole', function () { + describe('run', function () { + it('should PASS if IAM role attached with EC2 instance contains policies', function (done) { + const cache = createCache([describeInstances[0]], describeTags, [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[1]); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if IAM role attached with EC2 instance does not contain policies', function (done) { + const cache = createCache([describeInstances[0]], describeTags, [listRoles[0]], listRolePolicies[1], listAttachedRolePolicies[1]); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if instance does not use an IAM role', function (done) { + const cache = createCache([describeInstances[3]], describeTags, [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[0]); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if instance does not have Web-Tier tag key', function (done) { + const cache = createCache([describeInstances[0]], [describeTags[2]], [listRoles[0]], listRolePolicies[0], listAttachedRolePolicies[0]); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EC2 instances found', function (done) { + const cache = createCache([]); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no tags found', function (done) { + const cache = createCache([describeInstances[0]], []); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EC2 instances', function (done) { + const cache = createErrorCache(); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if describe EC2 instances response not found', function (done) { + const cache = createNullCache(); + webTierInstanceIamRole.run(cache, { ec2_web_tier_tag_key: 'web-tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any results if Web-Tier tag key is not provided in settings', function (done) { + const cache = createNullCache(); + webTierInstanceIamRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/ecr/ecrImageVulnerability.js b/plugins/aws/ecr/ecrImageVulnerability.js new file mode 100644 index 000000000..5a02be762 --- /dev/null +++ b/plugins/aws/ecr/ecrImageVulnerability.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Amazon ECR Scan on Push', + category: 'ECR', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures Amazon ECR container images are automatically scanned for security vulnerabilities after being pushed to a repository.', + more_info: 'Scan on Push for Amazon ECR is an automated vulnerability assessment feature that helps improve the security of ECR container images by scanning them for OS vulnerabilities.', + link: 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html#scanning-on-push', + recommended_action: 'Enable "Scan on Push" for your Amazon ECR repositories.', + apis: ['ECR:describeRepositories'], + realtime_triggers: ['ecr:CreateRepository', 'ecr:PutImageScanningConfiguration', 'ecr:DeleteRepository'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecr, function(region, rcb) { + var describeRepositories = helpers.addSource(cache, source, + ['ecr', 'describeRepositories', region]); + + if (!describeRepositories) return rcb(); + + if (describeRepositories.err || !describeRepositories.data) { + helpers.addResult( + results, 3, + 'Unable to query for ECR repositories: ' + helpers.addError(describeRepositories), region); + return rcb(); + } + + if (!describeRepositories.data.length) { + helpers.addResult(results, 0, 'No ECR repositories present', region); + return rcb(); + } + + for (var repository of describeRepositories.data) { + var resource = repository.repositoryArn; + + if (repository.imageScanningConfiguration && repository.imageScanningConfiguration.scanOnPush) { + helpers.addResult( results, 0, + 'Scan on Push is enabled for ECR repository', region, resource); + } else { + helpers.addResult( results, 2, + 'Scan on Push is not enabled for ECR repository', region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecr/ecrImageVulnerability.spec.js b/plugins/aws/ecr/ecrImageVulnerability.spec.js new file mode 100644 index 000000000..1242cd260 --- /dev/null +++ b/plugins/aws/ecr/ecrImageVulnerability.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +const ecrImageVulnerability = require('./ecrImageVulnerability'); + +const describeRepositories = [ + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": true + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "IMMUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, +] + +const createCache = (ecrRepository) => { + return { + ecr: { + describeRepositories: { + "us-east-1": { + data: ecrRepository + } + } + } + } + +} + +const repositoryErrorCache = () => { + return { + ecr: { + describeRepositories: { + "us-east-1": {} + }, + } + } +} + +describe('ecrImageVulnerability', () => { + describe('run', () => { + + it('should PASS if no ecr repositories present', () => { + const cache = createCache([]); + ecrImageVulnerability.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }); + }); + + it('should FAIL if scan on push is not enabled for repository', () => { + const cache = createCache([describeRepositories[1]]); + ecrImageVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + + it('should PASS if scan on push is enabled for repository', () => { + const cache = createCache([describeRepositories[0]]); + ecrImageVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + + it('should UNKNOWN if unable to get repository policy', () => { + const cache = repositoryErrorCache(); + ecrImageVulnerability.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + }); + }); + }) +}) diff --git a/plugins/aws/ecr/ecrRepositoryEncrypted.js b/plugins/aws/ecr/ecrRepositoryEncrypted.js new file mode 100644 index 000000000..fffc5ccd1 --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryEncrypted.js @@ -0,0 +1,106 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECR Repository Encrypted', + category: 'ECR', + domain: 'Containers', + severity: 'High', + description: 'Ensure that the images in ECR repository are encrypted using desired encryption level.', + more_info: 'By default, Amazon ECR uses server-side encryption with Amazon S3-managed encryption keys which encrypts your data at rest using an AES-256 encryption algorithm. ' + + 'Use customer-managed keys instead, in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create ECR Repository with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/Repositories.html', + apis: ['ECR:describeRepositories', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + ecr_repository_desired_encryption_level: { + name: 'ECR Repository Encryption', + description: 'In order (lowest to highest) sse=AES-256; awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['ecr:CreateRepository', 'ecr:DeleteRepository'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.ecr_repository_desired_encryption_level || this.settings.ecr_repository_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.ecr, function(region, rcb){ + var describeRepositories = helpers.addSource(cache, source, + ['ecr', 'describeRepositories', region]); + + if (!describeRepositories) return rcb(); + + if (describeRepositories.err || !describeRepositories.data) { + helpers.addResult(results, 3, `Unable to query ECR repositories: ${helpers.addError(describeRepositories)}`, region); + return rcb(); + } + + if (!describeRepositories.data.length) { + helpers.addResult(results, 0, 'No ECR repositories found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let repository of describeRepositories.data) { + if (!repository.repositoryArn) continue; + + let resource = repository.repositoryArn; + + if (repository.encryptionConfiguration && repository.encryptionConfiguration.kmsKey) { + let kmsKey = repository.encryptionConfiguration.kmsKey; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 1; //sse + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `ECR repository is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `ECR repository encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecr/ecrRepositoryEncrypted.spec.js b/plugins/aws/ecr/ecrRepositoryEncrypted.spec.js new file mode 100644 index 000000000..20a0341d8 --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryEncrypted.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +var ecrRepositoryEncrypted = require('./ecrRepositoryEncrypted'); + +const describeRepositories = [ + { + "repositoryArn": "arn:aws:ecr:us-east-1:000011112222:repository/private-test", + "registryId": "560213429563", + "repositoryName": "private-test", + "repositoryUri": "000011112222.dkr.ecr.us-east-1.amazonaws.com/private-test", + "createdAt": "2021-07-24T17:20:58+05:00", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, + { + "repositoryArn": "arn:aws:ecr:us-east-1:000011112222:repository/sad", + "registryId": "560213429563", + "repositoryName": "sad", + "repositoryUri": "560213429563.dkr.ecr.us-east-1.amazonaws.com/sad", + "createdAt": "2021-11-12T21:35:40+05:00", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "KMS", + "kmsKey": "arn:aws:kms:us-east-1:000011112222:key/92e3e4cf-dfc3-4ea7-a225-22542c8e1528" + } + }, + { + "repositoryArn": "arn:aws:ecr:us-east-1:000011112222:repository/sadeed1", + "registryId": "560213429563", + "repositoryName": "sadeed1", + "repositoryUri": "000011112222.dkr.ecr.us-east-1.amazonaws.com/sadeed1", + "createdAt": "2021-11-12T19:56:56+05:00", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "KMS", + "kmsKey": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (repository, keys, describeKey, repositoryErr, keysErr, describeKeyErr) => { + var keyId = (repository && repository.length && repository[0].encryptionConfiguration.kmsKey) ? repository[0].encryptionConfiguration.kmsKey.split('/')[1] : null; + return { + ecr: { + describeRepositories: { + 'us-east-1': { + err: repositoryErr, + data: repository + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + +describe('ecrRepositoryEncrypted', function () { + describe('run', function () { + it('should PASS if ECR Repository is encrypted with desired encryption level', function (done) { + const cache = createCache([describeRepositories[2]], listKeys, describeKey[0]); + ecrRepositoryEncrypted.run(cache, { ecr_repository_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ECR repository is encrypted with awscmk'); + done(); + }); + }); + + it('should FAIL if ECR Repository is not encrypted with desired encyption level', function (done) { + const cache = createCache([describeRepositories[1]], listKeys, describeKey[1]); + ecrRepositoryEncrypted.run(cache, { ecr_repository_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ECR repository encrypted with awskms'); + done(); + }); + }); + + it('should PASS if no ECR Repository found', function (done) { + const cache = createCache([]); + ecrRepositoryEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECR repositories found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list ECR Repository', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list ECR repositories" }); + ecrRepositoryEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list ECR repositories'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + ecrRepositoryEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ecr/ecrRepositoryHasTags.js b/plugins/aws/ecr/ecrRepositoryHasTags.js new file mode 100644 index 000000000..03fa224a3 --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryHasTags.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECR Repository Has Tags', + category: 'ECR', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that Amazon ECR repositories have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/ecr-using-tags.html', + recommended_action: 'Modify ECR repository and add tags.', + apis: ['ECR:describeRepositories', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['ecr:CreateRepository', 'ecr:TagResource', 'ecr:UntagResource', 'ecr:DeleteRepository'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecr, function(region, rcb) { + var describeRepositories = helpers.addSource(cache, source, + ['ecr', 'describeRepositories', region]); + + if (!describeRepositories) return rcb(); + + if (describeRepositories.err || !describeRepositories.data) { + helpers.addResult(results, 3, + 'Unable to query for ECR repositories: ' + helpers.addError(describeRepositories), region); + return rcb(); + } + + if (!describeRepositories.data.length) { + helpers.addResult(results, 0, 'No ECR repositories present', region); + return rcb(); + } + const ecrARN = []; + for (let repo of describeRepositories.data) { + if (!repo.repositoryArn) continue; + ecrARN.push(repo.repositoryArn); + } + helpers.checkTags(cache, 'ECR repository', ecrARN, region, results, settings); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecr/ecrRepositoryHasTags.spec.js b/plugins/aws/ecr/ecrRepositoryHasTags.spec.js new file mode 100644 index 000000000..83f3860b7 --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryHasTags.spec.js @@ -0,0 +1,130 @@ +var expect = require('chai').expect; +const ecrRepositoryHasTags = require('./ecrRepositoryHasTags'); + +const resourcegroupstaggingapi = [ + { + "ResourceARN": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "Tags": [{key:"key1", value:"value"}], + }, + { + "ResourceARN": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "Tags": [], + }, +] + +const describeRepositories = [ + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "IMMUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, +] + +const createCache = (ecrRepository, rgData) => { + return { + ecr: { + describeRepositories: { + "us-east-1": { + data: ecrRepository + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + } + +} + +const repositoryErrorCache = () => { + return { + ecr: { + describeRepositories: { + "us-east-1": {} + }, + } + } +} + +describe('ecrRepositoryHasTags', () => { + describe('run', () => { + + it('should PASS if no ecr repositories exist', () => { + const cache = createCache([]); + ecrRepositoryHasTags.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No ECR repositories present') + }); + }); + + it('should FAIL if repository does not have tags', () => { + const cache = createCache([describeRepositories[0]], [resourcegroupstaggingapi[1]]); + ecrRepositoryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ECR repository does not have any tags') + }) + }); + + it('should PASS if repository has tags', () => { + const cache = createCache([describeRepositories[0]], [resourcegroupstaggingapi[0]]); + ecrRepositoryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ECR repository has tags') + }) + }); + + it('should UNKNOWN if unable to describe ecr resource', () => { + const cache = repositoryErrorCache(); + ecrRepositoryHasTags.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for ECR repositories') + + }); + }); + it('should give unknown result if unable to query resource group tagging api', () => { + const cache = createCache([describeRepositories[0]],null); + ecrRepositoryHasTags.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/ecr/ecrRepositoryPolicy.js b/plugins/aws/ecr/ecrRepositoryPolicy.js index 33dcd3f7a..0a1de2ffb 100644 --- a/plugins/aws/ecr/ecrRepositoryPolicy.js +++ b/plugins/aws/ecr/ecrRepositoryPolicy.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ECR Repository Policy', category: 'ECR', + domain: 'Containers', + severity: 'Low', description: 'Ensures ECR repository policies do not enable global or public access to images', more_info: 'ECR repository policies should limit access to images to known IAM entities and AWS accounts and avoid the use of account-level wildcards.', link: 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/RepositoryPolicyExamples.html', @@ -23,6 +25,7 @@ module.exports = { default: 'true' } }, + realtime_triggers: ['ecr:CreateRepository', 'ecr:SetRepositoryPolicy', 'ecr:DeleteRepository'], run: function(cache, settings, callback) { var config = { @@ -50,7 +53,7 @@ module.exports = { return rcb(); } - if(describeRepositories.data.length === 0){ + if (describeRepositories.data.length === 0){ helpers.addResult(results, 0, 'No ECR repositories present', region); return rcb(); } @@ -104,11 +107,11 @@ module.exports = { if (Array.isArray(srcAcct) && srcAcct.length == 1 && srcAcct[0] == registryId) continue; } - if (helpers.globalPrincipal(statement.Principal) && config.ecr_check_global_principal) { + if (statement.Principal && helpers.globalPrincipal(statement.Principal, settings) && config.ecr_check_global_principal) { // Check for global access found.push('Repository allows global access for actions: ' + statement.Action.join(', ') + '.'); if (result < 2) result = 2; - } else if (helpers.crossAccountPrincipal(statement.Principal, registryId) && config.ecr_check_cross_account_principal) { + } else if (statement.Principal && helpers.crossAccountPrincipal(statement.Principal, registryId, undefined, settings) && config.ecr_check_cross_account_principal) { // Check for cross-account access found.push('Repository allows cross-account access for actions: ' + statement.Action.join(', ') + '.'); if (result < 2) result = 2; diff --git a/plugins/aws/ecr/ecrRepositoryPolicy.spec.js b/plugins/aws/ecr/ecrRepositoryPolicy.spec.js new file mode 100644 index 000000000..502123a0d --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryPolicy.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +const ecrRepositoryPolicy = require('./ecrRepositoryPolicy'); + +const describeRepositories = [ + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, +] + +const getRepositoryPolicy = [ + { + "registryId": "111111111111", + "repositoryName": "test", + "policyText": "{\n \"Version\" : \"2008-10-17\",\n \"Statement\" : [ {\n \"Sid\" : \"new statement\",\n \"Effect\" : \"Allow\",\n \"Principal\" : \"*\",\n \"Action\" : [ \"ecr:DescribeImages\", \"ecr:PutImage\" ]\n } ]\n}" + }, + { + "registryId": "111111111111", + "repositoryName": "test", + "policyText": "{\n \"Version\" : \"2008-10-17\",\n \"Statement\" : [ {\n \"Sid\" : \"new statement\",\n \"Effect\" : \"Allow\",\n \"Principal\" : { \"AWS\" : \"arn:aws:iam::111122223333:root\"},\n \"Action\" : [ \"ecr:DescribeImages\", \"ecr:PutImage\" ]\n } ]\n}" + }, + { + "registryId": "111111111111", + "repositoryName": "test", + "policyText": "{\n \"Version\" : \"2008-10-17\",\n" + }, + { + "registryId": "111111111111", + "repositoryName": "test", + } +] + +const createCache = (ecrRepository, repositoryPolicy) => { + return { + ecr: { + describeRepositories: { + "us-east-1": { + data: ecrRepository + } + }, + getRepositoryPolicy: { + "us-east-1": { + "test": { + + data: repositoryPolicy + } + } + } + } + } + +} + +const repositoryPolicyErrorCache = (ecrRepository) => { + return { + ecr: { + describeRepositories: { + "us-east-1": { + data: ecrRepository + } + }, + getRepositoryPolicy: { + "us-east-1": { + "test": { + err: { + "message": "Repository policy does not exist for the repository with name 'test' in the registry with id '111111111111'", + "code": "RepositoryPolicyNotFoundException", + "time": "2021-07-24T12:24:42.602Z", + "requestId": "a3aa74d2-c889-419a-98bc-ae91787cddc8", + "statusCode": 400, + "retryable": false, + "retryDelay": 57.277474027512554 + } + } + } + } + } + } +} + +describe('ecrRepositoryPolicy', () => { + describe('run', () => { + it('should PASS if ecr registry policy does not exist', () => { + const cache = repositoryPolicyErrorCache([describeRepositories[0]]); + ecrRepositoryPolicy.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }); + }); + + it('should PASS if custom policy doe snot exist', () => { + const cache = createCache([describeRepositories[0]], getRepositoryPolicy[2]); + ecrRepositoryPolicy.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }); + }); + + it('should PASS if no ecr repositories exist', () => { + const cache = createCache([], getRepositoryPolicy[2]); + ecrRepositoryPolicy.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }); + }); + + it('should PASS if overly permissive statement does not exist', () => { + const cache = createCache([describeRepositories[0]],getRepositoryPolicy[1]); + ecrRepositoryPolicy.run(cache, {ecr_check_cross_account_principal: 'false', ecr_check_global_principal: 'false'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + + it('should FAIL if global access allowed', () => { + const cache = createCache([describeRepositories[0]],getRepositoryPolicy[0]); + ecrRepositoryPolicy.run(cache, {ecr_check_global_principal: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + + it('should FAIL if cross account access allowed', () => { + const cache = createCache([describeRepositories[0]],getRepositoryPolicy[1]); + ecrRepositoryPolicy.run(cache, {ecr_check_cross_account_principal: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + + it('should UNKNOWN if unable to get repository policy', () => { + const cache = createCache([describeRepositories[0]], getRepositoryPolicy[3]); + ecrRepositoryPolicy.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/ecr/ecrRepositoryTagImmutability.js b/plugins/aws/ecr/ecrRepositoryTagImmutability.js index d94bd79be..9aade5142 100644 --- a/plugins/aws/ecr/ecrRepositoryTagImmutability.js +++ b/plugins/aws/ecr/ecrRepositoryTagImmutability.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ECR Repository Tag Immutability', category: 'ECR', + domain: 'Containers', + severity: 'Low', description: 'Ensures ECR repository image tags cannot be overwritten', more_info: 'ECR repositories should be configured to prevent overwriting of image tags to avoid potentially-malicious images from being deployed to live environments.', link: 'https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-tag-mutability.html', recommended_action: 'Update ECR registry configurations to ensure image tag mutability is set to immutable.', apis: ['ECR:describeRepositories'], + realtime_triggers: ['ecr:CreateRepository', 'ecr:PutImageTagMutability', 'ecr:DeleteRepository'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/ecr/ecrRepositoryTagImmutability.spec.js b/plugins/aws/ecr/ecrRepositoryTagImmutability.spec.js new file mode 100644 index 000000000..34c1e8015 --- /dev/null +++ b/plugins/aws/ecr/ecrRepositoryTagImmutability.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +const ecrRepositoryTagImmutability = require('./ecrRepositoryTagImmutability'); + +const describeRepositories = [ + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "MUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, + { + "repositoryArn": "arn:aws:ecr:us-east-1:111111111111:repository/test", + "registryId": "111111111111", + "repositoryName": "test", + "repositoryUri": "111111111111.dkr.ecr.us-east-1.amazonaws.com/test", + "createdAt": "2021-07-24T12:20:58.000Z", + "imageTagMutability": "IMMUTABLE", + "imageScanningConfiguration": { + "scanOnPush": false + }, + "encryptionConfiguration": { + "encryptionType": "AES256" + } + }, +] + +const createCache = (ecrRepository) => { + return { + ecr: { + describeRepositories: { + "us-east-1": { + data: ecrRepository + } + } + } + } + +} + +const repositoryErrorCache = () => { + return { + ecr: { + describeRepositories: { + "us-east-1": {} + }, + } + } +} + +describe('ecrRepositoryTagImmutability', () => { + describe('run', () => { + + it('should PASS if no ecr repositories exist', () => { + const cache = createCache([]); + ecrRepositoryTagImmutability.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }); + }); + + it('should FAIL if repository is mutable', () => { + const cache = createCache([describeRepositories[0]]); + ecrRepositoryTagImmutability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + + it('should PASS if repository is immutable', () => { + const cache = createCache([describeRepositories[1]]); + ecrRepositoryTagImmutability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + + it('should UNKNOWN if unable to get repository policy', () => { + const cache = repositoryErrorCache(); + ecrRepositoryTagImmutability.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/ecs/ecsClusterActiveService.js b/plugins/aws/ecs/ecsClusterActiveService.js new file mode 100644 index 000000000..6716c3c6e --- /dev/null +++ b/plugins/aws/ecs/ecsClusterActiveService.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Cluster Active Services', + category: 'ECS', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that AWS ECS clusters have active services.', + more_info: 'Amazon ECS service allows you to run and maintain a specified number of instances of a task definition simultaneously in an Amazon ECS cluster. It is recommended to have clusters with the active services to avoid any container attack surface.', + recommended_action: 'Modify Cluster and create new service.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html', + apis: ['ECS:listClusters', 'ECS:describeCluster'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:CreateService', 'ecs:UpdateService', 'ecs:DeleteService', 'ecs:DeleteCluster'], + + run: function(cache, settings, callback){ + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['ecs','listClusters',region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters present', region); + return rcb(); + } + + for (var clusterARN of listClusters.data) { + var describeCluster = helpers.addSource(cache, source, + ['ecs', 'describeCluster', region, clusterARN]); + + if (!describeCluster || describeCluster.err ||!describeCluster.data || + !describeCluster.data.clusters || !describeCluster.data.clusters.length) { + helpers.addResult(results, 3, + 'Unable to describe ECS cluster: ' +helpers.addError(describeCluster), region, clusterARN); + continue; + } + + const cluster = describeCluster.data.clusters[0]; + + if (!cluster) continue; + + if (cluster.activeServicesCount && cluster.activeServicesCount > 0) { + helpers.addResult(results, 0, + 'ECS cluster has active services', region, clusterARN); + } else { + helpers.addResult(results, 2, + 'ECS cluster does not have active services', region, clusterARN); + } + } + rcb(); + }, + function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecs/ecsClusterActiveService.spec.js b/plugins/aws/ecs/ecsClusterActiveService.spec.js new file mode 100644 index 000000000..77013768a --- /dev/null +++ b/plugins/aws/ecs/ecsClusterActiveService.spec.js @@ -0,0 +1,119 @@ +var expect = require('chai').expect; +var ecs = require('./ecsClusterActiveService'); + +const createCache = (listData, descData) => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'arn:aws:ecs:us-east-1:012345678911:cluster/testCluster': { + err: null, + data: descData + } + } + } + }, + } +}; + +describe('ECSContainerInsightsEnabled', function () { + describe('run', function () { + it('should give passing result if no ECS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No ECS clusters present') + done() + }; + const cache = createCache([], {}); + ecs.run(cache, {}, callback); + }) + + it('should FAIL if ecs cluster does not has active service', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('ECS cluster does not have active services') + done() + }; + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "activeServicesCount": 0, + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "disabled"}] + }]} + + ); + + ecs.run(cache, {}, callback); + }) + it('should give Unknown result if unable to query ecs cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for ECS clusters') + done() + }; + + const cache = createCache( + null, + { + "clusters": [{ + "name": "mycluster", + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }) + + it('should give unknown result if unable to describe the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe ECS cluster'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + null + + ); + ecs.run(cache, {}, callback); + }); + + it('should PASS if ecs cluster has active services', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ECS cluster has active services'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "activeServicesCount": 1, + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }); + + }); +}) \ No newline at end of file diff --git a/plugins/aws/ecs/ecsClusterWithActiveTask.js b/plugins/aws/ecs/ecsClusterWithActiveTask.js new file mode 100644 index 000000000..715455b60 --- /dev/null +++ b/plugins/aws/ecs/ecsClusterWithActiveTask.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Cluster Service Active Tasks', + category: 'ECS', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure ECS clusters have services with running tasks.', + more_info: 'A task is the instantiation of a task definition within a cluster. Amazon ECS service instantiates and maintains the specified number of tasks simultaneously in a cluster. As a best practice, ensure you always have running tasks in a cluster.', + recommended_action: 'Modify Cluster services and add tasks', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html', + apis: ['ECS:listClusters', 'ECS:describeCluster'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:RunTask', 'ecs:StopTask', 'ecs:DeleteCluster'], + + run: function(cache, settings, callback){ + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb){ + + var listClusters = helpers.addSource(cache, source, + ['ecs','listClusters',region]); + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters present', region); + return rcb(); + } + + for (var clusterARN of listClusters.data) { + var describeCluster = helpers.addSource(cache, source, + ['ecs', 'describeCluster', region, clusterARN]); + + if (!describeCluster || describeCluster.err ||!describeCluster.data || + !describeCluster.data.clusters || !describeCluster.data.clusters.length) { + helpers.addResult(results, 3, + 'Unable to describe ECS cluster: ' +helpers.addError(describeCluster), region, clusterARN); + continue; + } + const cluster = describeCluster.data.clusters[0]; + + if (!cluster) continue; + + if (cluster.activeServicesCount && cluster.activeServicesCount > 0 && + cluster.runningTasksCount && cluster.runningTasksCount > 0) { + helpers.addResult(results, 0, + 'ECS cluster has service with running tasks', region, clusterARN); + } else { + helpers.addResult(results, 2, + 'ECS cluster does not have service with running tasks', region, clusterARN); + } + } + rcb(); + }, + function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecs/ecsClusterWithActiveTask.spec.js b/plugins/aws/ecs/ecsClusterWithActiveTask.spec.js new file mode 100644 index 000000000..39869caa0 --- /dev/null +++ b/plugins/aws/ecs/ecsClusterWithActiveTask.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var ecs = require('./ecsClusterWithActiveTask'); + +const createCache = (listData, descData) => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'arn:aws:ecs:us-east-1:012345678911:cluster/testCluster': { + err: null, + data: descData + } + } + } + }, + } +}; + +describe('ecsClusterWithActiveTask', function () { + describe('run', function () { + it('should give passing result if no ECS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No ECS clusters present') + done() + }; + + const cache = createCache([], {}); + + ecs.run(cache, {}, callback); + }) + + it('should PASS if ecs cluster does not have active tasks on services', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('ECS cluster does not have service with running tasks') + done() + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "activeServicesCount": 1, + "runningTasksCount": 0, + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "disabled"}] + }]} + + ); + + ecs.run(cache, {}, callback); + }) + it('should give Unknown result if unable to query ecs cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for ECS clusters') + done() + }; + + const cache = createCache( + null, + { + "clusters": [{ + "name": "mycluster", + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }) + + it('should give unknown result if unable to describe the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe ECS cluster'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + null + + ); + ecs.run(cache, {}, callback); + }); + + it('should give passing result if ecs cluster have service with running tasks', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ECS cluster has service with running tasks'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "activeServicesCount": 1, + "runningTasksCount": 1, + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }); + + }); +}) \ No newline at end of file diff --git a/plugins/aws/ecs/ecsClustersHaveTags.js b/plugins/aws/ecs/ecsClustersHaveTags.js new file mode 100644 index 000000000..f04b0b1ea --- /dev/null +++ b/plugins/aws/ecs/ecsClustersHaveTags.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Cluster Has Tags', + category: 'ECS', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that AWS ECS Clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-using-tags.html', + recommended_action: 'Modify ECS Cluster and add tags.', + apis: ['ECS:listClusters', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:TagResource', 'ecs:UntagResource', 'ecs:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['ecs', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length){ + helpers.addResult(results, 0, 'No ECS clusters present', region); + return rcb(); + } + + helpers.checkTags(cache,'ECS clsuters', listClusters.data, region, results, settings); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ecs/ecsClustersHaveTags.spec.js b/plugins/aws/ecs/ecsClustersHaveTags.spec.js new file mode 100644 index 000000000..c05ac6c07 --- /dev/null +++ b/plugins/aws/ecs/ecsClustersHaveTags.spec.js @@ -0,0 +1,104 @@ +var expect = require('chai').expect; +var ecsClustersHaveTags = require('./ecsClustersHaveTags'); + +const createCache = (clsuterData, rgData) => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: null, + data: clsuterData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + } +}; + +describe('ecsClustersHaveTags', function () { + describe('run', function () { + it('should give unknown result if unable to list ECS clusters', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for ECS clusters'); + done(); + }; + + const cache = createCache(null, []); + ecsClustersHaveTags.run(cache, {}, callback); + }); + + it('should give passing result if ecs Clusters not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS clusters present'); + done(); + }; + const cache = createCache([], null); + ecsClustersHaveTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query all resources from group tagging api'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:101363884315:cluster/test2'], + null + ); + + ecsClustersHaveTags.run(cache, {}, callback); + }); + + it('should give passing result if ecs Clusters have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ECS clsuters has tags'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:101363884315:cluster/test2'], + [{ + "ResourceARN": "arn:aws:ecs:us-east-1:101363884315:cluster/test2", + "Tags": [{key:"key1", value:"value"}], + }] + ); + ecsClustersHaveTags.run(cache, {}, callback); + }) + + it('should give failing result if ecs cluster does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ECS clsuters does not have any tags'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:101363884315:cluster/test2'], + [{ + "ResourceARN": "arn:aws:ecs:us-east-1:101363884315:cluster/Test-cluster", + "Tags": [], + }] + ); + + ecsClustersHaveTags.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ecs/ecsContainerInsightsEnabled.js b/plugins/aws/ecs/ecsContainerInsightsEnabled.js new file mode 100644 index 000000000..4fd3f1e41 --- /dev/null +++ b/plugins/aws/ecs/ecsContainerInsightsEnabled.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Container Insights Enabled', + category: 'ECS', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that ECS clusters have CloudWatch Container Insights feature enabled.', + more_info: 'CloudWatch Container Insights provides monitoring and troubleshooting solution for containerized applications and microservices that collects, aggregates and summarizes resource utilization such as CPU, memory, disk, and network.', + recommended_action: 'Enabled container insights feature for ECS clusters.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/cloudwatch-container-insights.html', + apis: ['ECS:listClusters', 'ECS:describeCluster'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:UpdateClusterSettings', 'ecs:DeleteCluster'], + + run: function(cache, settings, callback){ + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb){ + + var listClusters = helpers.addSource(cache, source, + ['ecs','listClusters',region]); + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters present', region); + return rcb(); + } + + for (var clusterARN of listClusters.data) { + var describeCluster = helpers.addSource(cache, source, + ['ecs', 'describeCluster', region, clusterARN]); + + if (!describeCluster || describeCluster.err ||!describeCluster.data || + !describeCluster.data.clusters || !describeCluster.data.clusters.length) { + helpers.addResult(results, 3, + 'Unable to describe ECS cluster: ' +helpers.addError(describeCluster), region, clusterARN); + continue; + } + + const cluster = describeCluster.data.clusters[0]; + let containerInsightsEnabled = (cluster.settings && cluster.settings.length) ? cluster.settings.find(item => item.name == 'containerInsights' && item.value == 'enabled') : false; + + if (containerInsightsEnabled) { + helpers.addResult(results, 0, + 'ECS cluster has container insights enabled', region, clusterARN); + } else { + helpers.addResult(results, 2, + 'ECS cluster does not have container insights enabled', region, clusterARN); + } + } + rcb(); + }, + function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ecs/ecsContainerInsightsEnabled.spec.js b/plugins/aws/ecs/ecsContainerInsightsEnabled.spec.js new file mode 100644 index 000000000..fa6b2d56c --- /dev/null +++ b/plugins/aws/ecs/ecsContainerInsightsEnabled.spec.js @@ -0,0 +1,121 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var ecs = require('./ecsContainerInsightsEnabled'); + +const createCache = (listData, descData) => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'arn:aws:ecs:us-east-1:012345678911:cluster/testCluster': { + err: null, + data: descData + } + } + } + }, + } +}; + +describe('ECSContainerInsightsEnabled', function () { + describe('run', function () { + it('should give passing result if no ECS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No ECS clusters present') + done() + }; + + const cache = createCache([], {}); + + ecs.run(cache, {}, callback); + }) + + it('should give error result if ecs cluster has container insights disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('ECS cluster does not have container insights enabled') + done() + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "disabled"}] + }]} + + ); + + ecs.run(cache, {}, callback); + }) + it('should give Unknown result if unable to query ecs cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for ECS clusters') + done() + }; + + const cache = createCache( + null, + { + "clusters": [{ + "name": "mycluster", + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }) + + it('should give unknown result if unable to describe the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to describe ECS cluster'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + null + + ); + ecs.run(cache, {}, callback); + }); + + it('should give passing result if ecs cluster has container insights is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ECS cluster has container insights enabled'); + done(); + }; + + const cache = createCache( + ['arn:aws:ecs:us-east-1:012345678911:cluster/testCluster'], + { + "clusters": [{ + "name": "mycluster", + "arn": "arn:aws:ecs:us-east-1:012345678911:cluster/testCluster", + "settings": [{"name": "containerInsights", value: "enabled"}] + }]} + + ); + ecs.run(cache, {}, callback); + }); + + }); +}) \ No newline at end of file diff --git a/plugins/aws/ecs/ecsFargatePlatformVersion.js b/plugins/aws/ecs/ecsFargatePlatformVersion.js new file mode 100644 index 000000000..6d73e9bdf --- /dev/null +++ b/plugins/aws/ecs/ecsFargatePlatformVersion.js @@ -0,0 +1,114 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Fargate Latest Platform Version', + category: 'ECS', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Amazon ECS Fargate services are using the latest Fargate platform version.', + more_info: 'Using the latest Fargate platform version ensures services benefit from up-to-date security patches, performance improvements, and feature updates.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/platform_versions.html', + recommended_action: 'Update ECS Fargate services to use the latest platform version.', + apis: ['ECS:listClusters', 'ECS:listServices', 'ECS:describeServices'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:CreateService', 'ecs:UpdateService', 'ecs:DeleteService', 'ecs:DeleteCluster'], + run: function(cache, settings, callback){ + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb){ + + var listClusters = helpers.addSource(cache, source, + ['ecs', 'listClusters', region]); + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters found', region); + return rcb(); + } + + for (var clusterArn of listClusters.data) { + var listServices = helpers.addSource(cache, source, + ['ecs', 'listServices', region, clusterArn]); + + if (!listServices || listServices.err || !listServices.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS services: ' + helpers.addError(listServices), region, clusterArn); + continue; + } + + if (!listServices.data.length) { + helpers.addResult(results, 0, + 'No ECS Fargate services found in cluster', region, clusterArn); + continue; + } + + var hasFargateServices = false; + + for (var serviceArn of listServices.data) { + var describeServices = helpers.addSource(cache, source, + ['ecs', 'describeServices', region, serviceArn]); + + if (!describeServices || describeServices.err || !describeServices.data) { + helpers.addResult(results, 3, + 'Unable to describe ECS service: ' + helpers.addError(describeServices), region, serviceArn); + continue; + } + + var service = null; + if (describeServices.data.services && describeServices.data.services.length > 0) { + service = describeServices.data.services[0]; + } + if (!service) continue; + + var isFargate = false; + + if (service.launchType && service.launchType.toLowerCase() === 'fargate') { + isFargate = true; + } else if (service.capacityProviderStrategy && service.capacityProviderStrategy.length > 0) { + for (var cp of service.capacityProviderStrategy) { + if (cp.capacityProvider && cp.capacityProvider.toLowerCase().indexOf('fargate') !== -1) { + isFargate = true; + break; + } + } + } + + if (!isFargate) continue; + + hasFargateServices = true; + var platformVersion = service.platformVersion; + var platformVersionLower = platformVersion ? platformVersion.toLowerCase() : ''; + + if (platformVersionLower !== 'latest') { + helpers.addResult(results, 2, + 'ECS Fargate service is not using the latest platform version', + region, serviceArn); + } else { + helpers.addResult(results, 0, + 'ECS Fargate service is using the latest platform version', + region, serviceArn); + } + } + + if (!hasFargateServices) { + helpers.addResult(results, 0, + 'No ECS Fargate services found in cluster', + region, clusterArn); + } + } + rcb(); + }, + function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js b/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js new file mode 100644 index 000000000..fb97e625a --- /dev/null +++ b/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +const ecsFargatePlatformVersion = require('./ecsFargatePlatformVersion'); + +const listClusters = [ + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster', + 'arn:aws:ecs:us-east-1:112233445566:cluster/another-cluster' +]; + +const createCache = (clusters, servicesMap, describeServicesMap) => { + var cache = { + ecs: { + listClusters: { + 'us-east-1': { + data: clusters || [] + } + }, + listServices: { + 'us-east-1': {} + }, + describeServices: { + 'us-east-1': {} + } + } + }; + + if (clusters && clusters.length) { + for (var clusterArn of clusters) { + if (servicesMap && servicesMap[clusterArn]) { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: servicesMap[clusterArn] + }; + } else { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: [] + }; + } + } + } + + if (describeServicesMap) { + for (var serviceArn in describeServicesMap) { + cache.ecs.describeServices['us-east-1'][serviceArn] = { + data: describeServicesMap[serviceArn] + }; + } + } + + return cache; +}; + +const createErrorCache = () => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + } + } + } + } + }; +}; + +describe('ecsFargatePlatformVersion', function () { + describe('run', function () { + it('should PASS if no clusters found', function (done) { + const cache = createCache([], {}, {}); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS clusters found'); + done(); + }); + }); + + it('should PASS if no Fargate services found', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'EC2' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS Fargate services found'); + done(); + }); + }); + + it('should PASS if Linux Fargate service uses LATEST platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: 'LATEST', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('using the latest platform version'); + done(); + }); + }); + + it('should FAIL if Linux Fargate service uses 1.3.0 platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: '1.3.0', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not using the latest platform version'); + done(); + }); + }); + + it('should PASS if Windows Fargate service uses LATEST platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: 'LATEST', + platformFamily: 'WINDOWS_SERVER' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('using the latest platform version'); + done(); + }); + }); + + it('should FAIL if Fargate service has no platform version configured', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not using the latest platform version'); + done(); + }); + }); + + it('should UNKNOWN if unable to list clusters', function (done) { + const cache = createErrorCache(); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ecs/ecsServicePublicIpDisabled.js b/plugins/aws/ecs/ecsServicePublicIpDisabled.js new file mode 100644 index 000000000..e3f65eb3a --- /dev/null +++ b/plugins/aws/ecs/ecsServicePublicIpDisabled.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Service Public IP Disabled', + category: 'ECS', + domain: 'Containers', + severity: 'High', + description: 'Ensure that Amazon ECS services have assignPublicIp set to disabled.', + more_info: 'Enabling public IP assignment could expose container application servers to unintended or unauthorized access. Services should use private networking with NAT gateways or VPC endpoints for outbound internet access.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-networking.html', + recommended_action: 'Modify ECS services to set assignPublicIp to disabled in the network configuration.', + apis: ['ECS:listClusters', 'ECS:listServices', 'ECS:describeServices'], + realtime_triggers: ['ecs:CreateService', 'ecs:UpdateService', 'ecs:DeleteService', 'ecs:CreateCluster', 'ecs:DeleteCluster', 'ecs:UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['ecs', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters found', region); + return rcb(); + } + + for (var clusterArn of listClusters.data) { + var listServices = helpers.addSource(cache, source, + ['ecs', 'listServices', region, clusterArn]); + + if (!listServices || listServices.err || !listServices.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS services: ' + helpers.addError(listServices), region, clusterArn); + continue; + } + + if (!listServices.data.length) { + helpers.addResult(results, 0, + 'No ECS services found in cluster', region, clusterArn); + continue; + } + + for (var serviceArn of listServices.data) { + var describeServices = helpers.addSource(cache, source, + ['ecs', 'describeServices', region, serviceArn]); + + if (!describeServices || describeServices.err || !describeServices.data) { + helpers.addResult(results, 3, + 'Unable to describe ECS service: ' + helpers.addError(describeServices), region, serviceArn); + continue; + } + + + var service = null; + if (describeServices.data.services && describeServices.data.services.length > 0) { + service = describeServices.data.services[0]; + } + if (!service) continue; + + var networkMode = service.networkConfiguration; + var assignPublicIp = null; + + if (networkMode && networkMode.awsvpcConfiguration) { + assignPublicIp = networkMode.awsvpcConfiguration.assignPublicIp; + var assignPublicIpLower = assignPublicIp ? assignPublicIp.toLowerCase() : ''; + if (assignPublicIpLower !== 'disabled') { + helpers.addResult(results, 2, + 'ECS service does not have assignPublicIp set to disabled', + region, serviceArn); + } else { + helpers.addResult(results, 0, + 'ECS service has assignPublicIp set to disabled', + region, serviceArn); + } + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js b/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js new file mode 100644 index 000000000..3bbaeb4d8 --- /dev/null +++ b/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +const ecsServicePublicIpDisabled = require('./ecsServicePublicIpDisabled'); + +const listClusters = [ + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster', + 'arn:aws:ecs:us-east-1:112233445566:cluster/another-cluster' +]; + +const createCache = (clusters, servicesMap, describeServicesMap) => { + var cache = { + ecs: { + listClusters: { + 'us-east-1': { + data: clusters || [] + } + }, + listServices: { + 'us-east-1': {} + }, + describeServices: { + 'us-east-1': {} + } + } + }; + + if (clusters && clusters.length) { + for (var clusterArn of clusters) { + if (servicesMap && servicesMap[clusterArn]) { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: servicesMap[clusterArn] + }; + } else { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: [] + }; + } + } + } + + if (describeServicesMap) { + for (var serviceArn in describeServicesMap) { + cache.ecs.describeServices['us-east-1'][serviceArn] = { + data: describeServicesMap[serviceArn] + }; + } + } + + return cache; +}; + +const createErrorCache = () => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + } + } + } + } + }; +}; + +describe('ecsServicePublicIpDisabled', function () { + describe('run', function () { + it('should PASS if no clusters found', function (done) { + const cache = createCache([], {}, {}); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS clusters found'); + done(); + }); + }); + + it('should PASS if no services found', function (done) { + const cache = createCache(listClusters, {}, {}); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS services found in cluster'); + done(); + }); + }); + + it('should PASS if service has assignPublicIp set to disabled', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'DISABLED', + subnets: ['subnet-12345'], + securityGroups: ['sg-12345'] + } + } + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has assignPublicIp set to disabled'); + done(); + }); + }); + + it('should FAIL if service has assignPublicIp set to ENABLED', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'ENABLED', + subnets: ['subnet-12345'], + securityGroups: ['sg-12345'] + } + } + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have assignPublicIp set to disabled'); + done(); + }); + }); + + it('should UNKNOWN if unable to list clusters', function (done) { + const cache = createErrorCache(); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/efs/efsCmkEncrypted.js b/plugins/aws/efs/efsCmkEncrypted.js new file mode 100644 index 000000000..0fc599395 --- /dev/null +++ b/plugins/aws/efs/efsCmkEncrypted.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EFS CMK Encrypted', + category: 'EFS', + domain: 'Storage', + severity: 'High', + description: 'Ensure EFS file systems are encrypted using Customer Master Keys (CMKs).', + more_info: 'EFS file systems should use KMS Customer Master Keys (CMKs) instead of AWS managed keys for encryption in order to have full control over data encryption and decryption.', + link: 'https://docs.aws.amazon.com/efs/latest/ug/encryption-at-rest.html', + recommended_action: 'Encryption at rest key can only be configured during file system creation. Encryption of data in transit is configured when mounting your file system. 1. Backup your data in not encrypted efs 2. Recreate the EFS and use KMS CMK for encryption of data at rest.', + apis: ['EFS:describeFileSystems', 'KMS:listAliases'], + settings: { + cmk_unencrypted_threshold: { + name: 'Threshold for EFS CMK Unencrypted Individual Reporting.', + description: 'Plugin results will become aggregated once this value is breached', + regex: '^[0-9]*$', + default: 20 + } + }, + realtime_triggers: ['efs:CreateFileSystem', 'efs:DeleteFileSystem'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var cmk_unencrypted_threshold = parseInt(settings.cmk_unencrypted_threshold || this.settings.cmk_unencrypted_threshold.default); + + async.each(regions.efs, function(region, rcb) { + var describeFileSystems = helpers.addSource(cache, source, + ['efs', 'describeFileSystems', region]); + + if (!describeFileSystems) return rcb(); + + if (describeFileSystems.err || !describeFileSystems.data) { + helpers.addResult( + results, 3, + `Unable to query for EFS file systems: ${helpers.addError(describeFileSystems)}`, region); + return rcb(); + } + + if (!describeFileSystems.data.length){ + helpers.addResult(results, 0, 'No EFS file systems found', region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var aliasId; + var kmsAliases = {}; + var cmkUnencryptedEFS = []; + var danglingKeys = []; + + listAliases.data.forEach(function(alias){ + aliasId = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliases[aliasId] = alias.AliasName; + }); + + describeFileSystems.data.forEach(function(efs){ + if (efs.Encrypted && efs.KmsKeyId){ + if (kmsAliases[efs.KmsKeyId] && kmsAliases[efs.KmsKeyId] === 'alias/aws/elasticfilesystem') { + cmkUnencryptedEFS.push(efs); + } else if (!kmsAliases[efs.KmsKeyId]) { + danglingKeys.push(efs); + } + } + }); + + if (cmkUnencryptedEFS.length > cmk_unencrypted_threshold) { + helpers.addResult(results, 2, `More than ${cmk_unencrypted_threshold} EFS systems are not using CMK for encryption`, region); + } else if (cmkUnencryptedEFS.length) { + for (let u in cmkUnencryptedEFS) { + let resource = cmkUnencryptedEFS[u].FileSystemArn; + helpers.addResult(results, 2, `EFS "${cmkUnencryptedEFS[u].FileSystemId}" is not CMK encrypted`, region, resource); + } + } else { + helpers.addResult(results, 0, 'No AWS managed key encrypted file systems found', region); + } + + if (danglingKeys.length > cmk_unencrypted_threshold) { + helpers.addResult(results, 2, `More than ${cmk_unencrypted_threshold} EFS systems are referencing deleted KMS keys`, region); + } else if (danglingKeys.length) { + for (let u in danglingKeys) { + let resource = danglingKeys[u].FileSystemArn; + helpers.addResult(results, 2, `EFS "${danglingKeys[u].FileSystemId}" is referencing deleted KMS key`, region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/efs/efsCmkEncrypted.spec.js b/plugins/aws/efs/efsCmkEncrypted.spec.js new file mode 100644 index 000000000..c877fcfbb --- /dev/null +++ b/plugins/aws/efs/efsCmkEncrypted.spec.js @@ -0,0 +1,205 @@ +var expect = require('chai').expect; +var efsCmkEncrypted = require('./efsCmkEncrypted'); + +const fileSystems = [ + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467" + }, + { + "OwnerId": "112233445566", + "CreationToken": "quickCreated-c6e60995-a5eb-478c-8b60-2464510ebb58", + "FileSystemId": "fs-30f3dab2", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-30f3dab2", + "CreationTime": "2020-10-18T17:53:27.000Z", + "LifeCycleState": "available", + "Name": "", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/080891c0-b3a8-42a3-91be-c23aa7b46d3f" + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-76ffd6d5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-76ffd6d5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null, + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/143947b8-b22b-4360-9835-af7d346092f9" + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/080891c0-b3a8-42a3-91be-c23aa7b46d3f" + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "Name": null, + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/12345678-b3a8-42a3-91be-c23aa7b46d3f" + } +]; + +const listAliases = [ + { + "AliasName": "alias/efsKmsKey", + "AliasArn": "arn:aws:kms:us-east-1:112233445566:alias/efsKmsKey", + "TargetKeyId": "60c4f21b-e271-4e97-86ae-6403618a9467" + }, + { + "AliasName": "alias/aws/elasticfilesystem", + "AliasArn": "arn:aws:kms:us-east-1:112233445566:alias/aws/elasticfilesystem", + "TargetKeyId": "080891c0-b3a8-42a3-91be-c23aa7b46d3f" + }, +]; + +const createCache = (fileSystems, kmsAliases) => { + return { + efs: { + describeFileSystems: { + 'us-east-1': { + data: fileSystems + }, + }, + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + efs: { + describeFileSystems: { + 'us-east-1': { + err: { + message: 'error while describing EFS file systems' + }, + }, + }, + }, + kms: { + listAliases: { + 'us-east-1': { + err: { + message: 'error while listing KMS aliases' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + efs: { + describeFileSystems: { + 'us-east-1': null, + }, + }, + kms: { + listAliases: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('efsCmkEncrypted', function () { + describe('run', function () { + it('should PASS if all EFS file systems are using Customer Master Key for encryption', function (done) { + const cache = createCache([fileSystems[0]], [listAliases[0]]); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if EFS file system is not using Customer Master Key for encryption', function (done) { + const cache = createCache([fileSystems[1]], [listAliases[1]]); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if number of AWS managed key encrypted EFS file systems is more than threshold', function (done) { + const cache = createCache([fileSystems[1], fileSystems[4]], listAliases); + efsCmkEncrypted.run(cache, { cmk_unencrypted_threshold: 1 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if EFS file systems is referencing deleted KMS keys', function (done) { + const cache = createCache([fileSystems[5]], listAliases); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + + it('should PASS if no EFS file systems found', function (done) { + const cache = createCache([]); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe file systems', function (done) { + const cache = createErrorCache(); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe file systems response not found', function (done) { + const cache = createNullCache(); + efsCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/efs/efsEncryptionEnabled.js b/plugins/aws/efs/efsEncryptionEnabled.js index 852d7c46a..e63b4856f 100644 --- a/plugins/aws/efs/efsEncryptionEnabled.js +++ b/plugins/aws/efs/efsEncryptionEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EFS Encryption Enabled', category: 'EFS', + domain: 'Storage', + severity: 'High', description: 'Ensures that EFS volumes are encrypted at rest', more_info: 'EFS offers data at rest encryption using keys managed through AWS Key Management Service (KMS).', link: 'https://aws.amazon.com/blogs/aws/new-encryption-at-rest-for-amazon-elastic-file-system-efs/', @@ -17,11 +19,13 @@ module.exports = { 'encryption should be enabled for all volumes storing this type ' + 'of data.' }, + realtime_triggers: ['efs:CreateFileSystem','efs:DeleteFileSystem'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); async.each(regions.efs, function(region, rcb) { var describeFileSystems = helpers.addSource(cache, source, @@ -36,7 +40,7 @@ module.exports = { return rcb(); } - if(describeFileSystems.data.length === 0){ + if (describeFileSystems.data.length === 0){ helpers.addResult(results, 0, 'No EFS file systems present', region); return rcb(); } @@ -54,7 +58,7 @@ module.exports = { } else if (unencryptedEFS.length) { for (var u in unencryptedEFS) { // ARN: arn:aws:elasticfilesystem:region:account-id:file-system/file-system-id - var arn = 'arn:aws:elasticfilesystem:' + region + ':' + unencryptedEFS[u].OwnerId + ':file-system/' + unencryptedEFS[u].FileSystemId; + var arn = `arn:${awsOrGov}:elasticfilesystem:` + region + ':' + unencryptedEFS[u].OwnerId + ':file-system/' + unencryptedEFS[u].FileSystemId; helpers.addResult(results, 2, 'EFS: ' + unencryptedEFS[u].FileSystemId + ' is unencrypted', region, arn); } } else { diff --git a/plugins/aws/efs/efsEncryptionEnabled.spec.js b/plugins/aws/efs/efsEncryptionEnabled.spec.js new file mode 100644 index 000000000..2ef0a45d4 --- /dev/null +++ b/plugins/aws/efs/efsEncryptionEnabled.spec.js @@ -0,0 +1,328 @@ +var expect = require('chai').expect; +var efsEncryptionEnabled = require('./efsEncryptionEnabled'); + +const fileSystems = [ + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467" + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + { + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-f3fg4ht5", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-f3fg4ht5", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null + }, + +]; + + +const createCache = (fileSystems) => { + return { + efs: { + describeFileSystems: { + 'us-east-1': { + data: fileSystems + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + efs: { + describeFileSystems: { + 'us-east-1': { + err: { + message: 'error while describing EFS file systems' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + efs: { + describeFileSystems: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('efsEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if EFS file systems are encrypted', function (done) { + const cache = createCache([fileSystems[0]]); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No unencrypted file systems found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if EFS file system is not encrypted', function (done) { + const cache = createCache([fileSystems[1]]); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is unencrypted'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if EFS file systems more than 20 files are not encrypted', function (done) { + const cache = createCache(fileSystems); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('More than 20 EFS systems are unencrypted'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no EFS file systems found', function (done) { + const cache = createCache([]); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No EFS file systems present'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe file systems', function (done) { + const cache = createErrorCache(); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe file systems response not found', function (done) { + const cache = createNullCache(); + efsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/efs/efsHasTags.js b/plugins/aws/efs/efsHasTags.js new file mode 100644 index 000000000..6fefe619e --- /dev/null +++ b/plugins/aws/efs/efsHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EFS Has Tags', + category: 'EFS', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that AWS EFS file systems have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/efs/latest/ug/manage-fs-tags.html', + recommended_action: 'Modify EFS file systems to add tags.', + apis: ['EFS:describeFileSystems'], + realtime_triggers: ['efs:CreateFileSystem', 'efs:TagResource', 'efs:UnTagResource','efs:DeleteFileSystem'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.efs, function(region, rcb) { + var describeFileSystems = helpers.addSource(cache, source, + ['efs', 'describeFileSystems', region]); + + if (!describeFileSystems) return rcb(); + + if (describeFileSystems.err || !describeFileSystems.data) { + helpers.addResult(results, 3, + 'Unable to query for EFS file systems: ' + helpers.addError(describeFileSystems), region); + return rcb(); + } + + if (!describeFileSystems.data.length){ + helpers.addResult(results, 0, 'No EFS file systems present', region); + return rcb(); + } + + for (var efs of describeFileSystems.data) { + const { FileSystemArn, Tags} = efs; + + if (!Tags.length){ + helpers.addResult(results, 2, 'EFS file system does not have tags associated', region, FileSystemArn); + } else { + helpers.addResult(results, 0, 'EFS file system has tags', region, FileSystemArn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/efs/efsHasTags.spec.js b/plugins/aws/efs/efsHasTags.spec.js new file mode 100644 index 000000000..1bd63dd83 --- /dev/null +++ b/plugins/aws/efs/efsHasTags.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var efsHasTags = require('./efsHasTags'); + +const createCache = (efsData) => { + return { + efs: { + describeFileSystems: { + 'us-east-1': { + err: null, + data: efsData + } + } + } +}; +} + +describe('efsHasTags', function () { + describe('run', function () { + + it('should give passing result if no EFS file systems found', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No EFS file systems present'); + done(); + }; + + const cache = createCache([]); + + efsHasTags.run(cache, {}, callback); + }); + + it('shouldgive UNKNOWN result if unable to describe file systems', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for EFS file systems'); + done(); + }; + + const cache = createCache(null); + efsHasTags.run(cache, {}, callback); + }); + + it('should give failing result if EFS file systems have no tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('EFS file system does not have tags associated'); + done(); + }; + + const cache = createCache([{ + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", + "Tags": [] + }]); + + efsHasTags.run(cache, {}, callback); + }); + + it('should give passing results if EFS file systems have tags specified', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('EFS file system has tags') + done() + }; + + const cache = createCache([{ + "OwnerId": "112233445566", + "CreationToken": "console-c3581ec3-fc03-4a0e-924a-4bc09a4ec64e", + "FileSystemId": "fs-61dff6e3", + "FileSystemArn": "arn:aws:elasticfilesystem:us-east-1:112233445566:file-system/fs-61dff6e3", + "CreationTime": "2020-10-18T18:55:19.000Z", + "LifeCycleState": "available", + "Name": null, + "PerformanceMode": "generalPurpose", + "Encrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", + "Tags": [{"Name": "TagName", "Value": "TagValue"}] + }]); + + efsHasTags.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/eks/eksClusterHasTags.js b/plugins/aws/eks/eksClusterHasTags.js new file mode 100644 index 000000000..6d831c8de --- /dev/null +++ b/plugins/aws/eks/eksClusterHasTags.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EKS Cluster Has Tags', + category: 'EKS', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that AWS EKS Clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/eks-using-tags.html', + recommended_action: 'Modify EKS Cluster and add tags.', + apis: ['EKS:listClusters', 'ResourceGroupsTaggingAPI:getResources', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'eks:TagResource', 'eks:UntagResource', 'eks:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.eks, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['eks', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EKS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EKS clusters present', region); + return rcb(); + } + + const ARNList = []; + for (var clusterName of listClusters.data) { + var arn = 'arn:' + awsOrGov + ':eks:' + region + ':' + accountId + ':cluster/' + clusterName; + ARNList.push(arn); + } + + helpers.checkTags(cache,'EKS cluster', ARNList, region, results, settings); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/eks/eksClusterHasTags.spec.js b/plugins/aws/eks/eksClusterHasTags.spec.js new file mode 100644 index 000000000..a33ab7a3b --- /dev/null +++ b/plugins/aws/eks/eksClusterHasTags.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var eksClusterHasTags = require('./eksClusterHasTags'); + +const createCache = (clsuterData, rgData) => { + return { + eks: { + listClusters: { + 'us-east-1': { + err: null, + data: clsuterData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '101363884315' + } + } + } + } +}; + +describe('eksClusterHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list eks clusters', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for EKS clusters'); + done() + }; + + const cache = createCache(null, []); + eksClusterHasTags.run(cache, {}, callback); + }); + + it('should give passing result if EKS Clusters not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No EKS clusters present'); + done(); + }; + const cache = createCache([], null); + eksClusterHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query all resources'); + done(); + }; + + const cache = createCache( + ["Test-cluster"], + null + ); + + eksClusterHasTags.run(cache, {}, callback); + }); + + it('should give passing result if EKS Clusters have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('EKS cluster has tags'); + done(); + }; + + const cache = createCache( + ["Test-cluster"], + [{ + "ResourceARN": "arn:aws:eks:us-east-1:101363884315:cluster/Test-cluster", + "Tags": [{key:"key1", value:"value"}], + }] + ); + eksClusterHasTags.run(cache, {}, callback); + }); + + it('should give failing result if eks cluster does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('EKS cluster does not have any tags'); + done(); + }; + + const cache = createCache( + ['Test-cluster'], + [{ + "ResourceARN": "arn:aws:eks:us-east-1:101363884315:cluster/Test-cluster", + "Tags": [], + }] + ); + + eksClusterHasTags.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/eks/eksKubernetesVersion.js b/plugins/aws/eks/eksKubernetesVersion.js index 45ab0e039..26d362c82 100644 --- a/plugins/aws/eks/eksKubernetesVersion.js +++ b/plugins/aws/eks/eksKubernetesVersion.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EKS Kubernetes Version', category: 'EKS', + domain: 'Containers', + severity: 'Low', description: 'Ensures the latest version of Kubernetes is installed on EKS clusters', more_info: 'EKS supports provisioning clusters from several versions of Kubernetes. Clusters should be kept up to date to ensure Kubernetes security patches are applied.', link: 'https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html', recommended_action: 'Upgrade the version of Kubernetes on all EKS clusters to the latest available version.', apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'eks:UpdateClusterVersion', 'eks:DeleteCluster'], run: function(cache, settings, callback) { var results = []; @@ -20,12 +23,32 @@ module.exports = { var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); var deprecatedVersions = { - '1.10': '2019-07-22' + '1.10': '2019-07-22', + '1.11': '2019-11-04', + '1.12': '2020-05-11', + '1.13': '2020-06-30', + '1.14': '2020-12-08', + '1.15': '2021-05-03', + '1.16': '2021-09-27', + '1.17': '2021-11-02', + '1.18': '2022-03-31', + '1.19': '2022-08-01', + '1.20': '2022-11-01', + '1.21': '2023-02-16', + '1.22': '2023-06-04', + '1.23': '2023-10-11', + '1.24': '2024-01-31', + '1.25': '2024-05-01', + '1.26': '2024-06-11', + '1.27': '2024-07-24', + '1.28': '2024-11-26', + '1.29': '2025-03-23', + '1.30': '2025-07-23', + '1.31': '2025-11-26', + '1.32': '2026-03-23' }; var outdatedVersions = { - '1.11': '2019-03-28', - '1.12': '2019-06-18' }; async.each(regions.eks, function(region, rcb) { @@ -41,7 +64,7 @@ module.exports = { return rcb(); } - if(listClusters.data.length === 0){ + if (listClusters.data.length === 0){ helpers.addResult(results, 0, 'No EKS clusters present', region); return rcb(); } @@ -64,11 +87,18 @@ module.exports = { if (describeCluster.data.cluster && describeCluster.data.cluster.version) { var version = describeCluster.data.cluster.version; - if (deprecatedVersions[version]) { + let versionDeprecationDate = (deprecatedVersions[version]) ? deprecatedVersions[version] : null; + let versionOutdatedDate = (outdatedVersions[version]) ? outdatedVersions[version] : null; + let today = new Date(); + let dateToday = (today.getDate() < 10) ? '0' + today.getDate() : today.getDate(); + let month = (today.getMonth() < 10) ? '0' + (today.getMonth()+1) : today.getMonth(); + today = `${today.getFullYear()}-${month}-${dateToday}`; + + if (versionDeprecationDate && today > versionDeprecationDate) { helpers.addResult(results, 2, 'EKS cluster is running Kubernetes: ' + version + ' which was deprecated on: ' + deprecatedVersions[version], region, arn); - } else if (outdatedVersions[version]) { + } else if (versionOutdatedDate && today > versionOutdatedDate) { helpers.addResult(results, 1, 'EKS cluster is running Kubernetes: ' + version + ' which is currently outdated', region, arn); @@ -87,4 +117,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/aws/eks/eksKubernetesVersion.spec.js b/plugins/aws/eks/eksKubernetesVersion.spec.js index 9372db690..3d36ed45d 100644 --- a/plugins/aws/eks/eksKubernetesVersion.spec.js +++ b/plugins/aws/eks/eksKubernetesVersion.spec.js @@ -60,29 +60,7 @@ describe('eksKubernetesVersion', function () { "cluster": { "name": "mycluster", "arn": "arn:aws:eks:us-east-1:012345678911:cluster/mycluster", - "version": "1.10", - } - } - ); - - eks.run(cache, {}, callback); - }) - - it('should give warning result if EKS cluster is outdated', function (done) { - const callback = (err, results) => { - expect(results.length).to.equal(1) - expect(results[0].status).to.equal(1) - expect(results[0].message).to.include('which is currently outdated') - done() - }; - - const cache = createCache( - ['mycluster'], - { - "cluster": { - "name": "mycluster", - "arn": "arn:aws:eks:us-east-1:012345678911:cluster/mycluster", - "version": "1.12", + "version": "1.15", } } ); @@ -104,7 +82,7 @@ describe('eksKubernetesVersion', function () { "cluster": { "name": "mycluster", "arn": "arn:aws:eks:us-east-1:012345678911:cluster/mycluster", - "version": "1.13", + "version": "1.32", } } ); diff --git a/plugins/aws/eks/eksLatestPlatformVersion.js b/plugins/aws/eks/eksLatestPlatformVersion.js new file mode 100644 index 000000000..8b4e924ed --- /dev/null +++ b/plugins/aws/eks/eksLatestPlatformVersion.js @@ -0,0 +1,130 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EKS Latest Platform Version', + category: 'EKS', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that EKS clusters are using latest platform version.', + more_info: 'Amazon EKS platform versions represent the capabilities of the Amazon EKS cluster control plane, such as which Kubernetes API server flags are enabled, as well as the current Kubernetes patch version.'+ + 'Clusters should be kept up to date of latest platforms to ensure Kubernetes security patches are applied.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/platform-versions.html', + recommended_action: 'Check for the version on all EKS clusters to be the latest platform version.', + apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'eks:UpdateClusterVersion', 'eks:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var latestVersions = { + '1.29':6, + '1.28':12, + '1.27': 16, + '1.26': 17, + '1.25': 18, + '1.24': 21, + '1.23': 23, + '1.22': 13, + '1.21': 18, + }; + + var deprecatedVersions = { + '1.10': '2019-07-22', + '1.11': '2019-11-04', + '1.12': '2020-05-11', + '1.13': '2020-06-30', + '1.14': '2020-12-08', + '1.15': '2021-05-03', + '1.16': '2021-09-27', + '1.17': '2021-11-02', + '1.18': '2022-03-31', + '1.19': '2022-08-01', + '1.20': '2022-11-01', + '1.21': '2023-02-16', + '1.22': '2023-06-04', + '1.23': '2023-10-01', + '1.24': '2024-01-01', + '1.25': '2024-05-01', + '1.26': '2024-06-11', + '1.27': '2024-07-24', + '1.28': '2024-11-26', + '1.29': '2025-03-23' + }; + + async.each(regions.eks, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['eks', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult( + results, 3, + 'Unable to query for EKS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (listClusters.data.length === 0){ + helpers.addResult(results, 0, 'No EKS clusters present', region); + return rcb(); + } + + for (var c in listClusters.data) { + var clusterName = listClusters.data[c]; + var describeCluster = helpers.addSource(cache, source, + ['eks', 'describeCluster', region, clusterName]); + + var arn = 'arn:' + awsOrGov + ':eks:' + region + ':' + accountId + ':cluster/' + clusterName; + + if (!describeCluster || describeCluster.err || !describeCluster.data) { + helpers.addResult( + results, 3, + 'Unable to describe EKS cluster: ' + helpers.addError(describeCluster), + region, arn); + continue; + } + + if (describeCluster.data.cluster && + describeCluster.data.cluster.version && + describeCluster.data.cluster.platformVersion) { + var version = describeCluster.data.cluster.version; + let versionLatestPlatform = (latestVersions[version]) ? latestVersions[version] : null; + + let platform = describeCluster.data.cluster.platformVersion; + let platformVersion = platform.replace('eks.', ''); + + if (versionLatestPlatform) { + if (parseInt(platformVersion) >= versionLatestPlatform) { + helpers.addResult(results, 0, + 'EKS cluster is running latest EKS platform version', + region, arn); + } else { + helpers.addResult(results, 2, + 'EKS cluster is not running latest EKS platform version', + region, arn); + } + } else if (deprecatedVersions[version]){ + helpers.addResult(results, 0, + 'EKS cluster using deprecated EKS version', + region, arn); + } else { + helpers.addResult(results, 3, 'EKS cluster is using unknown EKS version', region, arn); + } + } else { + helpers.addResult(results, 3, 'Unable to query cluster EKS version or platform version', region, arn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/eks/eksLatestPlatformVersion.spec.js b/plugins/aws/eks/eksLatestPlatformVersion.spec.js new file mode 100644 index 000000000..682683c96 --- /dev/null +++ b/plugins/aws/eks/eksLatestPlatformVersion.spec.js @@ -0,0 +1,119 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var eks = require('./eksLatestPlatformVersion'); + +const createCache = (listData, descData) => { + return { + eks: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'das': { + err: null, + data: descData + } + } + } + }, + sts: { + getCallerIdentity: { + data: '012345678911' + } + } + } +}; + + +describe('eksLatestPlatformVersion', function () { + describe('run', function () { + it('should give passing result if no EKS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No EKS clusters present') + done() + }; + + const cache = createCache( + [], + {} + ); + + eks.run(cache, {}, callback); + }) + + it('should give passing result if EKS cluster running platform is deprecated', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('EKS cluster using deprecated EKS version') + done() + }; + + const cache = createCache( + ['das'], + { + "cluster": { + "name": "das", + "arn": "arn:aws:eks:us-east-1:012345678911:cluster/das", + "version": "1.16", + "platformVersion": "eks.9", + } + } + ); + + eks.run(cache, {}, callback); + }) + + it('should give passing result if EKS cluster is latest', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('EKS cluster is running latest EKS platform version') + done() + }; + + const cache = createCache( + ['das'], + { + "cluster": { + "name": "das", + "arn": "arn:aws:eks:us-east-1:012345678911:cluster/das", + "version": "1.27", + "platformVersion": "eks.16", + } + } + ); + + eks.run(cache, {}, callback); + }) + + it('should give error result if EKS cluster is not the latest', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('EKS cluster is not running latest EKS platform version') + done() + }; + + const cache = createCache( + ['das'], + { + "cluster": { + "name": "das", + "arn": "arn:aws:eks:us-east-1:012345678911:cluster/das", + "version": "1.21", + "platformVersion": "eks.2", + } + } + ); + + eks.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/aws/eks/eksLoggingEnabled.js b/plugins/aws/eks/eksLoggingEnabled.js index 8e3e81c6d..28a4169e6 100644 --- a/plugins/aws/eks/eksLoggingEnabled.js +++ b/plugins/aws/eks/eksLoggingEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EKS Logging Enabled', category: 'EKS', + domain: 'Containers', + severity: 'Low', description: 'Ensures all EKS cluster logs are being sent to CloudWatch', more_info: 'EKS supports routing of cluster event and audit logs to CloudWatch, including control plane logs. All logs should be sent to CloudWatch for security analysis.', link: 'https://docs.aws.amazon.com/eks/latest/userguide/control-plane-logs.html', @@ -14,6 +16,7 @@ module.exports = { apis_remediate: ['EKS:listClusters', 'EKS:describeCluster'], actions: {remediate: ['EKS:updateClusterConfig'], rollback: ['EKS:updateClusterConfig']}, permissions: {remediate: ['eks:UpdateClusterConfig'], rollback: ['eks:UpdateClusterConfig']}, + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster'], run: function(cache, settings, callback) { var results = []; @@ -37,7 +40,7 @@ module.exports = { return rcb(); } - if(listClusters.data.length === 0){ + if (listClusters.data.length === 0){ helpers.addResult(results, 0, 'No EKS clusters present', region); return rcb(); } diff --git a/plugins/aws/eks/eksNetworkExposure.js b/plugins/aws/eks/eksNetworkExposure.js new file mode 100644 index 000000000..d2306e27a --- /dev/null +++ b/plugins/aws/eks/eksNetworkExposure.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Exposure', + category: 'EKS', + domain: 'Containers', + severity: 'Info', + description: 'Check if EKS clusters are exposed to the internet.', + more_info: 'EKS clusters exposed to the internet are vulnerable to unauthorized access, potential data loss, and increased cyberattack risks. Securing access to the EKS API server, worker nodes, and associated resources by configuring security groups, NACLs, and using private subnets is essential for minimizing exposure.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/network_reqs.html', + recommended_action: 'Restrict public access to the EKS API server and worker nodes by ensuring proper configuration of API endpoint access, security groups, and NACLs. Utilize private subnets and NAT gateways where appropriate for worker node traffic.', + apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity', 'EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'EC2:describeSubnets', + 'EC2:describeRouteTables'], + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster','ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:ReplaceNetworkAclAssociation', + 'ec2:DeleteNetworkAcl', 'ec2:CreateSecurityGroup', 'ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', + 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.eks, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['eks', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult( + results, 3, + 'Unable to query for EKS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (listClusters.data.length === 0){ + helpers.addResult(results, 0, 'No EKS clusters present', region); + return rcb(); + } + + for (var c in listClusters.data) { + var clusterName = listClusters.data[c]; + var describeCluster = helpers.addSource(cache, source, + ['eks', 'describeCluster', region, clusterName]); + + var arn = 'arn:' + awsOrGov + ':eks:' + region + ':' + accountId + ':cluster/' + clusterName; + + if (!describeCluster || describeCluster.err || !describeCluster.data) { + helpers.addResult( + results, 3, + 'Unable to describe EKS cluster: ' + helpers.addError(describeCluster), + region, arn); + continue; + } + + describeCluster.data.arn = arn; + + if (describeCluster.data.cluster) { + let cluster = describeCluster.data.cluster; + let securityGroups = []; + if (cluster.resourcesVpcConfig) { + if (cluster.resourcesVpcConfig.clusterSecurityGroupId) { + securityGroups.push(cluster.resourcesVpcConfig.clusterSecurityGroupId); + } + if (cluster.resourcesVpcConfig.securityGroupIds) { + securityGroups = securityGroups.concat(cluster.resourcesVpcConfig.securityGroupIds); + } + let internetExposed = helpers.checkNetworkExposure(cache, source, cluster.resourcesVpcConfig.subnetIds, securityGroups, [], region, results, cluster); + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `EKS cluster is exposed to the internet through ${internetExposed}`, region, arn); + } else { + helpers.addResult(results, 0, 'EKS cluster is not exposed to the internet', region, arn); + } + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/eks/eksPrivateEndpoint.js b/plugins/aws/eks/eksPrivateEndpoint.js index c40c6fa87..78a98b3a3 100644 --- a/plugins/aws/eks/eksPrivateEndpoint.js +++ b/plugins/aws/eks/eksPrivateEndpoint.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EKS Private Endpoint', category: 'EKS', + domain: 'Containers', + severity: 'Medium', description: 'Ensures the private endpoint setting is enabled for EKS clusters', more_info: 'EKS private endpoints can be used to route all traffic between the Kubernetes worker and control plane nodes over a private VPC endpoint rather than across the public internet.', link: 'https://docs.aws.amazon.com/eks/latest/userguide/cluster-endpoint.html', recommended_action: 'Enable the private endpoint setting for all EKS clusters.', apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster'], run: function(cache, settings, callback) { var results = []; @@ -32,7 +35,7 @@ module.exports = { return rcb(); } - if(listClusters.data.length === 0){ + if (listClusters.data.length === 0){ helpers.addResult(results, 0, 'No EKS clusters present', region); return rcb(); } diff --git a/plugins/aws/eks/eksPrivilegeAnalysis.js b/plugins/aws/eks/eksPrivilegeAnalysis.js new file mode 100644 index 000000000..4550ef04d --- /dev/null +++ b/plugins/aws/eks/eksPrivilegeAnalysis.js @@ -0,0 +1,19 @@ + +module.exports = { + title: 'Privilege Analysis', + category: 'EKS', + domain: 'Containers', + severity: 'Info', + description: 'Ensures no EKS cluster available in your AWS account is overly-permissive.', + more_info: 'Overly-permissive EKS clusters can expose your infrastructure to unauthorized access or accidental misconfigurations. Regular analysis of permissions helps maintain a secure cluster setup.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/cluster-endpoint.html', + recommended_action: 'Audit the IAM roles and policies associated with your EKS cluster. Restrict access to the minimum necessary permissions and configure security groups and endpoint access control appropriately.', + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +}; diff --git a/plugins/aws/eks/eksSecretsEncrypted.js b/plugins/aws/eks/eksSecretsEncrypted.js new file mode 100644 index 000000000..fb9257c93 --- /dev/null +++ b/plugins/aws/eks/eksSecretsEncrypted.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EKS Secrets Encrypted', + category: 'EKS', + domain: 'Containers', + severity: 'High', + description: 'Ensures EKS clusters are configured to enable envelope encryption of Kubernetes secrets using KMS.', + more_info: 'Amazon EKS clusters should be configured to enable envelope encryption for Kubernetes secrets to adhere to security best practice for applications that store sensitive data.', + link: 'https://aws.amazon.com/about-aws/whats-new/2020/03/amazon-eks-adds-envelope-encryption-for-secrets-with-aws-kms/', + recommended_action: 'Modify EKS clusters to enable envelope encryption for Kubernetes secrets', + apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'eks:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.eks, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['eks', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult( + results, 3, + `Unable to query for EKS clusters: ${helpers.addError(listClusters)}`, region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EKS clusters found', region); + return rcb(); + } + + for (var clusterName of listClusters.data) { + var describeCluster = helpers.addSource(cache, source, + ['eks', 'describeCluster', region, clusterName]); + + var arn = `arn:${awsOrGov}:eks:${region}:${accountId}:cluster/${clusterName}`; + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.cluster) { + helpers.addResult( + results, 3, + 'Unable to describe EKS cluster: ' + helpers.addError(describeCluster), + region, arn); + continue; + } + + var encryptionEnabled = false; + if (describeCluster.data.cluster.encryptionConfig && + describeCluster.data.cluster.encryptionConfig.length) { + for (var config of describeCluster.data.cluster.encryptionConfig) { + if (config.resources && + config.resources.includes('secrets') && + config.provider && + config.provider.keyArn && + config.provider.keyArn.length) { + encryptionEnabled = true; + break; + } + } + + if (encryptionEnabled) { + helpers.addResult(results, 0, + `Envelope encryption of Kubernetes secrets is enabled for EKS cluster "${clusterName}"`, + region, arn); + } else { + helpers.addResult(results, 2, + `Envelope encryption of Kubernetes secrets is not enabled for EKS cluster "${clusterName}"`, + region, arn); + } + } else { + helpers.addResult(results, 2, + `Envelope encryption of Kubernetes secrets is not enabled for cluster "${clusterName}"`, + region, arn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/eks/eksSecretsEncrypted.spec.js b/plugins/aws/eks/eksSecretsEncrypted.spec.js new file mode 100644 index 000000000..828e14fe2 --- /dev/null +++ b/plugins/aws/eks/eksSecretsEncrypted.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var eks = require('./eksSecretsEncrypted'); + +const createCache = (listData, descData) => { + return { + eks: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'mycluster': { + err: null, + data: descData + } + } + } + } + } +}; + +describe('eksSecretsEncrypted', function () { + describe('run', function () { + it('should give passing result if no EKS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + done() + }; + + const cache = createCache( + [], + {} + ); + + eks.run(cache, {}, callback); + }); + + it('should give error result if envelope encryption of Kubernetes secrets is not enabled for EKS cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + done() + }; + + const cache = createCache( + ['mycluster'], + { + "cluster": { + "name": "mycluster", + "arn": "arn:aws:eks:us-east-1:111122223333:cluster/mycluster", + }, + "encryptionConfig": [ + ] + } + ); + + eks.run(cache, {}, callback); + }); + + it('should give passing result if envelope encryption of Kubernetes secrets is enabled for EKS cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + done() + }; + + const cache = createCache( + ['mycluster'], + { + "cluster": { + "name": "mycluster", + "arn": "arn:aws:eks:us-east-1:111122223333:cluster/mycluster", + "encryptionConfig": [ + { + "resources": [ + "secrets" + ], + "provider": { + "keyArn": "arn:aws:kms:us-east-1:560213429563:key/e29030ff-0833-432d-83fc-e8072e12be69" + } + } + ] + } + } + ); + + eks.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for EKS clusters', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + done() + }; + + const cache = createCache( + null + ); + + eks.run(cache, {}, callback); + }); + + it('should give unknown result if unable to describe EKS cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + done() + }; + + const cache = createCache( + ['mycluster'], + null + ); + + eks.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/eks/eksSecurityGroups.js b/plugins/aws/eks/eksSecurityGroups.js index 4b577349a..8d44ae5fc 100644 --- a/plugins/aws/eks/eksSecurityGroups.js +++ b/plugins/aws/eks/eksSecurityGroups.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'EKS Security Groups', category: 'EKS', + domain: 'Containers', + severity: 'Medium', description: 'Ensures the EKS control plane only allows inbound traffic on port 443.', more_info: 'The EKS control plane only requires port 443 access. Security groups for the control plane should not add additional port access.', link: 'https://docs.aws.amazon.com/eks/latest/userguide/sec-group-reqs.html', recommended_action: 'Configure security groups for the EKS control plane to allow access only on port 443.', apis: ['EKS:listClusters', 'EKS:describeCluster', 'EC2:describeSecurityGroups', 'STS:getCallerIdentity'], + realtime_triggers: ['eks:CreateCluster', 'ec2:RevokeSecurityGroupIngress', 'ec2:AuthorizeSecurityGroupIngress', 'eks:DeleteCluster'], run: function(cache, settings, callback) { var results = []; @@ -32,7 +35,7 @@ module.exports = { return rcb(); } - if(listClusters.data.length === 0){ + if (listClusters.data.length === 0){ helpers.addResult(results, 0, 'No EKS clusters present', region); return rcb(); } diff --git a/plugins/aws/eks/eksSecurityGroups.spec.js b/plugins/aws/eks/eksSecurityGroups.spec.js new file mode 100644 index 000000000..788164a42 --- /dev/null +++ b/plugins/aws/eks/eksSecurityGroups.spec.js @@ -0,0 +1,251 @@ +var expect = require('chai').expect; +var eks = require('./eksSecurityGroups'); + +const createCache = (listData, descSgData, descData) => { + return { + eks: { + listClusters: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeCluster: { + 'us-east-1': { + 'mycluster': { + err: null, + data: descData + } + } + } + }, + ec2: { + describeSecurityGroups: { + 'us-east-1': { + data: descSgData + } + } + } + } +}; + +describe('eksSecurityGroups', function () { + describe('run', function () { + it('should give passing result if no EKS clusters present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No EKS clusters present') + done() + }; + + const cache = createCache( + [], + {} + ); + + eks.run(cache, {}, callback); + }) + + it('should give error result if EKS control plane security groups allow additional access on unnecessary port ranges', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + done() + }; + + const cache = createCache( + ['mycluster'], + [ + { + "Description": "eks sg", + "GroupName": "sg-1", + "IpPermissions": [ + { + "FromPort": 22, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 22, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02d95f133690f7400", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } + ], + { + "cluster": { + "name": "mycluster", + "arn": "arn:aws:eks:us-east-1:111122223333:cluster/mycluster", + "resourcesVpcConfig": { + "subnetIds": [ + "subnet-06aa0f60", + "subnet-673a9a46" + ], + "securityGroupIds": [ + "sg-02d95f133690f7400" + ], + "vpcId": "vpc-99de2fe4", + "endpointPublicAccess": true, + "endpointPrivateAccess": false, + "publicAccessCidrs": [ + "0.0.0.0/0" + ] + }, + } + } + ); + + eks.run(cache, {}, callback); + }) + + it('should give passing result if EKS control plane security groups do not contain unnecessary ports', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + done() + }; + + const cache = createCache( + ['mycluster'], + [ + { + "Description": "eks sg", + "GroupName": "sg-1", + "IpPermissions": [ + { + "FromPort": 443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02d95f133690f7400", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } + ], + { + "cluster": { + "name": "mycluster", + "arn": "arn:aws:eks:us-east-1:111122223333:cluster/mycluster", + "resourcesVpcConfig": { + "subnetIds": [ + "subnet-06aa0f60", + "subnet-673a9a46" + ], + "securityGroupIds": [ + "sg-02d95f133690f7400" + ], + "vpcId": "vpc-99de2fe4", + "endpointPublicAccess": true, + "endpointPrivateAccess": false, + "publicAccessCidrs": [ + "0.0.0.0/0" + ] + }, + } + } + ); + + eks.run(cache, {}, callback); + }) + + it('should give warn result if EKS control plane does not have security groups configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(1) + done() + }; + + const cache = createCache( + ['mycluster'], + [ + { + "Description": "eks sg", + "GroupName": "sg-1", + "IpPermissions": [ + { + "FromPort": 443, + "IpProtocol": "tcp", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "ToPort": 443, + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111122223333", + "GroupId": "sg-02d95f133690f7400", + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "VpcId": "vpc-99de2fe4" + } + ], + { + "cluster": { + "name": "mycluster", + "arn": "arn:aws:eks:us-east-1:111122223333:cluster/mycluster", + } + } + ); + + eks.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticCacheClusterHasTags.js b/plugins/aws/elasticache/elasticCacheClusterHasTags.js new file mode 100644 index 000000000..a9e187e2d --- /dev/null +++ b/plugins/aws/elasticache/elasticCacheClusterHasTags.js @@ -0,0 +1,48 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Cluster Has Tags', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that ElastiCache clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Tagging-Resources.html', + recommended_action: 'Modify ElastiCache cluster and add tags.', + apis: ['ElastiCache:describeCacheClusters', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:AddTagsToResource', 'elasticache:RemoveTagsToResource'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + const ARNList = []; + for (var cluster of describeCacheClusters.data) { + ARNList.push(cluster.ARN); + } + helpers.checkTags(cache, 'ElastiCache cluster', ARNList, region, results, settings); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/elasticCacheClusterHasTags.spec.js b/plugins/aws/elasticache/elasticCacheClusterHasTags.spec.js new file mode 100644 index 000000000..396de713d --- /dev/null +++ b/plugins/aws/elasticache/elasticCacheClusterHasTags.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var elastiCacheClusterHasTags = require('./elasticCacheClusterHasTags'); + +const createCache = (clsuterData, rgData) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: null, + data: clsuterData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + } +}; + +describe('elastiCacheClusterHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list elastiCache clusters', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for ElastiCache clusters'); + done() + }; + + const cache = createCache(null, []); + elastiCacheClusterHasTags.run(cache, {}, callback); + }); + + it('should give passing result if elastiCache Clusters not found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ElastiCache clusters found'); + done(); + }; + const cache = createCache([], null); + elastiCacheClusterHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query all resources from group tagging api'); + done(); + }; + + const cache = createCache( + [{ + ARN: 'arn:aws:elasticache:us-east-1:201363884315:cluster:test-001' + }], + null + ); + + elastiCacheClusterHasTags.run(cache, {}, callback); + }); + + it('should give passing result if elastiCache Clusters have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ElastiCache cluster has tags'); + done(); + }; + + const cache = createCache( + [{ + ARN: 'arn:aws:elasticache:us-east-1:201363884315:cluster:test-001' + }], + [{ + 'ResourceARN': 'arn:aws:elasticache:us-east-1:201363884315:cluster:test-001', + 'Tags': [{key:'key1', value:'value'}], + }] + ); + elastiCacheClusterHasTags.run(cache, {}, callback); + }); + + it('should give failing result if elastiCache cluster does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ElastiCache cluster does not have any tags'); + done(); + }; + + const cache = createCache( + [{ + ARN: 'arn:aws:elasticache:us-east-1:201363884315:cluster:test-001' + }], + [{ + 'ResourceARN': 'arn:aws:elasticache:us-east-1:201363884315:cluster:test-001', + 'Tags': [], + }] + ); + + elastiCacheClusterHasTags.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticacheClusterInVpc.js b/plugins/aws/elasticache/elasticacheClusterInVpc.js new file mode 100644 index 000000000..b2c273a19 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheClusterInVpc.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Cluster In VPC', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that your ElastiCache clusters are provisioned within the AWS VPC platform.', + more_info: 'Creating Amazon ElastiCache clusters inside Amazon VPC can bring multiple advantages such as better networking infrastructure and flexible control over access security .', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/VPCs.EC.html', + recommended_action: 'Create ElastiCache clusters within VPC network', + apis: ['ElastiCache:describeCacheClusters'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + var resource = cluster.ARN; + + if (cluster.CacheSubnetGroupName && + cluster.CacheSubnetGroupName.length) { + helpers.addResult(results, 0, + `ElastiCache cluster "${cluster.CacheClusterId}" is in VPC`, region, resource); + } else { + helpers.addResult(results, 2, + `ElastiCache cluster "${cluster.CacheClusterId}" is not in VPC`, region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/elasticache/elasticacheClusterInVpc.spec.js b/plugins/aws/elasticache/elasticacheClusterInVpc.spec.js new file mode 100644 index 000000000..ef1205fa5 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheClusterInVpc.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +const elasticacheClusterInVpc = require('./elasticacheClusterInVpc'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sad1", + "ConfigurationEndpoint": { + "Address": "sad1.zvodgj.cfg.use1.cache.amazonaws.com", + "Port": 11211 + }, + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-01T10:46:20.450000+00:00", + "PreferredMaintenanceWindow": "fri:10:00-fri:11:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad1", + }, + { + "CacheClusterId": "test-002", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "redis", + "EngineVersion": "6.2.5", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-01T10:56:34.474000+00:00", + "PreferredMaintenanceWindow": "wed:09:00-wed:10:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "", + "ReplicationGroupId": "sad23", + "SnapshotRetentionLimit": 0, + "SnapshotWindow": "05:00-06:00", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad23-001", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elasticacheClusterInVpc', function () { + describe('run', function () { + it('should FAIL if ElastiCache cluster is not in VPC', function (done) { + const cache = createCache([describeCacheClusters[1]]); + elasticacheClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if ElastiCache cluster is in VPC', function (done) { + const cache = createCache([describeCacheClusters[0]]); + elasticacheClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no ElastiCache clusters found', function (done) { + const cache = createCache([]); + elasticacheClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for ElastiCache clusters', function (done) { + const cache = createErrorCache(); + elasticacheClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for ElastiCache clusters', function (done) { + const cache = createNullCache(); + elasticacheClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticache/elasticacheDefaultPorts.js b/plugins/aws/elasticache/elasticacheDefaultPorts.js new file mode 100644 index 000000000..1ccb8b3b9 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheDefaultPorts.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Default Ports', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Low', + description: 'Ensure AWS ElastiCache clusters are not using the default ports set for Redis and Memcached cache engines.', + more_info: 'ElastiCache clusters should be configured not to use the default assigned port value for Redis (6379) and Memcached (11211).', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/accessing-elasticache.html', + recommended_action: 'Configure ElastiCache clusters to use the non-default ports.', + apis: ['ElastiCache:describeCacheClusters'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultPorts = [ + { 'engine':'redis', 'port': 6379}, + { 'engine':'memcached', 'port': 11211}, + ]; + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to describe cache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + if (!cluster.Engine || + !(cluster.ConfigurationEndpoint && cluster.ConfigurationEndpoint.Port)) continue; + + var defaultPort = defaultPorts.filter((d) => { + return d.engine == cluster.Engine && d.port == cluster.ConfigurationEndpoint.Port; + }); + + if (defaultPort && defaultPort.length) { + helpers.addResult(results, 2, + 'The ' + cluster.Engine + ' cluster is configured with default port ' + cluster.ConfigurationEndpoint.Port, + region, cluster.ARN); + } else { + helpers.addResult(results, 0, + 'The ' + cluster.Engine + ' cluster is configured with a non default port ' + cluster.ConfigurationEndpoint.Port, + region, cluster.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/elasticacheDefaultPorts.spec.js b/plugins/aws/elasticache/elasticacheDefaultPorts.spec.js new file mode 100644 index 000000000..7d3c594dd --- /dev/null +++ b/plugins/aws/elasticache/elasticacheDefaultPorts.spec.js @@ -0,0 +1,161 @@ +const expect = require('chai').expect; +var elasticacheDefaultPort = require('./elasticacheDefaultPorts'); + +const describeClusters = [ + { + "CacheClusterId": "test-dev", + "ConfigurationEndpoint": { + "Address": "test-dev.1234.aaa.use1.cache.amazonaws.com", + "Port": 11111 + }, + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.r6g.large", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1b", + "CacheClusterCreateTime": "2021-08-23T17:26:45.535Z", + "PreferredMaintenanceWindow": "mon:09:30-mon:10:30", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "test-dev-subnet-group", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-1234", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:111122223333:cluster:test-dev" + }, + { + "CacheClusterId": "test-dev", + "ConfigurationEndpoint": { + "Address": "test-dev.1234.aaa.use1.cache.amazonaws.com", + "Port": 11211 + }, + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.r6g.large", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1b", + "CacheClusterCreateTime": "2021-08-23T17:26:45.535Z", + "PreferredMaintenanceWindow": "mon:09:30-mon:10:30", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "test-dev-subnet-group", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-1234", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:111122223333:cluster:test-dev" + }, +]; + + +const createCache = (describeClusters) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: describeClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': null + } + } + }; +}; + +describe('elasticacheDefaultPorts', function () { + describe('run', function () { + + it('should PASS if no cluster using default port', function (done) { + const cache = createCache([describeClusters[0]]); + elasticacheDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no cluster using default port', function (done) { + const cache = createCache([describeClusters[1]]); + elasticacheDefaultPort.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no were clusters found', function (done) { + const cache = createCache([]); + elasticacheDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe cache clusters', function (done) { + const cache = createErrorCache(); + elasticacheDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe cache clusters response not found', function (done) { + const cache = createNullCache(); + elasticacheDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticacheInstanceGeneration.js b/plugins/aws/elasticache/elasticacheInstanceGeneration.js new file mode 100644 index 000000000..312d31da9 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheInstanceGeneration.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Instance Generation', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that all ElastiCache clusters provisioned within your AWS account are using the latest generation of instances', + more_info: 'Using the latest generation of Amazon ElastiCache instances instances will benefit clusters for higher hardware performance, ' + + 'better support for latest Memcached and Redis in-memory engines versions and lower costs.', + link: 'https://aws.amazon.com/elasticache/previous-generation/', + recommended_action: 'Upgrade ElastiCache instance generation to the latest available generation.', + apis: ['ElastiCache:describeCacheClusters'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:ModifyCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var previousGen = [ + 'cache.m1.small', + 'cache.m1.medium', + 'cache.m1.large', + 'cache.m1.xlarge', + 'cache.m2.xlarge', + 'cache.m2.2xlarge', + 'cache.m2.4xlarge', + 'cache.c1.xlarge', + 'cache.t1.micro', + 'cache.m3.medium', + 'cache.m3.large', + 'cache.m3.xlarge', + 'cache.m3.2xlarge', + 'cache.r3.large', + 'cache.r3.xlarge', + 'cache.r3.2xlarge', + 'cache.r3.4xlarge', + 'cache.r3.8xlarge' + ]; + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + var resource = cluster.ARN; + + if (cluster.CacheNodeType) { + var generation = cluster.CacheNodeType; + + if (previousGen.includes(generation)) { + helpers.addResult(results, 2, + 'ElastiCache cluster is running previoud generation instance node: ' + generation, + region, resource); + } else { + helpers.addResult(results, 0, + 'ElastiCache cluster is running current generation Instance Node: ' + generation, + region, resource); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticacheInstanceGeneration.spec.js b/plugins/aws/elasticache/elasticacheInstanceGeneration.spec.js new file mode 100644 index 000000000..379a3345a --- /dev/null +++ b/plugins/aws/elasticache/elasticacheInstanceGeneration.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +const elasticacheInstanceGeneration = require('./elasticacheInstanceGeneration'); + +const clusters = [ + { + CacheClusterId: 'test-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.t2.micro', + Engine: 'redis', + EngineVersion: '6.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:16:41.340Z', + PreferredMaintenanceWindow: 'mon:09:30-mon:10:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec', + SnapshotRetentionLimit: 1, + SnapshotWindow: '03:30-04:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: true, + AtRestEncryptionEnabled: true, + ARN: 'arn:aws:elasticache:us-east-1:000111222333:cluster:test-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + }, + { + CacheClusterId: 'test1-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.m2.4xlarge', + Engine: 'redis', + EngineVersion: '6.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:17:36.297Z', + PreferredMaintenanceWindow: 'sat:05:30-sat:06:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec1', + SnapshotRetentionLimit: 1, + SnapshotWindow: '04:30-05:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: false, + AtRestEncryptionEnabled: false, + ARN: 'arn:aws:elasticache:us-east-1:000111222333:cluster:test1-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + } +]; + +const createCache = (clusters, err) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: clusters, + err: err + }, + }, + }, + }; +}; + + +describe('elasticacheInstanceGeneration', function () { + describe('run', function () { + it('should FAIL if ElastiCache cluster is running previous generation', function (done) { + const cache = createCache([clusters[1]]); + elasticacheInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if ElastiCache cluster is running current generation', function (done) { + const cache = createCache([clusters[0]]); + elasticacheInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no ElastiCache clusters found', function (done) { + const cache = createCache([]); + elasticacheInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query ElastiCache clusters', function (done) { + const cache = createCache(null, { message: 'Unable to obtain data' }); + elasticacheInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticache/elasticacheNodesCount.js b/plugins/aws/elasticache/elasticacheNodesCount.js new file mode 100644 index 000000000..fea32df65 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheNodesCount.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Nodes Count', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that the number of ElastiCache cluster cache nodes has not reached the limit quota established by your organization.', + more_info: 'Defining limits for the maximum number of ElastiCache cluster nodes that can be created within your AWS account will help you to better manage your ElastiCache compute resources and prevent unexpected charges on your AWS bill.', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.html', + recommended_action: 'Enable limit for ElastiCache cluster nodes count', + apis: ['ElastiCache:describeCacheClusters'], + settings: { + elasticache_nodes_count_per_region: { + name: 'Amazon ElastiCache Nodes Count Per Region', + description: 'Maximum Amazon ElastiCache nodes count per region', + regex: '^[0-9]{1,4}', + default: '100' + }, + elasticache_nodes_count_global: { + name: 'Amazon ElastiCache Nodes Count Global', + description: 'Maximum Amazon ElastiCache nodes count per region', + regex: '^[0-9]{1,4}', + default: '200' + }, + }, + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + elasticache_nodes_count_per_region: parseInt(settings.elasticache_nodes_count_per_region || this.settings.elasticache_nodes_count_per_region.default), + elasticache_nodes_count_global: parseInt(settings.elasticache_nodes_count_global || this.settings.elasticache_nodes_count_global.default) + }; + + var globalCount = 0; + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + var nodesCount = 0; + describeCacheClusters.data.forEach(cluster => { + if (!cluster.CacheClusterId) return; + + if (cluster.NumCacheNodes) { + nodesCount = nodesCount + cluster.NumCacheNodes; + globalCount = globalCount + cluster.NumCacheNodes; + } + }); + + if (nodesCount <= config.elasticache_nodes_count_per_region) { + helpers.addResult(results, 0, + `Region contains "${nodesCount}" provisioned ElastiCache nodes of "${config.elasticache_nodes_count_per_region}" limit`, region); + } else { + helpers.addResult(results, 2, + `Region contains "${nodesCount}" provisioned ElastiCache nodes of "${config.elasticache_nodes_count_per_region}" limit`, region); + } + + rcb(); + }, function(){ + if (globalCount <= config.elasticache_nodes_count_global) { + helpers.addResult(results, 0, + `Region contains "${globalCount}" provisioned ElastiCache nodes of "${config.elasticache_nodes_count_global}" limit`, 'global'); + } else { + helpers.addResult(results, 2, + `Region contains "${globalCount}" provisioned ElastiCache nodes of "${config.elasticache_nodes_count_global}" limit`, 'global'); + } + + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/elasticacheNodesCount.spec.js b/plugins/aws/elasticache/elasticacheNodesCount.spec.js new file mode 100644 index 000000000..ba2cad32e --- /dev/null +++ b/plugins/aws/elasticache/elasticacheNodesCount.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +const elasticacheNodesCount = require('./elasticacheNodesCount'); + +const describeCacheClusters = [ + { + "CacheClusterId": "test1", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.r6g.large", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "creating", + "NumCacheNodes": 5, + "PreferredAvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "wed:09:30-wed:10:30", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000111222333:cluster:sadeed1", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + }, + { + "CacheClusterId": "test2", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.r6g.large", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "creating", + "NumCacheNodes": 2, + "PreferredAvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "wed:09:30-wed:10:30", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000111222333:cluster:sadeed1", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + + +const createCache = (clusters) => { + return { + elasticache:{ + describeCacheClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache:{ + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error describing elasticache clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache:{ + describeCacheClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elasticacheNodesCount', function () { + describe('run', function () { + it('should PASS if region contains provisioned Elasticache nodes less than or equal to the limit', function (done) { + const cache = createCache([describeCacheClusters[1]]); + const settings = { elasticache_nodes_count_per_region: '5' }; + + elasticacheNodesCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[1].status).to.equal(0); + expect(results[1].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if region contains provisioned Elasticache nodes more than the limit', function (done) { + const cache = createCache([describeCacheClusters[0]]); + const settings = { elasticache_nodes_count_per_region: '4', elasticache_nodes_count_global: '4' }; + + elasticacheNodesCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Elasticache clusters found', function (done) { + const cache = createCache([]); + elasticacheNodesCount.run(cache, { elasticache_nodes_count_per_region: '5' }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + elasticacheNodesCount.run(cache, { elasticache_nodes_count_per_region: '5' }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticacheRedisMultiAZ.js b/plugins/aws/elasticache/elasticacheRedisMultiAZ.js new file mode 100644 index 000000000..e5901ea62 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheRedisMultiAZ.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Redis Cluster Have Multi-AZ', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that your ElastiCache Redis Cache clusters are using a Multi-AZ deployment configuration to enhance High Availability.', + more_info: 'Enabling the Multi-AZ feature for your Redis Cache clusters will improve the fault tolerance in case the read/write primary node becomes unreachable due to loss of network connectivity, loss of availability in the primary’s AZ, etc. ', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/AutoFailover.html#AutoFailover.Enable', + recommended_action: 'Enable Redis Multi-AZ for ElastiCache clusters', + apis: ['ElastiCache:describeCacheClusters', 'ElastiCache:describeReplicationGroups'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:ModifyReplicationGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb) { + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query elasticache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No elasticache clusters found', region); + return rcb(); + } + + for (let cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + var resource = cluster.ARN; + var describeReplicationGroups = helpers.addSource(cache, source, + ['elasticache', 'describeReplicationGroups', region, cluster.ReplicationGroupId]); + + if (!describeReplicationGroups || describeReplicationGroups.err || !describeReplicationGroups.data) { + helpers.addResult(results, 3, + `Unable to get clusters description: ${helpers.addError(describeReplicationGroups)}`, + region, resource); + } else { + if (describeReplicationGroups.data.ReplicationGroups && + describeReplicationGroups.data.ReplicationGroups.some(group => group.MultiAZ && group.MultiAZ.toLowerCase() === 'enabled')) { + helpers.addResult(results, 0, + 'Cluster has Multi-AZ feature enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Cluster does not have Multi-AZ feature enabled', region, resource); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticacheRedisMultiAZ.spec.js b/plugins/aws/elasticache/elasticacheRedisMultiAZ.spec.js new file mode 100644 index 000000000..4547017e3 --- /dev/null +++ b/plugins/aws/elasticache/elasticacheRedisMultiAZ.spec.js @@ -0,0 +1,203 @@ +var expect = require('chai').expect; +const elasticacheRedisMultiAZ = require('./elasticacheRedisMultiAZ'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sadeed-001", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "redis", + "EngineVersion": "6.2.5", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-07T15:02:36.703000+00:00", + "PreferredMaintenanceWindow": "mon:09:00-mon:10:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "ReplicationGroupId": "sadeed", + "SnapshotRetentionLimit": 0, + "SnapshotWindow": "10:00-11:00", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sadeed-001", + }, + { + "CacheClusterId": "sadeed-001", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t3.micro", + "Engine": "redis", + "EngineVersion": "6.0.5", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1f", + "CacheClusterCreateTime": "2021-11-11T11:49:33.551000+00:00", + "PreferredMaintenanceWindow": "fri:03:00-fri:04:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "elasticache-subnet", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ReplicationGroupId": "sad", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "ARN": "arn:aws:elasticache:us-east-1:560213429563:cluster:sad-001", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + +const describeReplicationGroups = [ + { + "ReplicationGroups": [ + { + + "ReplicationGroupId": "sadeed", + "Description": " ", + "GlobalReplicationGroupInfo": {}, + "Status": "creating", + "PendingModifiedValues": {}, + "MemberClusters": [ + "sadeed-001" + ], + "SnapshottingClusterId": "sadeed-001", + "AutomaticFailover": "disabled", + "MultiAZ": "enabled", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "ClusterEnabled": false, + "CacheNodeType": "cache.t3.micro", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "KmsKeyId": "arn:aws:kms:us-east-1:560213429563:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "ARN": "arn:aws:elasticache:us-east-1:560213429563:replicationgroup:sadeed", + "LogDeliveryConfigurations": [], + "ReplicationGroupCreateTime": "2021-11-11T09:30:37.072000+00:00" + } + ] + }, + { + "ReplicationGroups": [ + { + + "ReplicationGroupId": "sad", + "Description": " ", + "GlobalReplicationGroupInfo": {}, + "Status": "creating", + "PendingModifiedValues": {}, + "MemberClusters": [ + "sadeed-001" + ], + "SnapshottingClusterId": "sad-001", + "AutomaticFailover": "disabled", + "MultiAZ": "disabled", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "ClusterEnabled": false, + "CacheNodeType": "cache.t3.micro", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "ARN": "arn:aws:elasticache:us-east-1:560213429563:replicationgroup:sadeed", + "LogDeliveryConfigurations": [], + "ReplicationGroupCreateTime": "2021-11-11T09:30:37.072000+00:00" + } + ] + } +]; + + +const createCache = (clusters, describeReplicationGroups, clustersErr, describeReplicationGroupsErr) => { + var ReplicationGroupId = (clusters && clusters.length) ? clusters[0].ReplicationGroupId : null; + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + describeReplicationGroups: { + 'us-east-1': { + [ReplicationGroupId]: { + data: describeReplicationGroups, + err: describeReplicationGroupsErr + } + } + } + }, + } +} + +describe('elasticacheRedisMultiAZ', function () { + describe('run', function () { + it('should PASS if ElastiCache Redis Cluster has MultiAZ feature enabled.', function (done) { + const cache = createCache([describeCacheClusters[0]], describeReplicationGroups[0]); + elasticacheRedisMultiAZ.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has Multi-AZ feature enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if ElastiCache Redis Cluster does not have MultiAZ feature enabled.', function (done) { + const cache = createCache([describeCacheClusters[1]], describeReplicationGroups[1]); + elasticacheRedisMultiAZ.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no ElastiCache Redis Cluster found', function (done) { + const cache = createCache([]); + elasticacheRedisMultiAZ.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list ElastiCache Redis Cluster', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list ElastiCache Redis Cluster" }); + elasticacheRedisMultiAZ.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/elasticache/elasticaheDesiredNodeType.js b/plugins/aws/elasticache/elasticaheDesiredNodeType.js new file mode 100644 index 000000000..2f870b43d --- /dev/null +++ b/plugins/aws/elasticache/elasticaheDesiredNodeType.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Desired Node Type', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that the Amazon ElastiCache cluster nodes provisioned in your AWS account have the desired node type established within your organization based on the workload deployed.', + more_info: 'Setting limits for the type of Amazon ElastiCache cluster nodes will help you address internal compliance requirements and prevent unexpected charges on your AWS bill.', + recommended_action: 'Create ElastiCache clusters with desired node types', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/WhatIs.html', + apis: ['ElastiCache:describeCacheClusters'], + settings: { + elasticache_desired_node_type: { + name: 'ElastiCache Cluster Desired Node Type', + description: 'ElastiCache Cluster should be using the desired node type', + regex: '^.*$', + default:'cache.t2.micro' + } + }, + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:ModifyReplicationGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + elasticache_desired_node_type: settings.elasticache_desired_node_type || this.settings.elasticache_desired_node_type.default + }; + + if (!config.elasticache_desired_node_type.length) return callback(null, results, source); + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query ElastiCache cluster: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache cluster found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN || !cluster.Engine) continue; + + var resource = cluster.ARN; + + if (cluster.CacheNodeType && cluster.CacheNodeType.length && cluster.Engine && + config.elasticache_desired_node_type.includes(cluster.CacheNodeType)) { + helpers.addResult(results, 0, + `${cluster.Engine} cluster has desired node type: ${cluster.CacheNodeType}`, + region, resource); + } else { + helpers.addResult(results, 2, + `${cluster.Engine} cluster does not have desired node type: ${cluster.CacheNodeType}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/elasticaheDesiredNodeType.spec.js b/plugins/aws/elasticache/elasticaheDesiredNodeType.spec.js new file mode 100644 index 000000000..ada652111 --- /dev/null +++ b/plugins/aws/elasticache/elasticaheDesiredNodeType.spec.js @@ -0,0 +1,168 @@ +const expect = require('chai').expect; +var elasticaheDesiredNodeType = require('./elasticaheDesiredNodeType'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sad1", + "ConfigurationEndpoint": { + "Address": "sad1.zvodgj.cfg.use1.cache.amazonaws.com", + "Port": 11211 + }, + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-01T10:46:20.450000+00:00", + "PreferredMaintenanceWindow": "fri:10:00-fri:11:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad1", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + }, + { + "CacheClusterId": "sad23-001", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.r6g.large", + "Engine": "redis", + "EngineVersion": "6.2.5", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-01T10:56:34.474000+00:00", + "PreferredMaintenanceWindow": "wed:09:00-wed:10:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "ReplicationGroupId": "sad23", + "SnapshotRetentionLimit": 0, + "SnapshotWindow": "05:00-06:00", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad23-001", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + + +const createCache = (describeCacheClusters) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: describeCacheClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error listing elasticache functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mq: { + describeCacheClusters: { + 'us-east-1': null + } + } + }; +}; + +describe('elasticaheDesiredNodeType', function () { + describe('run', function () { + + it('should PASS if ElastiCache clusters have the desired node type', function (done) { + const cache = createCache([describeCacheClusters[0]]); + elasticaheDesiredNodeType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('cluster has desired node type'); + done(); + }); + }); + + it('should FAIL if ElastiCache cluster does not have desired node type', function (done) { + const cache = createCache([describeCacheClusters[1]]); + elasticaheDesiredNodeType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('cluster does not have desired node type') + done(); + }); + }); + + it('should PASS if No ElastiCache cluster found', function (done) { + const cache = createCache([]); + elasticaheDesiredNodeType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ElastiCache cluster found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list ElastiCache clusters', function (done) { + const cache = createErrorCache(); + elasticaheDesiredNodeType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query ElastiCache cluster: error listing elasticache functions'); + done(); + }); + }); + + it('should not return anything if list clusters response not found', function (done) { + const cache = createNullCache(); + elasticaheDesiredNodeType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/idleElastiCacheNode.js b/plugins/aws/elasticache/idleElastiCacheNode.js new file mode 100644 index 000000000..a0b9506f4 --- /dev/null +++ b/plugins/aws/elasticache/idleElastiCacheNode.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache idle Cluster Status', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Identify ElastiCache clusters having CPU utilization below defined threshold within last 24 hours (idle clusters).', + more_info: 'Idle Amazon ElastiCache cache cluster nodes represent a good candidate to reduce your monthly AWS costs and avoid accumulating unnecessary usage charges.', + link: 'https://aws.amazon.com/elasticache/features/', + recommended_action: 'Identify and remove idle ElastiCache clusters', + apis: ['ElastiCache:describeCacheClusters', 'CloudWatch:getEcMetricStatistics'], + settings: { + elasticache_idle_node_percentage: { + name: 'ElastiCache Idle Node Average Percentage', + description: 'A percentage value for cluster CPU utilization under which cluster is considered idle i.e. 2.50', + regex: '^(100(\.0{1,2})?|[1-9]?\d(\.\d{1,2})?)$', // eslint-disable-line + default: '' + } + }, + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var elasticache_idle_node_percentage = settings.elasticache_idle_node_percentage || this.settings.elasticache_idle_node_percentage.default; + + if (!elasticache_idle_node_percentage.length) return callback(null, results, source); + + elasticache_idle_node_percentage = parseFloat(elasticache_idle_node_percentage); + + async.each(regions.elasticache, function(region, rcb) { + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult( + results, 3, + `Unable to query for ElastiCache cluster: ${helpers.addError(describeCacheClusters)}`, region); + return rcb(); + } + + if (!describeCacheClusters.data.length){ + helpers.addResult(results, 0, 'No ElastiCache cluster found', region); + return rcb(); + } + + describeCacheClusters.data.forEach(cluster => { + if (!cluster.ARN) return; + + var resource = cluster.ARN; + var getEcMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getEcMetricStatistics', region, cluster.CacheClusterId]); + + if (!getEcMetricStatistics || getEcMetricStatistics.err || + !getEcMetricStatistics.data || !getEcMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3, + `Unable to query for ElastiCache cluster metric stats: ${helpers.addError(getEcMetricStatistics)}`, region, resource); + return; + } + + if (!getEcMetricStatistics.data.Datapoints.length) { + helpers.addResult(results, 0, + 'ElastiCache cluster metric statistics are not configured', region, resource); + } else { + const isIdle = getEcMetricStatistics.data.Datapoints.every(datapoint => datapoint.Average < elasticache_idle_node_percentage); + const idleHours = getEcMetricStatistics.data.Datapoints.length; + + const status = isIdle ? 2 : 0; + helpers.addResult(results, status, + `ElastiCache cluster is ${isIdle ? 'idle since ' + idleHours + ' hours': 'not idle'}`, region, resource); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/idleElastiCacheNode.spec.js b/plugins/aws/elasticache/idleElastiCacheNode.spec.js new file mode 100644 index 000000000..95e42d596 --- /dev/null +++ b/plugins/aws/elasticache/idleElastiCacheNode.spec.js @@ -0,0 +1,223 @@ +const expect = require('chai').expect; +const idleElastiCacheNode = require('./idleElastiCacheNode'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sad1", + "ConfigurationEndpoint": { + "Address": "sad1.zvodgj.cfg.use1.cache.amazonaws.com", + "Port": 11211 + }, + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1a", + "CacheClusterCreateTime": "2021-12-10T10:17:24.387000+00:00", + "PreferredMaintenanceWindow": "fri:04:00-fri:05:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad1", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + +const ecMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 4.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 3.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 6.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 2.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 1.333, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Unit": "Percent" + } + ] + } +] + +const createCache = (cluster, metrics) => { + if (cluster && cluster.length) var id = cluster[0].CacheClusterId; + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: cluster, + }, + }, + }, + cloudwatch: { + getEcMetricStatistics: { + 'us-east-1': { + [id]: { + data: metrics + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + cloudwatch: { + getEcMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getEcMetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('idleElastiCacheNode', function () { + describe('run', function () { + it('should PASS if metric count is greater than 5', function (done) { + const cache = createCache([describeCacheClusters[0]], ecMetricStatistics[0]); + idleElastiCacheNode.run(cache, { elasticache_idle_node_percentage:'5.00' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ElastiCache cluster is not idle'); + done(); + }); + }); + + it('should FAIL if metric count is lesser than 5', function (done) { + const cache = createCache([describeCacheClusters[0]], ecMetricStatistics[1]); + idleElastiCacheNode.run(cache, { elasticache_idle_node_percentage:'5.00' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('ElastiCache cluster is idle'); + done(); + }); + }); + + it('should PASS if metric count is not part of the response', function (done) { + const cache = createCache([describeCacheClusters[0]], ecMetricStatistics[2]); + idleElastiCacheNode.run(cache, { elasticache_idle_node_percentage:'5.00' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ElastiCache cluster is not idle'); + done(); + }); + }); + + it('should PASS if no ElastiCache cluster found', function (done) { + const cache = createCache([]); + idleElastiCacheNode.run(cache, { elasticache_idle_node_percentage:'5.00' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ElastiCache cluster found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe cache clusters', function (done) { + const cache = createErrorCache(); + idleElastiCacheNode.run(cache, { elasticache_idle_node_percentage:'5.00' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for ElastiCache cluster'); + done(); + }); + }); + + it('should not return any results if describe cache clusters response not found', function (done) { + const cache = createNullCache(); + idleElastiCacheNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticache/redisClusterEncryptionAtRest.js b/plugins/aws/elasticache/redisClusterEncryptionAtRest.js new file mode 100644 index 000000000..966ddd086 --- /dev/null +++ b/plugins/aws/elasticache/redisClusterEncryptionAtRest.js @@ -0,0 +1,127 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Redis Cluster Encryption At-Rest', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Ensure that your Amazon ElastiCache Redis clusters are encrypted to increase data security.', + more_info: 'Amazon ElastiCache provides an optional feature to encrypt your data saved to persistent media. ' + + 'Enable this feature and use customer-managed keys In order to protect it from unauthorized access and fulfill compliance requirements within your organization.', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/at-rest-encryption.html', + recommended_action: 'Enable encryption for ElastiCache cluster data-at-rest', + apis: ['ElastiCache:describeCacheClusters', 'ElastiCache:describeReplicationGroups', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + ec_cluster_target_encryption_level: { + name: 'ElastiCache Cluster Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:CreateReplicationGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.ec_cluster_target_encryption_level || this.settings.ec_cluster_target_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + `Unable to list ElastiCache clusters : ${helpers.addError(describeCacheClusters)}`, region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, + 'No ElastiCache clusters found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + let resource = cluster.ARN; + + if (cluster.Engine !== 'redis') { + helpers.addResult(results, 0, `Encryption is not supported for ${cluster.Engine}`, region, resource); + continue; + } + + if (cluster.AtRestEncryptionEnabled) { + let describeReplicationGroups = helpers.addSource(cache, source, + ['elasticache', 'describeReplicationGroups', region, cluster.ReplicationGroupId]); + + if (!describeReplicationGroups || describeReplicationGroups.err || !describeReplicationGroups.data || + !describeReplicationGroups.data.ReplicationGroups || !describeReplicationGroups.data.ReplicationGroups.length) { + helpers.addResult(results, 3, + `Unable to describe replication groups for cluster: ${helpers.addError(describeReplicationGroups)}`, region, resource); + continue; + } + + if (describeReplicationGroups.data.ReplicationGroups[0].KmsKeyId) { + var kmsKeyId = describeReplicationGroups.data.ReplicationGroups[0].KmsKeyId.split('/')[1] ? describeReplicationGroups.data.ReplicationGroups[0].KmsKeyId.split('/')[1] : describeReplicationGroups.data.ReplicationGroups[0].KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `ElastiCache Redis Cluster is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `ElastiCache Redis Cluster is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Cluster does not have at-rest encryption enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/redisClusterEncryptionAtRest.spec.js b/plugins/aws/elasticache/redisClusterEncryptionAtRest.spec.js new file mode 100644 index 000000000..92b16c371 --- /dev/null +++ b/plugins/aws/elasticache/redisClusterEncryptionAtRest.spec.js @@ -0,0 +1,270 @@ +var expect = require('chai').expect; +var redisClusterEncryptionAtRest = require('./redisClusterEncryptionAtRest'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sadeed-001", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t3.micro", + "Engine": "redis", + "EngineVersion": "6.0.5", + "CacheClusterStatus": "creating", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1f", + "PreferredMaintenanceWindow": "tue:05:00-tue:06:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "elasticache-subnet", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ReplicationGroupId": "sadeed", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "AuthTokenEnabled": false, + }, + { + "CacheClusterId": "sadeed-001", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t3.micro", + "Engine": "redis", + "EngineVersion": "6.0.5", + "CacheClusterStatus": "available", + "NumCacheNodes": 1, + "PreferredAvailabilityZone": "us-east-1f", + "CacheClusterCreateTime": "2021-11-11T11:49:33.551000+00:00", + "PreferredMaintenanceWindow": "fri:03:00-fri:04:00", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.redis6.x", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "elasticache-subnet", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ReplicationGroupId": "sadeed", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "ARN": "arn:aws:elasticache:us-east-1:560213429563:cluster:sad-001", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + } +]; + +const describeReplicationGroups = [ + { + "ReplicationGroups": [ + { + + "ReplicationGroupId": "sadeed", + "Description": " ", + "GlobalReplicationGroupInfo": {}, + "Status": "creating", + "PendingModifiedValues": {}, + "MemberClusters": [ + "sadeed-001" + ], + "SnapshottingClusterId": "sadeed-001", + "AutomaticFailover": "disabled", + "MultiAZ": "disabled", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "ClusterEnabled": false, + "CacheNodeType": "cache.t3.micro", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "KmsKeyId": "arn:aws:kms:us-east-1:560213429563:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "ARN": "arn:aws:elasticache:us-east-1:560213429563:replicationgroup:sadeed", + "LogDeliveryConfigurations": [], + "ReplicationGroupCreateTime": "2021-11-11T09:30:37.072000+00:00" + } + ] + }, + { + "ReplicationGroups": [ + { + + "ReplicationGroupId": "sad", + "Description": " ", + "GlobalReplicationGroupInfo": {}, + "Status": "creating", + "PendingModifiedValues": {}, + "MemberClusters": [ + "sadeed-001" + ], + "SnapshottingClusterId": "sad-001", + "AutomaticFailover": "disabled", + "MultiAZ": "disabled", + "SnapshotRetentionLimit": 1, + "SnapshotWindow": "04:00-05:00", + "ClusterEnabled": false, + "CacheNodeType": "cache.t3.micro", + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": true, + "ARN": "arn:aws:elasticache:us-east-1:560213429563:replicationgroup:sadeed", + "LogDeliveryConfigurations": [], + "ReplicationGroupCreateTime": "2021-11-11T09:30:37.072000+00:00" + } + ] + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (clusters, keys, describeReplicationGroups, describeKey, clustersErr, keysErr, describeKeyErr, describeReplicationGroupsErr) => { + var keyId = (clusters && clusters.length && clusters[0].KmsKeyId) ? clusters[0].KmsKeyId.split('/')[1] : null; + var ReplicationGroupId = (clusters && clusters.length) ? clusters[0].ReplicationGroupId: null; + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + describeReplicationGroups: { + 'us-east-1': { + [ReplicationGroupId]: { + data: describeReplicationGroups, + err: describeReplicationGroupsErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('redisClusterEncryptionAtRest', function () { + describe('run', function () { + it('should PASS if Redis Cluster at-rest is encrypted with desired encryption level', function (done) { + const cache = createCache(describeCacheClusters[0], listKeys, describeReplicationGroups[0], describeKey[0]); + redisClusterEncryptionAtRest.run(cache, { ec_cluster_target_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Redis Cluster at-rest is not encrypted with desired encryption level', function (done) { + const cache = createCache([describeCacheClusters[1]],listKeys, describeReplicationGroups[1], describeKey[1]); + redisClusterEncryptionAtRest.run(cache, { ec_cluster_target_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Redis Cluster at-rest found', function (done) { + const cache = createCache([]); + redisClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Redis Cluster at-rest', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list AppFlow flows" }); + redisClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(describeCacheClusters, null, null, null, { message: "Unable to list KMS keys" }); + redisClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/elasticache/redisClusterEncryptionInTransit.js b/plugins/aws/elasticache/redisClusterEncryptionInTransit.js new file mode 100644 index 000000000..11a33d9ad --- /dev/null +++ b/plugins/aws/elasticache/redisClusterEncryptionInTransit.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Redis Cluster Encryption In-Transit', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Ensure that your AWS ElastiCache Redis clusters have encryption in-transit enabled.', + more_info: 'Amazon ElastiCache in-transit encryption is an optional feature that allows you to increase the security of your data at its most vulnerable points—when it is in transit from one location to another.', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/in-transit-encryption.html', + recommended_action: 'Enable in-transit encryption for ElastiCache clusters', + apis: ['ElastiCache:describeCacheClusters'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:CreateReplicationGroup','elasticache:ModifyReplicationGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN) continue; + + var resource = cluster.ARN; + + if (cluster.Engine !== 'redis'){ + helpers.addResult(results, 0, `Encryption is not supported for ${cluster.Engine}`, region, resource); + continue ; + } + + if (cluster.TransitEncryptionEnabled) { + helpers.addResult(results, 0, + 'Cluster has in-transit encryption enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Cluster does not have in-transit encryption enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/redisClusterEncryptionInTransit.spec.js b/plugins/aws/elasticache/redisClusterEncryptionInTransit.spec.js new file mode 100644 index 000000000..d2e4253da --- /dev/null +++ b/plugins/aws/elasticache/redisClusterEncryptionInTransit.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const redisClusterEncryptionInTransit = require('./redisClusterEncryptionInTransit'); + +const clusters = [ + { + CacheClusterId: 'test-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.t3.micro', + Engine: 'redis', + EngineVersion: '6.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:16:41.340Z', + PreferredMaintenanceWindow: 'mon:09:30-mon:10:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec', + SnapshotRetentionLimit: 1, + SnapshotWindow: '03:30-04:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: true, + AtRestEncryptionEnabled: true, + ARN: 'arn:aws:elasticache:us-east-1:560213429563:cluster:test-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + }, + { + CacheClusterId: 'test1-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.t3.micro', + Engine: 'redis', + EngineVersion: '6.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:17:36.297Z', + PreferredMaintenanceWindow: 'sat:05:30-sat:06:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec1', + SnapshotRetentionLimit: 1, + SnapshotWindow: '04:30-05:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: false, + AtRestEncryptionEnabled: false, + ARN: 'arn:aws:elasticache:us-east-1:560213429563:cluster:test1-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redisClusterEncryptionInTransit', function () { + describe('run', function () { + it('should FAIL if encryption is not enabled for ElastiCache cluster', function (done) { + const cache = createCache([clusters[1]]); + redisClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if encryption is enabled for ElastiCache cluster', function (done) { + const cache = createCache([clusters[0]]); + redisClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no ElastiCache clusters found', function (done) { + const cache = createCache([]); + redisClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for ElastiCache clusters', function (done) { + const cache = createErrorCache(); + redisClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for ElastiCache clusters', function (done) { + const cache = createNullCache(); + redisClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticache/redisEngineVersions.js b/plugins/aws/elasticache/redisEngineVersions.js new file mode 100644 index 000000000..b0d63de95 --- /dev/null +++ b/plugins/aws/elasticache/redisEngineVersions.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Engine Versions for Redis', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Amazon ElastiCache clusters are using the stable latest version of Redis cache engine.', + more_info: 'ElastiCache clusters with the latest version of Redis cache engine, You will benefit from new features and enhancements, ' + + 'Using engines prior to version 3.2.6 will not be benefited with Encryption Options, support for HIPAA compliance and much more. Also engine version 3.2.10 does not support Encryption options.', + link: 'https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/supported-engine-versions.html', + recommended_action: 'Upgrade the version of Redis on all ElastiCache clusters to the latest available version.', + apis: ['ElastiCache:describeCacheClusters'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:ModifyCacheCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var deprecatedVersions = { + '5.0.0' : 'redis', + '5.0.3' : 'redis', + '5.0.4' : 'redis', + '5.0.5' : 'redis', + '2.6.13': 'redis', + '2.8.6' : 'redis', + '2.8.19': 'redis', + }; + + var nonRecommendedVersions = { + '3.2.4' : 'redis', + '2.8.24' : 'redis', + '2.8.23' : 'redis', + '2.8.22' : 'redis', + '2.8.21' : 'redis', + '2.8.19' : 'redis', + '2.8.6' : 'redis', + '2.6.13' : 'redis', + '3.2.10' : 'redis', + '3.2.6' : 'redis', + }; + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + if (!describeCacheClusters) return rcb(); + + if (describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + if (!describeCacheClusters.data.length) { + helpers.addResult(results, 0, 'No ElastiCache clusters found', region); + return rcb(); + } + + for (var cluster of describeCacheClusters.data) { + if (!cluster.ARN || cluster.Engine !== 'redis') continue; + + var resource = cluster.ARN; + + if (cluster.EngineVersion) { + var version = cluster.EngineVersion; + let versionDeprecationType = (deprecatedVersions[version]) ? deprecatedVersions[version] : null; + let versionSpecifiedType = (nonRecommendedVersions[version]) ? nonRecommendedVersions[version] : null; + + if (versionDeprecationType) { + helpers.addResult(results, 2, + 'ElastiCache redis cluster is using ' + version + ' engine version which is deprecated', + region, resource); + } else if (versionSpecifiedType) { + helpers.addResult(results, 2, + 'ElastiCache redis cluster is using ' + version + ' engine version which is not recommended', + region, resource); + } else { + helpers.addResult(results, 0, + 'ElastiCache redis cluster is using ' + version + ' engine version', + region, resource); + } + } else { + helpers.addResult(results, 2, 'ElastiCache redis cluster is using unknown engine version', region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/redisEngineVersions.spec.js b/plugins/aws/elasticache/redisEngineVersions.spec.js new file mode 100644 index 000000000..74c4fa667 --- /dev/null +++ b/plugins/aws/elasticache/redisEngineVersions.spec.js @@ -0,0 +1,142 @@ +var expect = require('chai').expect; +const redisEngineVersions = require('./redisEngineVersions'); + +const clusters = [ + { + CacheClusterId: 'test-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.t2.micro', + Engine: 'redis', + EngineVersion: '6.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:16:41.340Z', + PreferredMaintenanceWindow: 'mon:09:30-mon:10:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec', + SnapshotRetentionLimit: 1, + SnapshotWindow: '03:30-04:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: true, + AtRestEncryptionEnabled: true, + ARN: 'arn:aws:elasticache:us-east-1:000111222333:cluster:test-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + }, + { + CacheClusterId: 'test1-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.m2.4xlarge', + Engine: 'redis', + EngineVersion: '5.0.5', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:17:36.297Z', + PreferredMaintenanceWindow: 'sat:05:30-sat:06:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec1', + SnapshotRetentionLimit: 1, + SnapshotWindow: '04:30-05:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: false, + AtRestEncryptionEnabled: false, + ARN: 'arn:aws:elasticache:us-east-1:000111222333:cluster:test1-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + }, + { + CacheClusterId: 'test1-001', + ClientDownloadLandingPage: 'https://console.aws.amazon.com/elasticache/home#client-download:', + CacheNodeType: 'cache.m2.4xlarge', + Engine: 'redis', + EngineVersion: '3.2.4', + CacheClusterStatus: 'available', + NumCacheNodes: 1, + PreferredAvailabilityZone: 'us-east-1f', + CacheClusterCreateTime: '2021-11-10T12:17:36.297Z', + PreferredMaintenanceWindow: 'sat:05:30-sat:06:30', + PendingModifiedValues: {}, + CacheSecurityGroups: [], + CacheParameterGroup: [Object], + CacheSubnetGroupName: 'elasticache-subnet', + CacheNodes: [], + AutoMinorVersionUpgrade: true, + SecurityGroups: [Array], + ReplicationGroupId: 'akhtar-ec1', + SnapshotRetentionLimit: 1, + SnapshotWindow: '04:30-05:30', + AuthTokenEnabled: false, + TransitEncryptionEnabled: false, + AtRestEncryptionEnabled: false, + ARN: 'arn:aws:elasticache:us-east-1:000111222333:cluster:test1-001', + ReplicationGroupLogDeliveryEnabled: false, + LogDeliveryConfigurations: [] + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeCacheClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + + +describe('redisEngineVersions', function () { + describe('run', function () { + it('should give error result if ElastiCache redis cluster is using deprecated engine version', function (done) { + const cache = createCache([clusters[1]]); + redisEngineVersions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should give passing result if ElastiCache redis cluster is using current version', function (done) { + const cache = createCache([clusters[0]]); + redisEngineVersions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should give failing result if ElastiCache redis cluster engine version is not recommended', function (done) { + const cache = createCache([clusters[2]]); + redisEngineVersions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS no ElastiCache clusters found', function (done) { + const cache = createCache([]); + redisEngineVersions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/reservedNodeLeaseExpiration.js b/plugins/aws/elasticache/reservedNodeLeaseExpiration.js new file mode 100644 index 000000000..14eedd406 --- /dev/null +++ b/plugins/aws/elasticache/reservedNodeLeaseExpiration.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Reserved Cache Node Lease Expiration', + category: 'ElastiCache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that your AWS ElastiCache Reserved Cache Nodes are renewed before expiration in order to get a significant discount.', + more_info: 'Reserved Cache Nodes can optimize your Amazon ElastiCache costs based on your expected usage. Since RCNs are not renewed automatically, purchasing another reserved ElastiCache nodes before expiration will guarantee their billing at a discounted hourly rate.', + link: 'https://aws.amazon.com/elasticache/reserved-cache-nodes/', + recommended_action: 'Enable ElastiCache reserved cache nodes expiration days alert', + apis: ['ElastiCache:describeReservedCacheNodes'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:PurchaseReservedCacheNodesOffering'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeReservedCacheNodes = helpers.addSource(cache, source, + ['elasticache', 'describeReservedCacheNodes', region]); + + if (!describeReservedCacheNodes) return rcb(); + + if (describeReservedCacheNodes.err || !describeReservedCacheNodes.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache Reserved Cache Node: ' + helpers.addError(describeReservedCacheNodes), region); + return rcb(); + } + + if (!describeReservedCacheNodes.data.length) { + helpers.addResult(results, 0, 'No ElastiCache reserved cache nodes found', region); + return rcb(); + } + + for (var cluster of describeReservedCacheNodes.data) { + if (!cluster.ReservationARN) continue; + + var resource = cluster.ReservationARN; + + let start = cluster.StartTime; + let duration = cluster.Duration; + + if (duration == 1 || duration == 3){ + duration = duration * 31536000; + } + + let expiry = Math.floor(new Date(start)) + (duration * 1000); + let expirationDays = Math.round((new Date(expiry).getTime() - new Date().getTime())/(24*60*60*1000)); + + if (expirationDays >= 30) { + helpers.addResult(results, 0, + 'ElastiCache reserved cache node lease expires in ' + expirationDays + ' days', region, resource); + } else if (expirationDays > 0 ) { + helpers.addResult(results, 2, + 'ElastiCache reserved cache node lease expires in ' + expirationDays + ' days', region, resource); + } else { + helpers.addResult(results, 2, + 'ElastiCache reserved cache node lease has expired', region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/reservedNodeLeaseExpiration.spec.js b/plugins/aws/elasticache/reservedNodeLeaseExpiration.spec.js new file mode 100644 index 000000000..dae87aca3 --- /dev/null +++ b/plugins/aws/elasticache/reservedNodeLeaseExpiration.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +const reservedNodeLeaseExpiration = require('./reservedNodeLeaseExpiration'); + +const clusters = [ + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": new Date(), + "Duration": 94608000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-pending", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + }, + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2020-12-18T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-failed", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + }, + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-29T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-failed", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('reservedNodeLeaseExpiration', function () { + describe('run', function () { + it('should FAIL if lease expiration is less than 30 days for renewal of ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[1]]); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if lease expiration is more than 30 days for renewal of ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[0]]); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if lease is expired for ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[2]]); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no ElastiCache reserved cache node found', function (done) { + const cache = createCache([]); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for ElastiCache reserved cache node', function (done) { + const cache = createErrorCache(); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for ElastiCache reserved cache node', function (done) { + const cache = createNullCache(); + reservedNodeLeaseExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/reservedNodePaymentFailed.js b/plugins/aws/elasticache/reservedNodePaymentFailed.js new file mode 100644 index 000000000..372d1b24e --- /dev/null +++ b/plugins/aws/elasticache/reservedNodePaymentFailed.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Reserved Cache Node Payment Failed', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Ensure that payments for ElastiCache Reserved Cache Nodes available within your AWS account has been processed completely. ', + more_info: 'When using ElastiCache Reserved Cache Nodes over standard On-Demand Cache Nodes savings are up to max that they give when used in steady state, therefore in order to receive this benefit you need to make sure that all your ElastiCache reservation purchases have been fully successful.', + link: 'https://aws.amazon.com/elasticache/reserved-cache-nodes/', + recommended_action: 'Identify any failed payments for ElastiCache reserved cache nodes', + apis: ['ElastiCache:describeReservedCacheNodes'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster', 'elasticache:PurchaseReservedCacheNodesOffering'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeReservedCacheNodes = helpers.addSource(cache, source, + ['elasticache', 'describeReservedCacheNodes', region]); + + if (!describeReservedCacheNodes) return rcb(); + + if (describeReservedCacheNodes.err || !describeReservedCacheNodes.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache reserved cache nodes: ' + helpers.addError(describeReservedCacheNodes), region); + return rcb(); + } + + if (!describeReservedCacheNodes.data.length) { + helpers.addResult(results, 0, 'No ElastiCache reserved cache nodes found', region); + return rcb(); + } + + for (var cluster of describeReservedCacheNodes.data) { + if (!cluster.ReservationARN) continue; + + var resource = cluster.ReservationARN; + + if (cluster.State === 'payment-failed') { + helpers.addResult(results, 2, + 'ElastiCache reserved cache node have payment failure', region, resource); + } else { + helpers.addResult(results, 0, + 'ElastiCache reserved cache node does not have payment failure', region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elasticache/reservedNodePaymentFailed.spec.js b/plugins/aws/elasticache/reservedNodePaymentFailed.spec.js new file mode 100644 index 000000000..15f6bb704 --- /dev/null +++ b/plugins/aws/elasticache/reservedNodePaymentFailed.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +const reservedNodePaymentFailed = require('./reservedNodePaymentFailed'); + +const clusters = [ + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-pending", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + }, + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-failed", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('reservedNodePaymentFailed', function () { + describe('run', function () { + it('should FAIL if shown state is payment-failure option for ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[1]]); + reservedNodePaymentFailed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if payment state is not failure for ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[0]]); + reservedNodePaymentFailed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no ElastiCache reserved cache node found', function (done) { + const cache = createCache([]); + reservedNodePaymentFailed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for ElastiCache reserved cache node', function (done) { + const cache = createErrorCache(); + reservedNodePaymentFailed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for ElastiCache reserved cache node', function (done) { + const cache = createNullCache(); + reservedNodePaymentFailed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticache/reservedNodePaymentPending.js b/plugins/aws/elasticache/reservedNodePaymentPending.js new file mode 100644 index 000000000..1c3e50407 --- /dev/null +++ b/plugins/aws/elasticache/reservedNodePaymentPending.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElastiCache Reserved Cache Node Payment Pending', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Ensure that payments for ElastiCache Reserved Cache Nodes available within your AWS account has been processed completely. ', + more_info: 'When using ElastiCache Reserved Cache Nodes over standard On-Demand Cache Nodes savings are up to max that they give when used in steady state, therefore in order to receive this benefit you need to make sure that all your ElastiCache reservation purchases have been fully successful.', + link: 'https://aws.amazon.com/elasticache/reserved-cache-nodes/', + recommended_action: 'Identify any pending payments for ElastiCache reserved cache nodes', + apis: ['ElastiCache:describeReservedCacheNodes'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster','elasticache:PurchaseReservedCacheNodesOffering'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeReservedCacheNodes = helpers.addSource(cache, source, + ['elasticache', 'describeReservedCacheNodes', region]); + + if (!describeReservedCacheNodes) return rcb(); + + if (describeReservedCacheNodes.err || !describeReservedCacheNodes.data) { + helpers.addResult(results, 3, + 'Unable to query for ElastiCache reserved cache node: ' + helpers.addError(describeReservedCacheNodes), region); + return rcb(); + } + + if (!describeReservedCacheNodes.data.length) { + helpers.addResult(results, 0, 'No ElastiCache reserved cache node found', region); + return rcb(); + } + + for (var cluster of describeReservedCacheNodes.data) { + if (!cluster.ReservationARN) continue; + + var resource = cluster.ReservationARN; + + if (cluster.State === 'payment-pending') { + helpers.addResult(results, 2, + 'ElastiCache reserved cache node have pending payment', region, resource); + } else { + helpers.addResult(results, 0, + 'ElastiCache reserved cache node does not have pending payment', region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/reservedNodePaymentPending.spec.js b/plugins/aws/elasticache/reservedNodePaymentPending.spec.js new file mode 100644 index 000000000..0b3040c77 --- /dev/null +++ b/plugins/aws/elasticache/reservedNodePaymentPending.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +const reservedNodePaymentPending = require('./reservedNodePaymentPending'); + +const clusters = [ + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-pending", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + }, + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-failed", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + } +]; + +const createCache = (clusters) => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticache: { + describeReservedCacheNodes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('reservedNodePaymentPending', function () { + describe('run', function () { + it('should FAIL if shown state payment is pending for ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[0]]); + reservedNodePaymentPending.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if payment state is not pending for ElastiCache reserved cache node', function (done) { + const cache = createCache([clusters[1]]); + reservedNodePaymentPending.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no ElastiCache reserved cache node found', function (done) { + const cache = createCache([]); + reservedNodePaymentPending.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for ElastiCache reserved cache node', function (done) { + const cache = createErrorCache(); + reservedNodePaymentPending.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for ElastiCache reserved cache node', function (done) { + const cache = createNullCache(); + reservedNodePaymentPending.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticache/unusedElastiCacheReservedNode.js b/plugins/aws/elasticache/unusedElastiCacheReservedNode.js new file mode 100644 index 000000000..42b711d50 --- /dev/null +++ b/plugins/aws/elasticache/unusedElastiCacheReservedNode.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused ElastiCache Reserved Cache Nodes', + category: 'ElastiCache', + domain: 'Databases', + severity: 'High', + description: 'Ensure that all your AWS ElastiCache reserved nodes have corresponding cache nodes running within the same account of an AWS Organization.', + more_info: 'Creating cache nodes for your unused reserved cache clusters will prevent your investment having a negative return. When an Amazon ElastiCache RCN is not in use the investment made is not properly exploited.', + link: 'https://aws.amazon.com/elasticache/reserved-cache-nodes/', + recommended_action: 'Enable prevention of unused reserved nodes for ElastiCache clusters', + apis: ['ElastiCache:describeCacheClusters', 'ElastiCache:describeReservedCacheNodes'], + realtime_triggers: ['elasticache:CreateCacheCluster', 'elasticache:DeleteCacheCluster','elasticache:PurchaseReservedCacheNodesOffering'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticache, function(region, rcb){ + var describeCacheClusters = helpers.addSource(cache, source, + ['elasticache', 'describeCacheClusters', region]); + + var describeReservedCacheNodes = helpers.addSource(cache, source, + ['elasticache', 'describeReservedCacheNodes', region]); + + if (!describeReservedCacheNodes) return rcb(); + + if (describeReservedCacheNodes.err || !describeReservedCacheNodes.data) { + helpers.addResult(results, 3, + 'Unable to query for elasticache reserved nodes: ' + helpers.addError(describeReservedCacheNodes), region); + return rcb(); + } + + if (!describeReservedCacheNodes.data.length) { + helpers.addResult(results, 0, 'No elasticache reserved nodes found', region); + return rcb(); + } + + if (!describeCacheClusters || describeCacheClusters.err || !describeCacheClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for elasticache clusters: ' + helpers.addError(describeCacheClusters), region); + return rcb(); + } + + var usedReservedNodes = []; + describeCacheClusters.data.forEach(cluster => { + if (!cluster.CacheClusterId) return; + + if (!usedReservedNodes.includes(cluster.CacheNodeType)) { + usedReservedNodes.push(cluster.CacheNodeType); + } + }); + + describeReservedCacheNodes.data.forEach(node => { + if (usedReservedNodes.includes(node.CacheNodeType)) { + helpers.addResult(results, 0, + `ElastiCache reserved cache node "${node.ReservedCacheNodeId}" is being used`, + region, node.ReservationARN); + } else { + helpers.addResult(results, 2, + `ElastiCache reserved cache node "${node.ReservedCacheNodeId}" is not being used`, + region, node.ReservationARN); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticache/unusedElastiCacheReservedNode.spec.js b/plugins/aws/elasticache/unusedElastiCacheReservedNode.spec.js new file mode 100644 index 000000000..371f09ec3 --- /dev/null +++ b/plugins/aws/elasticache/unusedElastiCacheReservedNode.spec.js @@ -0,0 +1,188 @@ +var expect = require('chai').expect; +const unusedElastiCacheReservedNode = require('./unusedElastiCacheReservedNode'); + +const describeCacheClusters = [ + { + "CacheClusterId": "sad2", + "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", + "CacheNodeType": "cache.t2.micro", + "Engine": "memcached", + "EngineVersion": "1.6.6", + "CacheClusterStatus": "creating", + "NumCacheNodes": 1, + "PreferredMaintenanceWindow": "sat:08:30-sat:09:30", + "PendingModifiedValues": {}, + "CacheSecurityGroups": [], + "CacheParameterGroup": { + "CacheParameterGroupName": "default.memcached1.6", + "ParameterApplyStatus": "in-sync", + "CacheNodeIdsToReboot": [] + }, + "CacheSubnetGroupName": "mine", + "AutoMinorVersionUpgrade": true, + "SecurityGroups": [ + { + "SecurityGroupId": "sg-05682812766c2fca2", + "Status": "active" + } + ], + "AuthTokenEnabled": false, + "TransitEncryptionEnabled": false, + "AtRestEncryptionEnabled": false, + "ARN": "arn:aws:elasticache:us-east-1:000011112222:cluster:sad2", + "ReplicationGroupLogDeliveryEnabled": false, + "LogDeliveryConfigurations": [] + }, +]; + +const describeReservedCacheNodes = [ + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t2.micro", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-pending", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + }, + { + "ReservedCacheNodeId": "mynode", + "ReservedCacheNodesOfferingId": "xxxxxxxxx-xxxxx-xxxxx-xxxx-xxxxxxxx71", + "CacheNodeType": "cache.t3.small", + "StartTime": "2019-12-06T02:50:44.003Z", + "Duration": 31536000, + "FixedPrice": 0.0, + "UsagePrice": 0.0, + "CacheNodeCount": 1, + "ProductDescription": "redis", + "OfferingType": "No Upfront", + "State": "payment-pending", + "RecurringCharges": [ + { + "RecurringChargeAmount": 0.023, + "RecurringChargeFrequency": "Hourly" + } + ], + "ReservationARN": "arn:aws:elasticache:us-west-2:xxxxxxxxxxxx52:reserved-instance:mynode" + } +]; + +const createCache = (clusters, nodes) => { + return { + elasticache:{ + describeCacheClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeReservedCacheNodes: { + 'us-east-1': { + data: nodes + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + elasticache:{ + describeCacheClusters: { + 'us-east-1': { + err: { + message: 'error while describing ElastiCache clusters' + }, + }, + }, + describeReservedCacheNodes: { + 'us-east-1': { + err: { + message: 'error while describing ElastiCache reserved cache nodes' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ElastiCache:{ + describeCacheClusters: { + 'us-east-1': null, + }, + describeReservedCacheNodes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('unusedElastiCacheReservedNode', function () { + describe('run', function () { + it('should PASS if ElastiCache reserved cache node is being used', function (done) { + const cache = createCache([describeCacheClusters[0]], [describeReservedCacheNodes[0]]); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ElastiCache reserved cache node is not being used', function (done) { + const cache = createCache([describeCacheClusters[0]], [describeReservedCacheNodes[1]]); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no ElastiCache reserved cache nodes found', function (done) { + const cache = createCache([], []); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe reserved cache nodes', function (done) { + const cache = createCache([]); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if describe clusters response not found', function (done) { + const cache = createNullCache(); + unusedElastiCacheReservedNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elasticbeanstalk/enhancedHealthReporting.js b/plugins/aws/elasticbeanstalk/enhancedHealthReporting.js new file mode 100644 index 000000000..98004a8a5 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/enhancedHealthReporting.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Enhanced Health Reporting', + category: 'ElasticBeanstalk', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Amazon Elastic Beanstalk (EB) environments have enhanced health reporting feature enabled.', + more_info: 'Enhanced health reporting is a feature that you can enable on your environment to allow AWS Elastic Beanstalk to gather additional' + + 'information about resources in your environment. Elastic Beanstalk analyzes the information gathered to provide a better picture of overall' + + 'environment health and aid in the identification of issues that can cause your application to become unavailable.', + recommended_action: 'Modify Elastic Beanstalk environmentsand enable enhanced health reporting.', + link: 'https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/health-enhanced.html', + apis: ['ElasticBeanstalk:describeEnvironments'], + realtime_triggers: ['elasticbeanstalk:CreateEnvironment', 'elasticbeanstalk:UpdateEnvironment', 'elasticbeanstalk:TerminateEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + + async.each(regions.elasticbeanstalk, function(region, rcb){ + var describeEnvironments = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeEnvironments', region]); + + if (!describeEnvironments) return rcb(); + + if (describeEnvironments.err || !describeEnvironments.data) { + helpers.addResult(results, 3, + 'Unable to query for Elastic Beanstalk environments', region); + return rcb(); + } + + if (!describeEnvironments.data.length) { + helpers.addResult(results, 0, + 'No Elastic Beanstalk environments found', region); + return rcb(); + } + + for (let environment of describeEnvironments.data) { + var resource = environment.EnvironmentArn; + + if (environment.Health && environment.HealthStatus) { + helpers.addResult(results, 0, `Enhanced Health Reporting feature is enabled for environment ${environment.EnvironmentName}.`, region, resource); + } else { + helpers.addResult(results, 2, `Enhanced Health Reporting feature is not enabled for environment: ${environment.EnvironmentName}`, region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticbeanstalk/enhancedHealthReporting.spec.js b/plugins/aws/elasticbeanstalk/enhancedHealthReporting.spec.js new file mode 100644 index 000000000..24e88e425 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/enhancedHealthReporting.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var enhancedHealthReporting = require('./enhancedHealthReporting'); + +const environments = [ + { + "EnvironmentName": "Akhtar681-env-2", + "EnvironmentId": "e-bucuvmfa4a", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.167.147.57", + "CNAME": "Akhtar681-env-2.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T17:02:36.060Z", + "DateUpdated": "2020-08-22T17:05:59.178Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-2" + }, + { + "EnvironmentName": "Akhtar681-env-1", + "EnvironmentId": "e-3bb85da33w", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.92.177.151", + "CNAME": "Akhtar681-env-1.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T16:08:13.720Z", + "DateUpdated": "2020-08-22T16:52:06.836Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-1" + } +]; + +const createCache = (environments, configurationSettings) => { + if (environments.length) var environmentArn = environments[0].EnvironmentArn; + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + data: environments + }, + }, + describeConfigurationSettings: { + 'us-east-1': { + [environmentArn]: { + data: { + ConfigurationSettings: configurationSettings + } + } + }, + }, + }, + }; +}; + + +const createErrorCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + err: { + message: 'error describing environments' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('enhancedHealthReporting', function () { + describe('run', function () { + + it('should PASS if unable to get application environments', function (done) { + const cache = createCache([]); + enhancedHealthReporting.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if enhanced health reporting is not enabled for application environment', function (done) { + const cache = createCache([environments[1]]); + enhancedHealthReporting.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('Enhanced Health Reporting feature is not enabled for environment'); + done(); + }); + }); + + it('should PASS if enhanced health reporting is enabled for application environment', function (done) { + const cache = createCache([environments[0]]); + enhancedHealthReporting.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('Enhanced Health Reporting feature is enabled for environment'); + done(); + }); + }); + + it('should not return any results if unable to get environments', function (done) { + const cache = createNullCache(); + enhancedHealthReporting.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching environments', function (done) { + const cache = createErrorCache(); + enhancedHealthReporting.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticbeanstalk/environmentAccessLogs.js b/plugins/aws/elasticbeanstalk/environmentAccessLogs.js new file mode 100644 index 000000000..896b281d9 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/environmentAccessLogs.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Environment Access Logs', + category: 'ElasticBeanstalk', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your Amazon Elastic Beanstalk environment is configured to save logs for load balancer associated with the application environment.', + more_info: 'Elastic Load Balancing provides access logs that capture detailed information about requests sent to your load balancer. Each log contains information such as the time the request was received, the client\'s IP address, latencies, request paths, and server responses. You can use these access logs to analyze traffic patterns and troubleshoot issues.', + recommended_action: 'Go to specific environment, select Configuration, edit Load Balancer category, and enable Store logs', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html', + apis: ['ElasticBeanstalk:describeEnvironments', 'ElasticBeanstalk:describeConfigurationSettings'], + realtime_triggers: ['elasticbeanstalk:CreateEnvironment', 'elasticbeanstalk:UpdateEnvironment', 'elasticbeanstalk:TerminateEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elasticbeanstalk, function(region, rcb){ + var describeEnvironments = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeEnvironments', region]); + + if (!describeEnvironments) return rcb(); + + if (describeEnvironments.err || !describeEnvironments.data) { + helpers.addResult(results, 3, + 'Unable to query for ElasticBeanstalk environments: ' + helpers.addError(describeEnvironments), region); + return rcb(); + } + + if (!describeEnvironments.data.length) { + helpers.addResult(results, 0, + 'No ElasticBeanstalk environments found', region); + return rcb(); + } + + async.each(describeEnvironments.data, function(environment, ecb){ + var resource = environment.EnvironmentArn; + var describeConfigurationSettings = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeConfigurationSettings', region, environment.EnvironmentArn]); + + if (!describeConfigurationSettings || + describeConfigurationSettings.err || + !describeConfigurationSettings.data || + !describeConfigurationSettings.data.ConfigurationSettings) { + helpers.addResult(results, 3, + 'Unable to query for environment configuration settings: ' + helpers.addError(describeConfigurationSettings), + region, resource); + return ecb(); + } + + if (!describeConfigurationSettings.data.ConfigurationSettings.length) { + helpers.addResult(results, 2, 'Environment does not have any log configuration', region, resource); + return ecb(); + } + + let OptionSettings = describeConfigurationSettings.data.ConfigurationSettings.map(({ OptionSettings }) => OptionSettings ); + let accesLogs = OptionSettings.flat().find(option => option.OptionName === 'AccessLogsS3Enabled' ); + + if (accesLogs && accesLogs.Value === 'true') { + helpers.addResult(results, 0, + 'Access Logs for environment: ' + environment.EnvironmentName + ' are enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Access Logs for environment: ' + environment.EnvironmentName + ' are not enabled', + region, resource); + } + + ecb(); + }, function() { + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticbeanstalk/environmentAccessLogs.spec.js b/plugins/aws/elasticbeanstalk/environmentAccessLogs.spec.js new file mode 100644 index 000000000..9cf150035 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/environmentAccessLogs.spec.js @@ -0,0 +1,221 @@ +var expect = require('chai').expect; +var environmentAccessLogs = require('./environmentAccessLogs'); + +const environments = [ + { + "EnvironmentName": "Akhtar681-env-2", + "EnvironmentId": "e-bucuvmfa4a", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.167.147.57", + "CNAME": "Akhtar681-env-2.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T17:02:36.060Z", + "DateUpdated": "2020-08-22T17:05:59.178Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-2" + }, + { + "EnvironmentName": "Akhtar681-env-1", + "EnvironmentId": "e-3bb85da33w", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.92.177.151", + "CNAME": "Akhtar681-env-1.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T16:08:13.720Z", + "DateUpdated": "2020-08-22T16:52:06.836Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-1" + } +]; + +const configurationSettings = [ + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'false' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'AccessLogsS3Enabled', + Value: 'true' + } + ] + }, + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'true' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'AccessLogsS3Enabled', + Value: 'false' + } + ] + } +]; + +const createCache = (environments, configurationSettings) => { + if (environments.length) var environmentArn = environments[0].EnvironmentArn; + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + data: environments + }, + }, + describeConfigurationSettings: { + 'us-east-1': { + [environmentArn]: { + data: { + ConfigurationSettings: configurationSettings + } + } + }, + }, + }, + }; +}; + + +const createErrorCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + err: { + message: 'error describing environments' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('environmentAccessLogs', function () { + describe('run', function () { + + it('should PASS if unable to get application environments', function (done) { + const cache = createCache([]); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to get configuration settings', function (done) { + const cache = createCache([environments[0]],[]); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if environment access logs are not enabled for application environment', function (done) { + const cache = createCache([environments[1]], [configurationSettings[1]]); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if environment access logs are enabled for application environment', function (done) { + const cache = createCache([environments[0]], [configurationSettings[0]]); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to get environments', function (done) { + const cache = createNullCache(); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching environments', function (done) { + const cache = createErrorCache(); + environmentAccessLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticbeanstalk/environmentPersistentLogs.js b/plugins/aws/elasticbeanstalk/environmentPersistentLogs.js new file mode 100644 index 000000000..d8218f68b --- /dev/null +++ b/plugins/aws/elasticbeanstalk/environmentPersistentLogs.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Environment Persistent Logs', + category: 'ElasticBeanstalk', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that AWS Elastic Beanstalk environment logs are retained and saved on S3.', + more_info: 'Elastic Beanstalk environment logs should be retained in order to keep the logging data for future audits, historical purposes or to track and analyze the EB application environment behavior for a long period of time.', + recommended_action: 'Go to specific environment, select Configuration, edit Software category, and enable Log streaming', + link: 'https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/AWSHowTo.cloudwatchlogs.html', + apis: ['ElasticBeanstalk:describeEnvironments', 'ElasticBeanstalk:describeConfigurationSettings'], + realtime_triggers: ['elasticbeanstalk:CreateEnvironment', 'elasticbeanstalk:UpdateEnvironment', 'elasticbeanstalk:TerminateEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + + async.each(regions.elasticbeanstalk, function(region, rcb){ + var describeEnvironments = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeEnvironments', region]); + + if (!describeEnvironments) return rcb(); + + if (describeEnvironments.err || !describeEnvironments.data) { + helpers.addResult(results, 3, + 'Unable to query for ElasticBeanstalk environments: ' + helpers.addError(describeEnvironments), region); + return rcb(); + } + + if (!describeEnvironments.data.length) { + helpers.addResult(results, 0, + 'No ElasticBeanstalk environments found', region); + return rcb(); + } + + async.each(describeEnvironments.data, function(environment, ecb){ + var resource = environment.EnvironmentArn; + var describeConfigurationSettings = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeConfigurationSettings', region, environment.EnvironmentArn]); + + if (!describeConfigurationSettings || + describeConfigurationSettings.err || + !describeConfigurationSettings.data || + !describeConfigurationSettings.data.ConfigurationSettings) { + helpers.addResult(results, 3, + 'Unable to query for environment configuration settings: ' + helpers.addError(describeConfigurationSettings), + region, resource); + return ecb(); + } + + if (!describeConfigurationSettings.data.ConfigurationSettings.length) { + helpers.addResult(results, 2, 'Environment does not have any log configuration', region, resource); + return ecb(); + } + + let OptionSettings = describeConfigurationSettings.data.ConfigurationSettings.map(({ OptionSettings }) => OptionSettings ); + let persistentLogs = OptionSettings.flat().filter(option => option.OptionName === 'LogPublicationControl' || option.OptionName === 'StreamLogs'); + + if (!persistentLogs || !persistentLogs.length) { + helpers.addResult(results, 2, + 'Environment Persistent Logs for environment: ' + environment.EnvironmentName + ' are not enabled', + region, resource); + } else if (persistentLogs[0] && persistentLogs[0].Value === 'false' && persistentLogs[1] && persistentLogs[1].Value === 'false') { + helpers.addResult(results, 2, + 'Environment Persistent Logs for environment: ' + environment.EnvironmentName + ' are not enabled', + region, resource); + } else { + helpers.addResult(results, 0, + 'Environment Persistent Logs for environment: ' + environment.EnvironmentName + ' are enabled', + region, resource); + } + + ecb(); + }, function() { + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticbeanstalk/environmentPersistentLogs.spec.js b/plugins/aws/elasticbeanstalk/environmentPersistentLogs.spec.js new file mode 100644 index 000000000..f74ef7279 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/environmentPersistentLogs.spec.js @@ -0,0 +1,241 @@ +var expect = require('chai').expect; +var environmentPersistentLogs = require('./environmentPersistentLogs'); + +const environments = [ + { + "EnvironmentName": "Akhtar681-env-2", + "EnvironmentId": "e-bucuvmfa4a", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.167.147.57", + "CNAME": "Akhtar681-env-2.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T17:02:36.060Z", + "DateUpdated": "2020-08-22T17:05:59.178Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-2" + }, + { + "EnvironmentName": "Akhtar681-env-1", + "EnvironmentId": "e-3bb85da33w", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.92.177.151", + "CNAME": "Akhtar681-env-1.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T16:08:13.720Z", + "DateUpdated": "2020-08-22T16:52:06.836Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-1" + } +]; + +const configurationSettings = [ + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'false' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'AccessLogsS3Enabled', + Value: 'true' + }, + { + OptionName: 'LogPublicationControl', + Namespace: 'aws:elasticbeanstalk:hostmanager', + Value: 'true' + }, + { + OptionName: 'StreamLogs', + Namespace: 'aws:elasticbeanstalk:cloudwatch:logs', + Value: 'false' + } + ] + }, + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'true' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'AccessLogsS3Enabled', + Value: 'false' + }, + { + OptionName: 'LogPublicationControl', + Namespace: 'aws:elasticbeanstalk:hostmanager', + Value: 'false' + }, + { + OptionName: 'StreamLogs', + Namespace: 'aws:elasticbeanstalk:cloudwatch:logs', + Value: 'false' + } + ] + } +]; + +const createCache = (environments, configurationSettings) => { + if (environments.length) var environmentArn = environments[0].EnvironmentArn; + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + data: environments + }, + }, + describeConfigurationSettings: { + 'us-east-1': { + [environmentArn]: { + data: { + ConfigurationSettings: configurationSettings + } + } + }, + }, + }, + }; +}; + + +const createErrorCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + err: { + message: 'error describing environments' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('environmentPersistentLogs', function () { + describe('run', function () { + + it('should PASS if unable to get application environments', function (done) { + const cache = createCache([]); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to get configuration settings', function (done) { + const cache = createCache([environments[0]],[]); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if persistent logs are not enabled for application environment', function (done) { + const cache = createCache([environments[1]], [configurationSettings[1]]); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if persistent logs are enabled for application environment', function (done) { + const cache = createCache([environments[0]], [configurationSettings[0]]); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to get environments', function (done) { + const cache = createNullCache(); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching environments', function (done) { + const cache = createErrorCache(); + environmentPersistentLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/elasticbeanstalk/managedPlatformUpdates.js b/plugins/aws/elasticbeanstalk/managedPlatformUpdates.js new file mode 100644 index 000000000..d3c7207c6 --- /dev/null +++ b/plugins/aws/elasticbeanstalk/managedPlatformUpdates.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ElasticBeanstalk Managed Platform Updates', + category: 'ElasticBeanstalk', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures ElasticBeanstalk applications are configured to use managed updates.', + more_info: 'Environments for an application should be configured to allow platform managed updates.', + link: 'https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/environment-platform-update-managed.html', + recommended_action: 'Update the environment to enable managed updates.', + apis: ['ElasticBeanstalk:describeEnvironments', 'ElasticBeanstalk:describeConfigurationSettings'], + realtime_triggers: ['elasticbeanstalk:CreateEnvironment', 'elasticbeanstalk:UpdateEnvironment', 'elasticbeanstalk:TerminateEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + + async.each(regions.elasticbeanstalk, function(region, rcb){ + var describeEnvironments = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeEnvironments', region]); + + if (!describeEnvironments) return rcb(); + + if (describeEnvironments.err || !describeEnvironments.data) { + helpers.addResult(results, 3, + 'Unable to query for application environments', region); + return rcb(); + } + + if (!describeEnvironments.data.length) { + helpers.addResult(results, 0, + 'No application environments found', region); + return rcb(); + } + + async.each(describeEnvironments.data, function(environment, ecb){ + var resource = environment.EnvironmentArn; + var describeConfigurationSettings = helpers.addSource(cache, source, ['elasticbeanstalk', 'describeConfigurationSettings', region, environment.EnvironmentArn]); + + if (!describeConfigurationSettings || + describeConfigurationSettings.err || + !describeConfigurationSettings.data || + !describeConfigurationSettings.data.ConfigurationSettings) { + helpers.addResult(results, 3, + 'Unable to query for environment configuration settings', + region, resource); + return ecb(); + } + + if (!describeConfigurationSettings.data.ConfigurationSettings.length) { + helpers.addResult(results, 2, 'No environment configuration settings found', region, resource); + return ecb(); + } + + var found = false; + + for (var p in describeConfigurationSettings.data.ConfigurationSettings) { + var param = describeConfigurationSettings.data.ConfigurationSettings[p]; + + if (!param.OptionSettings) continue; + + for (var s in param.OptionSettings) { + var setting = param.OptionSettings[s]; + + if (setting.Namespace && setting.Namespace === 'aws:elasticbeanstalk:managedactions' && + setting.OptionName && setting.OptionName === 'ManagedActionsEnabled') { + found = true; + if (setting.Value && setting.Value === 'true') { + helpers.addResult(results, 0, + 'Managed platform updates for environment: ' + environment.EnvironmentName + ' are enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Managed platform updates for environment: ' + environment.EnvironmentName + ' are not enabled', + region, resource); + } + } + } + } + + if (!found) { + helpers.addResult(results, 2, + 'Managed platform updates for environment: ' + environment.EnvironmentName + ' are not enabled', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elasticbeanstalk/managedPlatformUpdates.spec.js b/plugins/aws/elasticbeanstalk/managedPlatformUpdates.spec.js new file mode 100644 index 000000000..8edd8353c --- /dev/null +++ b/plugins/aws/elasticbeanstalk/managedPlatformUpdates.spec.js @@ -0,0 +1,221 @@ +var expect = require('chai').expect; +var managedPlatformUpdates = require('./managedPlatformUpdates'); + +const environments = [ + { + "EnvironmentName": "Akhtar681-env-2", + "EnvironmentId": "e-bucuvmfa4a", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.167.147.57", + "CNAME": "Akhtar681-env-2.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T17:02:36.060Z", + "DateUpdated": "2020-08-22T17:05:59.178Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-2" + }, + { + "EnvironmentName": "Akhtar681-env-1", + "EnvironmentId": "e-3bb85da33w", + "ApplicationName": "akhtar-681", + "VersionLabel": "Sample Application", + "SolutionStackName": "64bit Amazon Linux 2 v3.1.0 running Python 3.7", + "PlatformArn": "arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0", + "EndpointURL": "54.92.177.151", + "CNAME": "Akhtar681-env-1.eba-g3c99pdr.us-east-1.elasticbeanstalk.com", + "DateCreated": "2020-08-22T16:08:13.720Z", + "DateUpdated": "2020-08-22T16:52:06.836Z", + "Status": "Ready", + "AbortableOperationInProgress": false, + "Health": "Green", + "HealthStatus": "Ok", + "Tier": { + "Name": "WebServer", + "Type": "Standard", + "Version": "1.0" + }, + "EnvironmentLinks": [], + "EnvironmentArn": "arn:aws:elasticbeanstalk:us-east-1:123456654321:environment/akhtar-681/Akhtar681-env-1" + } +]; + +const configurationSettings = [ + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'false' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'InstanceRefreshEnabled', + Value: 'true' + } + ] + }, + { + SolutionStackName: '64bit Amazon Linux 2 v3.1.0 running Python 3.7', + PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Python 3.7 running on 64bit Amazon Linux 2/3.1.0', + ApplicationName: 'akhtar-681', + EnvironmentName: 'Akhtar681-env-1', + DeploymentStatus: 'deployed', + DateCreated: '2020-08-22T16:08:12.000Z', + DateUpdated: '2020-08-22T16:50:53.000Z', + OptionSettings: [ + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ManagedActionsEnabled', + Value: 'true' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'PreferredStartTime', + Value: 'Sat:03:00' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions', + OptionName: 'ServiceRoleForManagedUpdates', + Value: 'arn:aws:iam::123456654321:role/aws-elasticbeanstalk-service-role' + }, + { + Namespace: 'aws:elasticbeanstalk:managedactions:platformupdate', + OptionName: 'InstanceRefreshEnabled', + Value: 'false' + } + ] + } +]; + +const createCache = (environments, configurationSettings) => { + if (environments.length) var environmentArn = environments[0].EnvironmentArn; + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + data: environments + }, + }, + describeConfigurationSettings: { + 'us-east-1': { + [environmentArn]: { + data: { + ConfigurationSettings: configurationSettings + } + } + }, + }, + }, + }; +}; + + +const createErrorCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': { + err: { + message: 'error describing environments' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elasticbeanstalk: { + describeEnvironments: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('managedPlatformUpdates', function () { + describe('run', function () { + + it('should PASS if unable to get application environments', function (done) { + const cache = createCache([]); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to get configuration settings', function (done) { + const cache = createCache([environments[0]],[]); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if managed platform updates are not enabled for application environment', function (done) { + const cache = createCache([environments[0]], [configurationSettings[0]]); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if managed platform updates are enabled for application environment', function (done) { + const cache = createCache([environments[1]], [configurationSettings[1]]); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to get environments', function (done) { + const cache = createNullCache(); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching environments', function (done) { + const cache = createErrorCache(); + managedPlatformUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elastictranscoder/jobOutputsEncrypted.js b/plugins/aws/elastictranscoder/jobOutputsEncrypted.js new file mode 100644 index 000000000..f267efdbb --- /dev/null +++ b/plugins/aws/elastictranscoder/jobOutputsEncrypted.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Elastic Transcoder Job Outputs Encrypted', + category: 'Elastic Transcoder', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Elastic Transcoder jobs have encryption enabled to encrypt your data before saving on S3.', + more_info: 'Amazon Elastic Transcoder jobs saves th result output on S3. If you don\'t configure encryption parameters, these job will save the file unencrypted. ' + + 'You should enabled encryption for output files and use customer-managed keys for encryption in order to gain more granular control over encryption/decryption process', + recommended_action: 'Enable encryption for Elastic Transcoder job outputs', + link: 'https://docs.aws.amazon.com/elastictranscoder/latest/developerguide/encryption.html', + apis: ['ElasticTranscoder:listPipelines', 'ElasticTranscoder:listJobsByPipeline'], + realtime_triggers: ['elastictranscoder:CreatePipeline', 'elastictranscoder:UpdatePipeline', 'elastictranscoder:DeletePipeline'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elastictranscoder, function(region, rcb){ + var listPipelines = helpers.addSource(cache, source, + ['elastictranscoder', 'listPipelines', region]); + + if (!listPipelines) return rcb(); + + if (listPipelines.err || !listPipelines.data) { + helpers.addResult(results, 3, + `Unable to list Elastic Transcoder pipelines: ${helpers.addError(listPipelines)}`, region); + return rcb(); + } + + if (!listPipelines.data.length) { + helpers.addResult(results, 0, + 'No Elastic Transcoder pipelines found', region); + return rcb(); + } + + for (let pipeline of listPipelines.data) { + if (!pipeline.Id) continue; + + let pipelineJobs = helpers.addSource(cache, source, + ['elastictranscoder', 'listJobsByPipeline', region, pipeline.Id]); + + if (!pipelineJobs || pipelineJobs.err || !pipelineJobs.data || !pipelineJobs.data.Jobs) { + helpers.addResult(results, 3, + `Unable to list Elastic Transcoder jobs for pipeline: ${helpers.addError(pipelineJobs)}`, region, pipeline.Arn); + continue; + } + + if (!pipelineJobs.data.Jobs.length) { + helpers.addResult(results, 0, + 'No Elastic Transcoder jobs found for pipeline', region, pipeline.Arn); + continue; + } + + for (let job of pipelineJobs.data.Jobs) { + if (job.Status && job.Status.toUpperCase() == 'ERROR') { + helpers.addResult(results, 0, + 'Job status is "Error"', region, job.Arn); + } else { + if (job.Outputs && job.Outputs.length) var unencryptedOutputs = job.Outputs.find(output => !output.Encryption); + else helpers.addResult(results, 0, 'Job does not have any outputs', region, job.Arn); + + if (unencryptedOutputs) { + helpers.addResult(results, 2, + 'Job does not encryption enabled for one or more outputs', region, job.Arn); + } else { + helpers.addResult(results, 0, + 'Job has encryption enabled for outputs', region, job.Arn); + } + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elastictranscoder/jobOutputsEncrypted.spec.js b/plugins/aws/elastictranscoder/jobOutputsEncrypted.spec.js new file mode 100644 index 000000000..d251b3baf --- /dev/null +++ b/plugins/aws/elastictranscoder/jobOutputsEncrypted.spec.js @@ -0,0 +1,357 @@ +var expect = require('chai').expect; +var jobOutputsEncrypted = require('./jobOutputsEncrypted'); + +const listPipelines = [ + { + "Id": "1636527154039-wkwqg1", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:pipeline/1636527154039-wkwqg1", + "Name": "khulnasoft-pipeline", + "Status": "Active", + "InputBucket": "khulnasoftbucket", + "OutputBucket": "khulnasoftbucket", + "Role": "arn:aws:iam::000011112222:role/Elastic_Transcoder_Default_Role", + "AwsKmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Notifications": { + "Progressing": "", + "Completed": "", + "Warning": "", + "Error": "" + }, + "ContentConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + }, + "ThumbnailConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + } + } +]; + +const listJobsByPipeline = [ + { + "Id": "1636545275565-xwma1v", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:job/1636545275565-xwma1v", + "PipelineId": "1636530122589-ptkx1n", + "Input": { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).file", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + }, + "Inputs": [ + { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).file", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + } + ], + "Output": { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + }, + "Outputs": [ + { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "Encryption": { + "Mode": "s3-aws-kms", + "Key": null, + "KeyMd5": null, + "InitializationVector": null + }, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + } + ], + "OutputKeyPrefix": "data/", + "Playlists": [], + "Status": "Progressing", + "Timing": { + "SubmitTimeMillis": 1636545275576, + "StartTimeMillis": 1636545276637, + "FinishTimeMillis": 1636545278699 + } + }, + { + "Id": "1636545275565-xwma1v", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:job/1636545275565-xwma1v", + "PipelineId": "1636530122589-ptkx1n", + "Input": { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).file", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + }, + "Inputs": [ + { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).file", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + } + ], + "Output": { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + }, + "Outputs": [ + { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + } + ], + "OutputKeyPrefix": "data/", + "Playlists": [], + "Status": "Progressing", + "Timing": { + "SubmitTimeMillis": 1636545275576, + "StartTimeMillis": 1636545276637, + "FinishTimeMillis": 1636545278699 + } + }, + { + "Id": "1636545275565-xwma1v", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:job/1636545275565-xwma1v", + "PipelineId": "1636530122589-ptkx1n", + "Input": { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).csv", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + }, + "Inputs": [ + { + "Key": "data/part-00000-5094adae-2612-4fd3-acfb-e6490cdc70e1-c000 (1).csv", + "FrameRate": null, + "Resolution": null, + "AspectRatio": null, + "Interlaced": null, + "Container": null + } + ], + "Output": { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + }, + "Outputs": [ + { + "Id": "1", + "Key": "file.mp4", + "ThumbnailPattern": "", + "Rotate": "auto", + "PresetId": "1351620000001-000010", + "SegmentDuration": null, + "Status": "Error", + "StatusDetail": "4000 92362f1a-fc60-4064-a219-a885d9e15bd2: Amazon Elastic Transcoder could not interpret the media file.", + "Duration": null, + "Width": null, + "Height": null, + "FrameRate": null, + "FileSize": null, + "DurationMillis": null, + "Watermarks": [], + "AppliedColorSpaceConversion": null + } + ], + "OutputKeyPrefix": "data/", + "Playlists": [], + "Status": "Error", + "Timing": { + "SubmitTimeMillis": 1636545275576, + "StartTimeMillis": 1636545276637, + "FinishTimeMillis": 1636545278699 + } + } +]; + +const createCache = (pipelines, jobs, pipelinesErr, jobsErr) => { + var pipelineId = (pipelines && pipelines.length) ? pipelines[0].Id : null; + return { + elastictranscoder: { + listPipelines: { + 'us-east-1': { + err: pipelinesErr, + data: pipelines + }, + }, + listJobsByPipeline: { + 'us-east-1': { + [pipelineId]: { + data: { + Jobs: jobs + }, + err: jobsErr + } + } + } + }, + }; +}; + +describe('jobOutputsEncrypted', function () { + describe('run', function () { + it('should PASS if Elastic Transcoder pipeline has no jobs', function (done) { + const cache = createCache(listPipelines, []); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Elastic Transcoder pipeline job has encryption enabled for outputs', function (done) { + const cache = createCache(listPipelines, [listJobsByPipeline[0]]); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Elastic Transcoder pipeline job status is "Error"', function (done) { + const cache = createCache(listPipelines, [listJobsByPipeline[2]]); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Elastic Transcoder pipeline job does not encryption enabled for one or more outputs', function (done) { + const cache = createCache(listPipelines, [listJobsByPipeline[1]]); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Elastic Transcoder pipelines found', function (done) { + const cache = createCache([]); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Elastic Transcoder pipelines', function (done) { + const cache = createCache(null, [], { message: "Unable to list Elastic Transcoder pipelines" }); + jobOutputsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elastictranscoder/pipelineDataEncrypted.js b/plugins/aws/elastictranscoder/pipelineDataEncrypted.js new file mode 100644 index 000000000..5ab0ca1a6 --- /dev/null +++ b/plugins/aws/elastictranscoder/pipelineDataEncrypted.js @@ -0,0 +1,106 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Elastic Transcoder Pipeline Data Encrypted', + category: 'Elastic Transcoder', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Elastic Transcoder pipelines have encryption enabled with desired encryption level to encrypt your data.', + more_info: 'Amazon Elastic Transcoder pipelines use AWS-managed KMS keys to encrypt your data.' + + 'You should use customer-managed keys in order to gain more granular control over encryption/decryption process', + recommended_action: 'Modify Elastic Transcoder pipelines encryption settings to use custom KMS key', + link: 'https://docs.aws.amazon.com/elastictranscoder/latest/developerguide/encryption.html', + apis: ['ElasticTranscoder:listPipelines', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + transcoder_pipeline_encryption_level: { + name: 'Elastic Transcoder Pipeline Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awscmk', + } + }, + realtime_triggers: ['elastictranscoder:CreatePipeline', 'elastictranscoder:UpdatePipeline', 'elastictranscoder:DeletePipeline'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.transcoder_pipeline_encryption_level || this.settings.transcoder_pipeline_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.elastictranscoder, function(region, rcb){ + var listPipelines = helpers.addSource(cache, source, + ['elastictranscoder', 'listPipelines', region]); + + if (!listPipelines) return rcb(); + + if (listPipelines.err || !listPipelines.data) { + helpers.addResult(results, 3, + `Unable to list Elastic Transcoder pipelines: ${helpers.addError(listPipelines)}`, region); + return rcb(); + } + + if (!listPipelines.data.length) { + helpers.addResult(results, 0, + 'No Elastic Transcoder pipelines found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let pipeline of listPipelines.data) { + if (!pipeline.Arn) continue; + + let resource = pipeline.Arn; + if (pipeline.AwsKmsKeyArn) { + var kmsKeyId = pipeline.AwsKmsKeyArn.split('/')[1] ? pipeline.AwsKmsKeyArn.split('/')[1] : pipeline.AwsKmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, pipeline.AwsKmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Elastic Transcoder Pipeline is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Elastic Transcoder Pipeline is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elastictranscoder/pipelineDataEncrypted.spec.js b/plugins/aws/elastictranscoder/pipelineDataEncrypted.spec.js new file mode 100644 index 000000000..6c18ee21a --- /dev/null +++ b/plugins/aws/elastictranscoder/pipelineDataEncrypted.spec.js @@ -0,0 +1,212 @@ +var expect = require('chai').expect; +var pipelineDataEncrypted = require('./pipelineDataEncrypted'); + +const listPipelines = [ + { + "Id": "1636527154039-wkwqg1", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:pipeline/1636527154039-wkwqg1", + "Name": "khulnasoft-pipeline", + "Status": "Active", + "InputBucket": "khulnasoftbucket", + "OutputBucket": "khulnasoftbucket", + "Role": "arn:aws:iam::000011112222:role/Elastic_Transcoder_Default_Role", + "AwsKmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Notifications": { + "Progressing": "", + "Completed": "", + "Warning": "", + "Error": "" + }, + "ContentConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + }, + "ThumbnailConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + } + }, + { + "Id": "1636527154039-wkwqg1", + "Arn": "arn:aws:elastictranscoder:us-east-1:000011112222:pipeline/1636527154039-wkwqg1", + "Name": "khulnasoft-pipeline", + "Status": "Active", + "InputBucket": "khulnasoftbucket", + "OutputBucket": "khulnasoftbucket", + "Role": "arn:aws:iam::000011112222:role/Elastic_Transcoder_Default_Role", + "Notifications": { + "Progressing": "", + "Completed": "", + "Warning": "", + "Error": "" + }, + "ContentConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + }, + "ThumbnailConfig": { + "Bucket": "khulnasoftbucket", + "StorageClass": "Standard", + "Permissions": [ + { + "GranteeType": "Canonical", + "Grantee": "000011112222", + "Access": [ + "Read", + "ReadAcp", + "WriteAcp", + "FullControl" + ] + } + ] + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (pipelines, keys, describeKey, pipelinesErr, keysErr, describeKeyErr) => { + var keyId = (pipelines && pipelines.length && pipelines[0].AwsKmsKeyArn) ? pipelines[0].AwsKmsKeyArn.split('/')[1] : null; + return { + elastictranscoder: { + listPipelines: { + 'us-east-1': { + err: pipelinesErr, + data: pipelines + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('pipelineDataEncrypted', function () { + describe('run', function () { + it('should PASS if Elastic Transcoder pipeline is encrypted with desired encryption level', function (done) { + const cache = createCache([listPipelines[0]], listKeys, describeKey[0]); + pipelineDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Elastic Transcoder pipeline is not encrypted with desired encyption level', function (done) { + const cache = createCache([listPipelines[1]], listKeys, describeKey[0]); + pipelineDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Elastic Transcoder pipelines found', function (done) { + const cache = createCache([]); + pipelineDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Elastic Transcoder pipelines', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Elastic Transcoder pipelines" }); + pipelineDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listPipelines, null, null, null, { message: "Unable to list KMS keys" }); + pipelineDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/appTierElbSecurity.js b/plugins/aws/elb/appTierElbSecurity.js new file mode 100644 index 000000000..60e1ded27 --- /dev/null +++ b/plugins/aws/elb/appTierElbSecurity.js @@ -0,0 +1,159 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier ELB Security Policy', + category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that AWS App-Tier ELBs are using the latest predefined security policies.', + more_info: 'AWS App-Tier ELBs should use the latest predefined security policies to secure the connection between client and ELB.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html', + recommended_action: 'Update App-Tier ELB reference security policy to latest predefined security policy to secure the connection between client and ELB', + apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerPolicies', 'ELB:describeTags', 'STS:getCallerIdentity'], + settings: { + elb_app_tier_tag_key: { + name: 'App-Tier Tag Key', + description: 'App-Tier tag key used by ELBs to indicate App-Tier groups', + regex: '^.*$', + default: '' + }, + latest_security_policies: { + name: 'ELB Latest Predefined Security Policy Versions', + description: 'A comma-delimited list of security policies that indicates the latest predefined security policy versions', + regex: '[a-zA-Z0-9-,]', + default: 'ELBSecurityPolicy-2016-08,ELBSecurityPolicy-TLS-1-2-2017-01,ELBSecurityPolicy-TLS-1-1-2017-01' + } + }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancerListeners','elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancerListeners'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + elb_app_tier_tag_key : settings.elb_app_tier_tag_key || this.settings.elb_app_tier_tag_key.default, + latest_security_policies : settings.latest_security_policies || this.settings.latest_security_policies.default + }; + + if (!config.elb_app_tier_tag_key.length) return callback(null, results, source); + + config.latest_security_policies = config.latest_security_policies.split(','); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`, + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + + var appTierElbFound = false; + async.each(describeLoadBalancers.data, function(lb, cb){ + var describeTags = helpers.addSource(cache, source, + ['elb', 'describeTags', region, lb.LoadBalancerName]); + + if (!describeTags || + describeTags.err || + !describeTags.data || + !describeTags.data.TagDescriptions) { + helpers.addResult(results, 3, + `Unable to query load balancer tags for ELB "${lb.LoadBalancerName}": ${helpers.addError(describeLoadBalancerPolicies)}`, + region, resource); + return cb(); + } + + var appTierTag = false; + describeTags.data.TagDescriptions.forEach(function(Tags) { + if (Tags && Tags.Tags) { + for (var i in Tags.Tags){ + var td = Tags.Tags[i]; + if (td.Key && td.Key === config.elb_app_tier_tag_key) { + appTierTag = true; + appTierElbFound = true; + break; + } + } + } + }); + + if (appTierTag) { + if (!lb.LoadBalancerName || !lb.DNSName) return cb(); + + var resource = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + var describeLoadBalancerPolicies = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancerPolicies', region, lb.DNSName]); + + if (!describeLoadBalancerPolicies || + (!describeLoadBalancerPolicies.err && !describeLoadBalancerPolicies.data)) return cb(); + + if (describeLoadBalancerPolicies.err || + !describeLoadBalancerPolicies.data) { + helpers.addResult(results, 3, + `Unable to query policies for ELB "${lb.LoadBalancerName}": ${helpers.addError(describeLoadBalancerPolicies)}`, + region, resource); + return cb(); + } + + var insecurePolicy = false; + var securityPolicyFound = false; + describeLoadBalancerPolicies.data.PolicyDescriptions.forEach(function(policyDesc) { + if (policyDesc.PolicyAttributeDescriptions) { + for (var policyAttrDesc of policyDesc.PolicyAttributeDescriptions) { + if (policyAttrDesc.AttributeName && policyAttrDesc.AttributeName === 'Reference-Security-Policy') { + securityPolicyFound = true; + if (!config.latest_security_policies.includes(policyAttrDesc.AttributeValue)) { + insecurePolicy = true; + break; + } + } + } + } + }); + + if (securityPolicyFound) { + if (!insecurePolicy) { + helpers.addResult(results, 0, + `ELB "${lb.LoadBalancerName}" is using latest predefined security policy`, + region, resource); + } else { + helpers.addResult(results, 2, + `ELB "${lb.LoadBalancerName}" is not using latest predefined security policy`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `ELB "${lb.LoadBalancerName}" is not using any reference security policy`, + region, resource); + } + } + + cb(); + }, function(){ + if (!appTierElbFound) { + helpers.addResult(results, 0, + 'No App-Tier ELB found', region); + } + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elb/appTierElbSecurity.spec.js b/plugins/aws/elb/appTierElbSecurity.spec.js new file mode 100644 index 000000000..229bc25f4 --- /dev/null +++ b/plugins/aws/elb/appTierElbSecurity.spec.js @@ -0,0 +1,363 @@ +var expect = require('chai').expect; +const appTierElbSecurity = require('./appTierElbSecurity'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84-2", + "DNSName": "test-84-2-31381010.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-2-31381010.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803", + "ELBSecurityPolicy-2016-08" + ] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTPS:443/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T18:13:00.580Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "TCP", + "LoadBalancerPort": 82, + "InstanceProtocol": "TCP", + "InstancePort": 82 + }, + "PolicyNames": [] + }, + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const describeLoadBalancerPolicies = [ + { + "ResponseMetadata": { "RequestId": '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + "PolicyDescriptions": [ + { + "PolicyName": 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + "PolicyTypeName": 'SSLNegotiationPolicyType', + "PolicyAttributeDescriptions": [ + { + "AttributeName": 'Reference-Security-Policy', + "AttributeValue": 'ELBSecurityPolicy-2016-08' + }, + ], + }, + ], + }, + { + "ResponseMetadata": { "RequestId": '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + "PolicyDescriptions": [ + { + "PolicyName": 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + "PolicyTypeName": 'SSLNegotiationPolicyType', + "PolicyAttributeDescriptions": [ + { + "AttributeName": 'Reference-Security-Policy', + "AttributeValue": 'ELBSecurityPolicy-2015-08' + }, + ], + }, + ], + }, + { + "ResponseMetadata": { "RequestId": '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + "PolicyDescriptions": [ + { + "PolicyName": 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + "PolicyTypeName": 'SSLNegotiationPolicyType', + "PolicyAttributeDescriptions": [ + { + "AttributeName": 'Policy', + "AttributeValue": 'ELBSecurityPolicy-2015-08' + }, + ], + }, + ], + }, +]; + +const describeTags = [ + { + "TagDescriptions": [ + { + "LoadBalancerName": "test-84-2", + "Tags": [ + { + "Key": "app_tier", + "Value": "app_tier" + } + ] + } + ] + }, + { + "TagDescriptions": [ + { + "LoadBalancerName": "test-84-2", + "Tags": [ + { + "Key": "web_tier", + "Value": "web_tier" + } + ] + } + ] + } +]; + +const createCache = (elb, tags, policy) => { + var lbDnsName = (elb && elb.length) ? elb[0].DNSName : null; + var loadBalancerName = (elb && elb.length) ? elb[0].LoadBalancerName : null; + return { + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + describeTags: { + 'us-east-1': { + [loadBalancerName]: { + data: tags + }, + }, + }, + describeLoadBalancerPolicies: { + 'us-east-1': { + [lbDnsName]: { + data: policy + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing load balancers' + }, + }, + }, + describeTags: { + 'us-east-1': { + err: { + message: 'error describing load balancer tags' + }, + }, + }, + describeLoadBalancerPolicies: { + 'us-east-1': { + err: { + message: 'error describing load balancer policies' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeTags: { + 'us-east-1': null, + }, + describeLoadBalancerPolicies: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('appTierElbSecurity', function () { + describe('run', function () { + it('should PASS if app-tier load balancer is using latest predefined policies', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTags[0], describeLoadBalancerPolicies[0]); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if app-tier load balancer is not using latest predefined policies', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTags[0], describeLoadBalancerPolicies[1]); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if app-tier load balancer is not using any reference security policy', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTags[0], describeLoadBalancerPolicies[2]); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no App-Tier ELB found', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTags[1], describeLoadBalancerPolicies[2]); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancers', function (done) { + const cache = createErrorCache(); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancer tags', function (done) { + const cache = createCache([describeLoadBalancers[0]], null, null); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + appTierElbSecurity.run(cache, { elb_app_tier_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if elb app-tier tag key is not provided in settings', function (done) { + const cache = createNullCache(); + appTierElbSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/classicELBInUse.js b/plugins/aws/elb/classicELBInUse.js new file mode 100644 index 000000000..56b5d858c --- /dev/null +++ b/plugins/aws/elb/classicELBInUse.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Classic Load Balancers In Use', + category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that HTTP/HTTPS applications are using Application Load Balancer instead of Classic Load Balancer.', + more_info: 'HTTP/HTTPS applications should use Application Load Balancer instead of Classic Load Balancer for cost and web traffic distribution optimization.', + link: 'https://aws.amazon.com/elasticloadbalancing/features/', + recommended_action: 'Detach Classic Load balancer from HTTP/HTTPS applications and attach Application Load Balancer to those applications', + apis: ['ELB:describeLoadBalancers', 'STS:getCallerIdentity'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`, region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(lb, cb){ + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + if (!lb.ListenerDescriptions.length) { + helpers.addResult(results, 0, + `ELB "${lb.LoadBalancerName}" is not using any listeners`, + region, elbArn); + return cb(); + } + + let found; + + if (lb.Instances && lb.Instances.length) { + found = lb.ListenerDescriptions.find(listener => listener.Listener && (listener.Listener.Protocol === 'HTTP' || listener.Listener.Protocol === 'HTTPS')); + } + + if (!found) { + helpers.addResult(results, 0, + `Classic load balancer "${lb.LoadBalancerName}" is not in use`, + region, elbArn); + } else { + helpers.addResult(results, 2, + `Classic load balancer "${lb.LoadBalancerName}" is in use`, + region, elbArn); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elb/classicELBInUse.spec.js b/plugins/aws/elb/classicELBInUse.spec.js new file mode 100644 index 000000000..317033c7b --- /dev/null +++ b/plugins/aws/elb/classicELBInUse.spec.js @@ -0,0 +1,215 @@ +const expect = require('chai').expect; +var classicELBInUse = require('./classicELBInUse'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-83", + "DNSName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "11112222333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2021-01-22T03:49:32.680000+00:00", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-83", + "DNSName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "11112222333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2021-01-22T03:49:32.680000+00:00", + "Scheme": "internet-facing" + } +] + +const createCache = (describeLoadBalancers, describeLoadBalancersErr) => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: describeLoadBalancersErr, + data: describeLoadBalancers + } + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111122223333' + } + } + } + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + } + } + }; +}; + +describe('classicELBInUse', function () { + describe('run', function () { + it('should PASS if no HTTP/HTTPS application using Classic load balancer found', function (done) { + const cache = createCache([describeLoadBalancers[0]], null); + classicELBInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if HTTP/HTTPS application is using Classic load balancer', function (done) { + const cache = createCache([describeLoadBalancers[1]], null); + classicELBInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + classicELBInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancers', function (done) { + const cache = createCache(null, { message: "Unable to describe load balancers" }); + classicELBInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + classicELBInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/connectionDrainingEnabled.js b/plugins/aws/elb/connectionDrainingEnabled.js new file mode 100644 index 000000000..bc30f7005 --- /dev/null +++ b/plugins/aws/elb/connectionDrainingEnabled.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB Connection Draining Enabled', + category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that AWS ELBs have connection draining enabled.', + more_info: 'Connection draining should be used to ensure that a Classic Load Balancer stops sending requests to instances that are de-registering or unhealthy, while keeping the existing connections open.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-conn-drain.html', + recommended_action: 'Update ELBs to enable connection draining', + apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerAttributes', 'STS:getCallerIdentity'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`, + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(lb, cb){ + if (!lb.DNSName) return cb(); + + var resource = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + var describeLoadBalancerAttributes = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancerAttributes', region, lb.DNSName]); + + if (!describeLoadBalancerAttributes || + describeLoadBalancerAttributes.err || + !describeLoadBalancerAttributes.data || + !describeLoadBalancerAttributes.data.LoadBalancerAttributes) { + helpers.addResult(results, 3, + `Unable to query load balancer attributes: ${helpers.addError(describeLoadBalancerAttributes)}`, + region, resource); + return cb(); + } + + if (describeLoadBalancerAttributes.data.LoadBalancerAttributes.ConnectionDraining && + describeLoadBalancerAttributes.data.LoadBalancerAttributes.ConnectionDraining.Enabled) { + helpers.addResult(results, 0, + `ELB "${lb.LoadBalancerName}" has connection draining enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `ELB "${lb.LoadBalancerName}" does not have connection draining enabled`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elb/connectionDrainingEnabled.spec.js b/plugins/aws/elb/connectionDrainingEnabled.spec.js new file mode 100644 index 000000000..8cff728cb --- /dev/null +++ b/plugins/aws/elb/connectionDrainingEnabled.spec.js @@ -0,0 +1,207 @@ +const expect = require('chai').expect; +var connectionDrainingEnabled = require('./connectionDrainingEnabled'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "akd-41", + "DNSName": "akd-41-132269405.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "akd-41-132269405.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2021-01-24T04:40:45.520000+00:00", + "Scheme": "internet-facing" + } +]; + +const describeLoadBalancerAttributes = [ + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": true + }, + "AccessLog": { + "Enabled": false + }, + "ConnectionDraining": { + "Enabled": true, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + } + }, + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": true + }, + "AccessLog": { + "Enabled": false + }, + "ConnectionDraining": { + "Enabled": false, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + } + } +]; + + + +const createCache = (describeLoadBalancers, describeLoadBalancerAttributes, describeLoadBalancersErr, describeLoadBalancerAttributesErr) => { + var dnsName = (describeLoadBalancers && describeLoadBalancers.length) ? describeLoadBalancers[0].DNSName : null; + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: describeLoadBalancersErr, + data: describeLoadBalancers + } + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [dnsName]: { + err: describeLoadBalancerAttributesErr, + data: describeLoadBalancerAttributes + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + } + } + }; +}; + +describe('connectionDrainingEnabled', function () { + describe('run', function () { + it('should PASS if ELB has connection draining enabled', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[0], null, null); + connectionDrainingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if ELB does not have connection draining enabled', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[1], null, null); + connectionDrainingEnabled.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + connectionDrainingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancers', function (done) { + const cache = createCache(null, null, { message: "Unable to describe load balancers" }, null); + connectionDrainingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query load balancer attributes', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[1], null, { message: "Unable to describe load balancer attributes" }); + connectionDrainingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + connectionDrainingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/crosszoneLoadBalancing.js b/plugins/aws/elb/crosszoneLoadBalancing.js new file mode 100644 index 000000000..7430b2227 --- /dev/null +++ b/plugins/aws/elb/crosszoneLoadBalancing.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB Cross-Zone Load Balancing', + category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that AWS ELBs have cross-zone load balancing enabled.', + more_info: 'AWS ELBs should have cross-zone load balancing enabled to distribute the traffic evenly across the registered instances in all enabled Availability Zones.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html', + recommended_action: 'Update AWS ELB to enable cross zone load balancing', + apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerAttributes', 'STS:getCallerIdentity'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:AttachLoadBalancerToSubnets', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`, region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(lb, cb){ + var resource = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + var describeLoadBalancerAttributes = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancerAttributes', region, lb.DNSName]); + + if (!describeLoadBalancerAttributes || + !describeLoadBalancerAttributes.data || + describeLoadBalancerAttributes.err || + !describeLoadBalancerAttributes.data || + !describeLoadBalancerAttributes.data.LoadBalancerAttributes) { + helpers.addResult(results, 3, + `Unable to query load balancer attributes: ${helpers.addError(describeLoadBalancerAttributes)}`, + region, resource); + return cb(); + } + + if (describeLoadBalancerAttributes.data.LoadBalancerAttributes.CrossZoneLoadBalancing && + describeLoadBalancerAttributes.data.LoadBalancerAttributes.CrossZoneLoadBalancing.Enabled) { + helpers.addResult(results, 0, + `AWS ELB "${lb.LoadBalancerName}" has cross-zone load balancing enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `AWS ELB "${lb.LoadBalancerName}" does not have cross-zone load balancing enabled`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elb/crosszoneLoadBalancing.spec.js b/plugins/aws/elb/crosszoneLoadBalancing.spec.js new file mode 100644 index 000000000..15a487181 --- /dev/null +++ b/plugins/aws/elb/crosszoneLoadBalancing.spec.js @@ -0,0 +1,223 @@ +var expect = require('chai').expect; +const crosszoneLoadBalancing = require('./crosszoneLoadBalancing'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const describeLoadBalancerAttributes = [ + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": true + }, + "AccessLog": { + "Enabled": true + }, + "ConnectionDraining": { + "Enabled": true, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + }, + }, + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": false + }, + "AccessLog": { + "Enabled": false + }, + "ConnectionDraining": { + "Enabled": true, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + }, + } +]; + +const createCache = (describeLoadBalancers, describeLoadBalancerAttributes, describeLoadBalancersErr, describeLoadBalancerAttributesErr) => { + var dnsName = (describeLoadBalancers && describeLoadBalancers.length) ? describeLoadBalancers[0].DNSName : null; + + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: describeLoadBalancersErr, + data: describeLoadBalancers + } + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [dnsName]: { + err: describeLoadBalancerAttributesErr, + data: describeLoadBalancerAttributes + } + } + }, + } + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + } + } + }; +}; + +describe('crosszoneLoadBalancing', function () { + describe('run', function () { + it('should PASS if AWS ELB has cross zone load balancing enabled', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[0]); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if AWS ELB does not have cross zone load balancing enabled', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[1]); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancer attributes', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[1], { message: 'Unable to query for load balancers' }); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancer attributes', function (done) { + const cache = createCache([describeLoadBalancers[0]], null, null, { message: 'Unable to query for load balancers attributes' }); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe load balancers response is not found', function (done) { + const cache = createNullCache(); + crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/elbHasTags.js b/plugins/aws/elb/elbHasTags.js new file mode 100644 index 000000000..f0319c698 --- /dev/null +++ b/plugins/aws/elb/elbHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB Has Tags', + category: 'ELB', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that ELBs have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_AddTags.html', + recommended_action: 'Modify ELB and add tags.', + apis: ['ELB:describeLoadBalancers', 'ResourceGroupsTaggingAPI:getResources', 'STS:getCallerIdentity'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:AddTags', 'elasticloadbalancing:RemoveTags', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for load balancers: ' + helpers.addError(describeLoadBalancers), region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + const arnList = []; + for (let lb of describeLoadBalancers.data){ + if (!lb.LoadBalancerName) continue; + + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + arnList.push(elbArn); + } + helpers.checkTags(cache, 'ElasticLoadbalancing', arnList, region, results, settings); + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elb/elbHasTags.spec.js b/plugins/aws/elb/elbHasTags.spec.js new file mode 100644 index 000000000..643ac01bc --- /dev/null +++ b/plugins/aws/elb/elbHasTags.spec.js @@ -0,0 +1,173 @@ +const expect = require('chai').expect; +var elbHasTags = require('./elbHasTags'); + +const getResources = [ + { + "ResourceARN": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/test-83", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/test-83", + "Tags": [{key: 'value'}], + } +] + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-83", + "DNSName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "11112222333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2021-01-22T03:49:32.680000+00:00", + "Scheme": "internet-facing" + }, +] + +const createCache = (describeLoadBalancers, rgData) => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: null, + data: describeLoadBalancers + } + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111122223333' + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + } + } + }; +}; + +describe('elbHasTags', function () { + describe('run', function () { + it('should PASS if classic load balancer have tags.', function (done) { + const cache = createCache([describeLoadBalancers[0]], [getResources[1]]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ElasticLoadbalancing has tags'); + done(); + }); + }); + + it('should FAIL if classic load balancer have no tags.', function (done) { + const cache = createCache([describeLoadBalancers[0]], [getResources[0]]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ElasticLoadbalancing does not have any tags'); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No load balancers found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancers', function (done) { + const cache = createCache(null, { message: "Unable to describe load balancers" }); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for load balancers'); + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources from group tagging api'); + done(); + }; + + const cache = createCache( + [describeLoadBalancers[0]],null); + elbHasTags.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/elbHttpsOnly.js b/plugins/aws/elb/elbHttpsOnly.js index 35e5c7e42..d1d2e10dc 100644 --- a/plugins/aws/elb/elbHttpsOnly.js +++ b/plugins/aws/elb/elbHttpsOnly.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELB HTTPS Only', category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures ELBs are configured to only accept' + ' connections on HTTPS ports.', more_info: 'For maximum security, ELBs can be configured to only'+ @@ -14,6 +16,12 @@ module.exports = { link: 'http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-security-policy-options.html', recommended_action: 'Remove non-HTTPS listeners from load balancer.', apis: ['ELB:describeLoadBalancers', 'STS:getCallerIdentity'], + remediation_description: 'All HTTP Listeners will be deleted', + remediation_min_version: '202011062139', + apis_remediate: ['ELB:describeLoadBalancers'], + actions: {remediate: ['ELB:deleteLoadBalancerListeners'], rollback: ['ELB:createLoadBalancerListeners']}, + permissions: {remediate: ['elasticloadbalancing:DeleteLoadBalancerListeners'], rollback: ['elasticloadbalancing:CreateLoadBalancerListeners']}, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancerListeners','elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancerListeners'], run: function(cache, settings, callback) { var results = []; @@ -21,6 +29,7 @@ module.exports = { var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); async.each(regions.elb, function(region, rcb){ @@ -31,41 +40,46 @@ module.exports = { if (describeLoadBalancers.err || !describeLoadBalancers.data) { helpers.addResult(results, 3, - 'Unable to query for load balancers: ' + helpers.addError(describeLoadBalancers), region); + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`, region); return rcb(); } if (!describeLoadBalancers.data.length) { - helpers.addResult(results, 0, 'No load balancers present', region); + helpers.addResult(results, 0, 'No load balancers found', region); return rcb(); } async.each(describeLoadBalancers.data, function(lb, cb){ // arn:aws:elasticloadbalancing:region:account-id:loadbalancer/name - var elbArn = 'arn:aws:elasticloadbalancing:' + - region + ':' + accountId + ':' + - 'loadbalancer/' + lb.LoadBalancerName; + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + if (!lb.ListenerDescriptions.length) { + helpers.addResult(results, 0, + `ELB "${lb.LoadBalancerName}" is not using any listeners`, + region, elbArn); + return cb(); + } // loop through listeners - var non_https_listner = []; + var non_https_listeners = []; lb.ListenerDescriptions.forEach(function(listener){ // if it is not https add errors to results - if (listener.Listener.Protocol != 'HTTPS'){ - non_https_listner.push( - listener.Listener.Protocol + ' / ' + - listener.Listener.LoadBalancerPort + if (listener.Listener.Protocol !== 'HTTPS' && listener.Listener.Protocol !== 'SSL'){ + non_https_listeners.push( + `${listener.Listener.Protocol}/${listener.Listener.LoadBalancerPort}` ); } - }); - if (non_https_listner){ - //helpers.addResult(results, 2, non_https_listner.join(', '), region); - var msg = 'The following listeners are not using HTTPS-only: '; + + if (non_https_listeners.length) { helpers.addResult( - results, 2, msg + non_https_listner.join(', '), region, elbArn - ); - }else{ - helpers.addResult(results, 0, 'No listeners found', region, elbArn); + results, 2, + `Elb "${lb.LoadBalancerName}" is using these listeners ${non_https_listeners.join(', ')} without HTTPS protocol`, + region, elbArn); + } else { + helpers.addResult(results, 0, + `ELB "${lb.LoadBalancerName}" is using listeners with HTTPS protocol only`, + region, elbArn); } cb(); }, function(){ @@ -74,5 +88,80 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'elbHttpsOnly'; + var elbName; + + if (resource && resource.length) { + elbName = resource.split('/')[1]; + config.region = resource.split(':')[3]; + } else { + return callback('No resource to remediate'); + } + var describeLoadBalancers; + if (cache['elb'] && + cache['elb']['describeLoadBalancers'] && + cache['elb']['describeLoadBalancers'][config.region] && + cache['elb']['describeLoadBalancers'][config.region].data) { + describeLoadBalancers = cache['elb']['describeLoadBalancers'][config.region].data; + } else { + return callback('Unable to query for ELB'); + } + + var failingLoadBalancer = describeLoadBalancers.find(loadBalancer => { + if (loadBalancer.LoadBalancerName === elbName) { + return loadBalancer; + } + }); + + var failingIps = []; + if (failingLoadBalancer && failingLoadBalancer.ListenerDescriptions && failingLoadBalancer.ListenerDescriptions.length) { + failingLoadBalancer.ListenerDescriptions.forEach(listener => { + if (listener.Listener.Protocol === 'HTTP') { + failingIps.push(listener.Listener.LoadBalancerPort); + } + }); + } else { + return callback(`No listeners found for ELB: ${resource}`); + } + + if (!failingIps.length) { + return callback('No failing listeners found'); + } + + var params = { + 'LoadBalancerName': elbName, + 'LoadBalancerPorts': failingIps + }; + + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Listener': 'Deleted', + 'LoadBalancerName': elbName, + 'LoadBalancerPorts': failingIps + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'LISTENERS_DELETED', + 'LoadBalancerName': elbName, + 'LoadBalancerPorts': failingIps + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; diff --git a/plugins/aws/elb/elbHttpsOnly.spec.js b/plugins/aws/elb/elbHttpsOnly.spec.js new file mode 100644 index 000000000..3f704dd4f --- /dev/null +++ b/plugins/aws/elb/elbHttpsOnly.spec.js @@ -0,0 +1,272 @@ +var expect = require('chai').expect; +const elbHttpsOnly = require('./elbHttpsOnly'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "TCP", + "LoadBalancerPort": 82, + "InstanceProtocol": "TCP", + "InstancePort": 82 + }, + "PolicyNames": [] + }, + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const createCache = (elb) => { + return { + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing load balancers' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbHttpsOnly', function () { + describe('run', function () { + it('should PASS if ELB is using listeners with HTTPS protocol only', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ELB is using listeners without HTTPS protocol', function (done) { + const cache = createCache([describeLoadBalancers[1]]); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if ELB is not using any listeners', function (done) { + const cache = createCache([describeLoadBalancers[2]]); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancers', function (done) { + const cache = createErrorCache(); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + elbHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/elbLoggingEnabled.js b/plugins/aws/elb/elbLoggingEnabled.js index 4a6097d46..cb8619d5c 100644 --- a/plugins/aws/elb/elbLoggingEnabled.js +++ b/plugins/aws/elb/elbLoggingEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELB Logging Enabled', category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures load balancers have request logging enabled.', more_info: 'Logging requests to ELB endpoints is a helpful way ' + 'of detecting and investigating potential attacks, ' + @@ -12,7 +14,7 @@ module.exports = { 'analysis.', link: 'http://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html', recommended_action: 'Enable ELB request logging', - apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerAttributes'], + apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerAttributes', 'STS:getCallerIdentity'], compliance: { hipaa: 'HIPAA requires access logging to be enabled for the auditing ' + 'of services serving HIPAA data. All ELBs providing this access ' + @@ -21,12 +23,17 @@ module.exports = { pci: 'PCI requires logging of all network access to environments containing ' + 'cardholder data. Enable ELB logs to log these network requests.' }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + async.each(regions.elb, function(region, rcb){ var describeLoadBalancers = helpers.addSource(cache, source, ['elb', 'describeLoadBalancers', region]); @@ -45,22 +52,25 @@ module.exports = { } async.each(describeLoadBalancers.data, function(lb, cb){ + // arn:aws:elasticloadbalancing:region:account-id:loadbalancer/name + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + // loop through listeners var describeLoadBalancerAttributes = helpers.addSource(cache, source, ['elb', 'describeLoadBalancerAttributes', region, lb.DNSName]); - if ( describeLoadBalancerAttributes.data && + if (describeLoadBalancerAttributes && + describeLoadBalancerAttributes.data && describeLoadBalancerAttributes.data.LoadBalancerAttributes && describeLoadBalancerAttributes.data.LoadBalancerAttributes.AccessLog) { var accessLog = describeLoadBalancerAttributes.data.LoadBalancerAttributes.AccessLog; - //console.log(lb.DNSName) if (accessLog.Enabled){ helpers.addResult(results, 0, - 'Logging enabled for ' + lb.DNSName, region, lb.DNSName); + 'Logging enabled for ' + lb.DNSName, region, elbArn); } else { helpers.addResult(results, 2, - 'Logging not enabled for ' + lb.DNSName, region, lb.DNSName); + 'Logging not enabled for ' + lb.DNSName, region, elbArn); } } cb(); diff --git a/plugins/aws/elb/elbLoggingEnabled.spec.js b/plugins/aws/elb/elbLoggingEnabled.spec.js new file mode 100644 index 000000000..395293624 --- /dev/null +++ b/plugins/aws/elb/elbLoggingEnabled.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const elbLoggingEnabled = require('./elbLoggingEnabled'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "TCP", + "LoadBalancerPort": 82, + "InstanceProtocol": "TCP", + "InstancePort": 82 + }, + "PolicyNames": [] + }, + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const describeLoadBalancerAttributes = [ + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": true + }, + "AccessLog": { + "Enabled": true + }, + "ConnectionDraining": { + "Enabled": true, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + }, + }, + { + "LoadBalancerAttributes": { + "CrossZoneLoadBalancing": { + "Enabled": true + }, + "AccessLog": { + "Enabled": false + }, + "ConnectionDraining": { + "Enabled": true, + "Timeout": 300 + }, + "ConnectionSettings": { + "IdleTimeout": 60 + }, + "AdditionalAttributes": [ + { + "Key": "elb.http.desyncmitigationmode", + "Value": "defensive" + } + ] + }, + } +]; + +const createCache = (elb, attributes) => { + var dnsName = (elb && elb.length && elb[0].DNSName) ? elb[0].DNSName : null; + return { + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [dnsName]: { + data: attributes + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing load balancers' + }, + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + err: { + message: 'error describing load balancer attributes' + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('elbLoggingEnabled', function () { + describe('run', function () { + it('should PASS if logging is enabled for ELB', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[0]); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if logging is not enabled for ELB', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerAttributes[1]); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + + it('should PASS if no load balancers present', function (done) { + const cache = createCache([]); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancers', function (done) { + const cache = createErrorCache(); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if describe load balancer attributes response not found', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/elbNoInstances.js b/plugins/aws/elb/elbNoInstances.js index 433d63175..dcdd75758 100644 --- a/plugins/aws/elb/elbNoInstances.js +++ b/plugins/aws/elb/elbNoInstances.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELB No Instances', category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', description: 'Detects ELBs that have no backend instances attached', more_info: 'All ELBs should have backend server resources. ' + 'Those without any are consuming costs without providing ' + @@ -12,6 +14,18 @@ module.exports = { link: 'http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-backend-instances.html', recommended_action: 'Delete old ELBs that no longer have backend resources.', apis: ['ELB:describeLoadBalancers', 'STS:getCallerIdentity'], + remediation_description: 'ELBs that have no instances attached will be deleted.', + remediation_min_version: '202101071800', + apis_remediate: ['ELB:describeLoadBalancers'], + actions: { + remediate: ['ELB:deleteLoadBalancer'], + rollback: ['ELB:createLoadBalancer'] + }, + permissions: { + remediate: ['elasticloadbalancing:DeleteLoadBalancer'], + rollback: ['elasticloadbalancing:CreateLoadBalancer'] + }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer'], run: function(cache, settings, callback) { var results = []; @@ -19,6 +33,7 @@ module.exports = { var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); async.each(regions.elb, function(region, rcb){ @@ -40,14 +55,14 @@ module.exports = { async.each(describeLoadBalancers.data, function(lb, cb){ // arn:aws:elasticloadbalancing:region:account-id:loadbalancer/name - var elbArn = 'arn:aws:elasticloadbalancing:' + + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:` + region + ':' + accountId + ':' + 'loadbalancer/' + lb.LoadBalancerName; - if (lb.Instances.length){ + if (lb.Instances.length) { helpers.addResult(results, 0, 'ELB has ' + lb.Instances.length + ' backend instances', region, elbArn); - }else{ - helpers.addResult(results, 1, 'ELB does not have backend instances', region, elbArn); + } else { + helpers.addResult(results, 2, 'ELB does not have backend instances', region, elbArn); } cb(); }, function(){ @@ -56,5 +71,43 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'elbNoInstances'; + var lbNameArr = resource.split(':'); + var lbName = lbNameArr[5].substring(lbNameArr[5].lastIndexOf('/') + 1); + + config.region = lbNameArr[3]; + + // create the params necessary for the remediation + var params = { + 'LoadBalancerName': lbName + }; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Deletion': 'NOT_DELETED', + 'ELB': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'DELETED', + 'ELB': resource + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; diff --git a/plugins/aws/elb/elbNoInstances.spec.js b/plugins/aws/elb/elbNoInstances.spec.js new file mode 100644 index 000000000..5159243dd --- /dev/null +++ b/plugins/aws/elb/elbNoInstances.spec.js @@ -0,0 +1,229 @@ +var expect = require('chai').expect; +const elbNoInstances = require('./elbNoInstances'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356026abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "TCP", + "LoadBalancerPort": 82, + "InstanceProtocol": "TCP", + "InstancePort": 82 + }, + "PolicyNames": [] + }, + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const createCache = (elb) => { + return { + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing load balancers' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbNoInstances', function () { + describe('run', function () { + it('should PASS if ELB has backend instances', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbNoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ELB does not have backend instances', function (done) { + const cache = createCache([describeLoadBalancers[1]]); + elbNoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no load balancers present', function (done) { + const cache = createCache([]); + elbNoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancers', function (done) { + const cache = createErrorCache(); + elbNoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + elbNoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elb/elbUnhealthyInstances.js b/plugins/aws/elb/elbUnhealthyInstances.js new file mode 100644 index 000000000..cdf0f10c9 --- /dev/null +++ b/plugins/aws/elb/elbUnhealthyInstances.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB Unhealthy Instances', + category: 'ELB', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS ELBs have healthy instances attached', + more_info: 'ELBs should have healthy instances attached to ensure proper load balancing and availability. The status of the instances that are healthy should be InService.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-healthchecks.html#check-instance-health', + recommended_action: 'Investigate and resolve the health issues of the instances attached to the ELB.', + apis: ['ELB:describeLoadBalancers', 'ELB:describeInstanceHealth', 'STS:getCallerIdentity'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:RegisterInstancesWithLoadBalancer', 'elasticloadbalancing:DeregisterInstancesWithLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for load balancers: ' + helpers.addError(describeLoadBalancers), region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No load balancers present', region); + return rcb(); + } + + describeLoadBalancers.data.forEach(function(lb) { + + if (!lb.LoadBalancerName) return; + + var elbArn = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; + + var describeInstanceHealth = helpers.addSource(cache, source, + ['elb', 'describeInstanceHealth', region, lb.DNSName]); + + if (!describeInstanceHealth) return; + + if (describeInstanceHealth.err || !describeInstanceHealth.data) { + helpers.addResult(results, 3, + `Unable to query for instance health: ${helpers.addError(describeInstanceHealth)}`, region, elbArn); + return; + } + + var instanceStates = describeInstanceHealth.data.InstanceStates; + + var unhealthyInstances = instanceStates.filter(function(instance) { + return instance.State === 'OutOfService'; + }); + + if (unhealthyInstances.length > 0) { + var length = unhealthyInstances.length; + helpers.addResult(results, 2, `ELB has ${length} unhealthy instance(s)`, region, elbArn); + } else { + helpers.addResult(results, 0, 'ELB does not have unhealthy instance', region, elbArn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/elb/elbUnhealthyInstances.spec.js b/plugins/aws/elb/elbUnhealthyInstances.spec.js new file mode 100644 index 000000000..0330350ae --- /dev/null +++ b/plugins/aws/elb/elbUnhealthyInstances.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +var elbUnhealthyInstances = require('./elbUnhealthyInstances'); + + const describeLoadBalancersData = [ + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601842068416" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [ + { + "InstanceId": "i-093267d7a579c4bee", + "InstanceType": "t2.micro", + "AvailabilityZone": "us-east-1a", + "LifecycleState": "InService", + "HealthStatus": "Healthy", + "LaunchTemplate": { + "LaunchTemplateId": "lt-0f1f6b356027abc86", + "LaunchTemplateName": "auto-scaling-template", + "Version": "1" + }, + "ProtectedFromScaleIn": false + } + ], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; +const describeInstanceHealthData = [ + + { + InstanceStates: [ + { + InstanceId: 'instance-id', + State: 'InService', + "ReasonCode": "Instance", + "Description": "N/A" + } + ] +}, +{ + InstanceStates: [ + { + InstanceId: 'instance-id', + State: 'OutOfService', + "ReasonCode": "Instance", + "Description": "Instance has failed at least the UnhealthyThreshold number of health checks consecutively." + } + ] +}, +]; + +const createCache = (describeLoadBalancersData, describeInstanceHealthData) => { + var dnsName = (describeLoadBalancersData && describeLoadBalancersData.length) ? describeLoadBalancersData[0].DNSName : null; + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + data: describeLoadBalancersData + } + }, + describeInstanceHealth: { + 'us-east-1': { + [dnsName]: { + data: describeInstanceHealthData + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error fetching load balancers' + }, + }, + }, + describeInstanceHealth: { + 'us-east-1': { + 'elb-dns-name': { + err: { + message: 'error fetching instance health' + }, + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + }, + describeInstanceHealth: { + 'us-east-1': null + } + } + }; +}; + + +describe('elbUnhealthyInstances', function () { + describe('run', function () { + it('should return UNKNOWN if unable to query for load balancers', function (done) { + const cache = createErrorCache(); + elbUnhealthyInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for load balancers:'); + done(); + }); + }); + + it('should return PASS if no load balancers are present', function (done) { + const cache = createCache([]); + elbUnhealthyInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No load balancers present'); + done(); + }); + }); + + it('should return FAIL if ELB has unhealthy instances', function (done) { + const cache = createCache([describeLoadBalancersData[0]], describeInstanceHealthData[1]); + elbUnhealthyInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should return PASS if ELB does not have unhealthy instances', function (done) { + const cache = createCache([describeLoadBalancersData[0]], describeInstanceHealthData[0]); + elbUnhealthyInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ELB does not have unhealthy instance'); + done(); + }); + }); + + it('should not return anything if describeLoadBalancers response is not found', function (done) { + const cache = createNullCache(); + elbUnhealthyInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/elb/insecureCiphers.js b/plugins/aws/elb/insecureCiphers.js index ea918ade8..f3745aced 100644 --- a/plugins/aws/elb/insecureCiphers.js +++ b/plugins/aws/elb/insecureCiphers.js @@ -78,11 +78,13 @@ var badCiphers = [ module.exports = { title: 'Insecure Ciphers', category: 'ELB', + domain: 'Content Delivery', + severity: 'Medium', description: 'Detect use of insecure ciphers on ELBs', more_info: 'Various security vulnerabilities have rendered several ciphers insecure. Only the recommended ciphers should be used.', link: 'http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-security-policy-options.html', recommended_action: 'Update your ELBs to use the recommended cipher suites', - apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerPolicies'], + apis: ['ELB:describeLoadBalancers', 'ELB:describeLoadBalancerPolicies', 'STS:getCallerIdentity'], compliance: { hipaa: 'All HIPAA data should be encrypted in transit. Using secure ciphers ' + 'is a critical aspect of this requirement. Using outdated ciphers with ' + @@ -91,12 +93,17 @@ module.exports = { pci: 'PCI requires secure transfer of cardholder data. It does not permit SSL or TLS ' + 'version 1.0. ELB listeners should be configured for TLS v1.2.' }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:CreateLoadBalancerPolicy', 'elasticloadbalancing:DeleteLoadBalancerPolicy', 'elasticloadbalancing:DeleteLoadBalancer'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + async.each(regions.elb, function(region, rcb){ var describeLoadBalancers = helpers.addSource(cache, source, ['elb', 'describeLoadBalancers', region]); @@ -116,6 +123,7 @@ module.exports = { async.each(describeLoadBalancers.data, function(lb, cb){ if (!lb.DNSName) return cb(); + var resource = `arn:${awsOrGov}:elasticloadbalancing:${region}:${accountId}:loadbalancer/${lb.LoadBalancerName}`; var describeLoadBalancerPolicies = helpers.addSource(cache, source, ['elb', 'describeLoadBalancerPolicies', region, lb.DNSName]); @@ -128,7 +136,7 @@ module.exports = { helpers.addResult(results, 3, 'Unable to query load balancer policies for ELB: ' + lb.LoadBalancerName + ': ' + helpers.addError(describeLoadBalancerPolicies), - region, lb.DNSName); + region, resource); return cb(); } @@ -150,11 +158,11 @@ module.exports = { if (elbBad.length) { helpers.addResult(results, 1, 'ELB: ' + lb.LoadBalancerName + ' uses insecure protocols or ciphers: ' + elbBad.join(', '), - region, lb.DNSName); + region, resource); } else { helpers.addResult(results, 0, 'ELB: ' + lb.LoadBalancerName + ' uses secure protocols and ciphers', - region, lb.DNSName); + region, resource); } } diff --git a/plugins/aws/elb/insecureCiphers.spec.js b/plugins/aws/elb/insecureCiphers.spec.js new file mode 100644 index 000000000..281be70f3 --- /dev/null +++ b/plugins/aws/elb/insecureCiphers.spec.js @@ -0,0 +1,297 @@ +var expect = require('chai').expect; +const insecureCiphers = require('./insecureCiphers'); + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-84-2", + "DNSName": "test-84-2-31381010.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-2-31381010.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTPS", + "LoadBalancerPort": 443, + "InstanceProtocol": "HTTPS", + "InstancePort": 443, + "SSLCertificateId": "arn:aws:iam::111122223333:server-certificate/ExampleCertificate" + }, + "PolicyNames": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803" + ] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [ + "AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803", + "ELBSecurityPolicy-2016-08" + ] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTPS:443/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T18:13:00.580Z", + "Scheme": "internet-facing" + }, + { + "LoadBalancerName": "test-84", + "DNSName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneName": "test-84-1988801627.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "TCP", + "LoadBalancerPort": 82, + "InstanceProtocol": "TCP", + "InstancePort": 82 + }, + "PolicyNames": [] + }, + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "AvailabilityZones": [ + "us-east-1f", + "us-east-1e", + "us-east-1d", + "us-east-1c", + "us-east-1b", + "us-east-1a" + ], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "HealthCheck": { + "Target": "HTTP:80/index.html", + "Interval": 30, + "Timeout": 5, + "UnhealthyThreshold": 2, + "HealthyThreshold": 10 + }, + "SourceSecurityGroup": { + "OwnerAlias": "111122223333", + "GroupName": "default" + }, + "SecurityGroups": [ + "sg-aa941691" + ], + "CreatedTime": "2020-10-01T17:50:43.330Z", + "Scheme": "internet-facing" + } +]; + +const describeLoadBalancerPolicies = [ + { + ResponseMetadata: { RequestId: '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + PolicyDescriptions: [ + { + PolicyName: 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + PolicyTypeName: 'SSLNegotiationPolicyType', + PolicyAttributeDescriptions: [ + { + AttributeName: 'Reference-Security-Policy', + AttributeValue: 'ELBSecurityPolicy-2016-08' + } + ], + }, + ], + }, + { + ResponseMetadata: { RequestId: '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + PolicyDescriptions: [ + { + PolicyName: 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + PolicyTypeName: 'SSLNegotiationPolicyType', + PolicyAttributeDescriptions: [ + { + AttributeName: 'Reference-Security-Policy', + AttributeValue: 'ELBSecurityPolicy-2015-08' + }, + { + AttributeName: 'EXP-KRB5-RC4-MD5', + AttributeValue: 'true' + } + ], + }, + ], + }, + { + ResponseMetadata: { RequestId: '4344d6c8-5047-4610-94a4-70ca01fdffc6' }, + PolicyDescriptions: [ + { + PolicyName: 'AWSConsole-SSLNegotiationPolicy-test-84-2-1601575981803', + PolicyTypeName: 'SSLNegotiationPolicyType', + PolicyAttributeDescriptions: [ + { + AttributeName: 'Policy', + AttributeValue: 'ELBSecurityPolicy-2015-08' + }, + ], + }, + ], + } +]; + +const createCache = (elb, policy) => { + var lbDnsName = (elb && elb.length)? elb[0].DNSName : null; + return { + elb:{ + describeLoadBalancers: { + 'us-east-1': { + data: elb + }, + }, + describeLoadBalancerPolicies: { + 'us-east-1': { + [lbDnsName]: { + data: policy + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing load balancers' + }, + }, + }, + describeLoadBalancerPolicies: { + 'us-east-1': { + err: { + message: 'error describing load balancer policies' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerPolicies: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('insecureCiphers', function () { + describe('run', function () { + it('should PASS if ELB uses secure protocols and ciphers', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerPolicies[0]); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if ELB uses insecure protocols or ciphers', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeLoadBalancerPolicies[1]); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no load balancers present', function (done) { + const cache = createCache([]); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancers', function (done) { + const cache = createErrorCache(); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if describe load balancer policies response is not found', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + insecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2DeletionProtection.js b/plugins/aws/elbv2/elbv2DeletionProtection.js new file mode 100644 index 000000000..5a6b77a0c --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeletionProtection.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Deletion Protection', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures ELBv2 load balancers are configured with deletion protection.', + more_info: 'ELBv2 load balancers should be configured with deletion protection to prevent accidental' + + 'deletion of live resources in production environments.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#deletion-protection', + recommended_action: 'Update ELBv2 load balancers to use deletion protection to prevent accidental deletion', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeLoadBalancerAttributes'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancers: ' + helpers.addError(describeLoadBalancers), + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application/Network load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(elb, cb){ + var resource = elb.LoadBalancerArn; + + var elbv2Attributes = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancerAttributes', region, elb.DNSName]); + + if (!elbv2Attributes || elbv2Attributes.err || !elbv2Attributes.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancer attributes: ' + helpers.addError(elbv2Attributes), + region, resource); + return cb(); + } + + if (!elbv2Attributes.data.Attributes || !elbv2Attributes.data.Attributes.length){ + helpers.addResult(results, 2, + 'Application/Network load balancer attributes not found', + region, resource); + return cb(); + } + + var found = false; + + elbv2Attributes.data.Attributes.forEach(attribute => { + if (attribute.Key && attribute.Key === 'deletion_protection.enabled') { + found = true; + if (attribute.Value && attribute.Value === 'true') { + helpers.addResult(results, 0, + 'Load balancer :' + elb.LoadBalancerName + ': has deletion protection enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Load balancer :' + elb.LoadBalancerName + ': does not have deletion protection enabled', + region, resource); + } + } + }); + + if (!found) { + helpers.addResult(results, 2, 'Deletion protection not found', region, resource); + } + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2DeletionProtection.spec.js b/plugins/aws/elbv2/elbv2DeletionProtection.spec.js new file mode 100644 index 000000000..589ad6809 --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeletionProtection.spec.js @@ -0,0 +1,212 @@ +var expect = require('chai').expect; +const elbv2DeletionProtection = require('./elbv2DeletionProtection'); + +const loadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/test-lb-43/8e680c7bace394a7", + "DNSName": "test-lb-43-148538634.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-08-30T22:55:21.030Z", + "LoadBalancerName": "test-lb-43", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "IpAddressType": "ipv4" + } +]; + +const loadBalancerAttributes = [ + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "deletion_protection.enabled", + "Value": "true" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + }, + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "deletion_protection.enabled", + "Value": "false" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + } +]; + +const createCache = (elbv2, attribute) => { + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [elbv2[0].DNSName]: { + data: attribute + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2DeletionProtection', function () { + describe('run', function () { + it('should PASS if load balancer has deletion protection enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[0]); + elbv2DeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if load balancer does not have deletion protection enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[1]); + elbv2DeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no load balancer attributes found', function (done) { + const cache = createCache([loadBalancers[0]],[]); + elbv2DeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if error while describing load balancers', function (done) { + const cache = createErrorCache(); + elbv2DeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe load balancers', function (done) { + const cache = createNullCache(); + elbv2DeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.js b/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.js new file mode 100644 index 000000000..64d34ab0d --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Deprecated SSL Policies', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Elbv2 listeners are configured to use the latest predefined security policies.', + more_info: 'Insecure or deprecated security policies can expose the client and the load balancer to various vulnerabilities.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html', + recommended_action: 'Modify ELBv2 listeners with the latest predefined AWS security policies.', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeListeners'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:CreateListener', 'elasticloadbalancing:ModifyListener', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteListener'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var deprecatedALBPolicies = [ + 'ELBSecurityPolicy-2016-08', + 'ELBSecurityPolicy-TLS-1-0-2015-04', + 'ELBSecurityPolicy-TLS-1-1-2017-01', + 'ELBSecurityPolicy-FS-2018-06', + 'ELBSecurityPolicy-FS-1-1-2019-08', + 'ELBSecurityPolicy-2015-05' + ]; + + var deprecatedNLBPolicies = [ + 'ELBSecurityPolicy-TLS13-1-1-2021-06', + 'ELBSecurityPolicy-TLS13-1-0-2021-06', + 'ELBSecurityPolicy-FS-1-1-2019-08', + 'ELBSecurityPolicy-FS-2018-06', + 'ELBSecurityPolicy-TLS-1-1-2017-01', + 'ELBSecurityPolicy-2016-08', + 'ELBSecurityPolicy-TLS-1-0-2015-04', + 'ELBSecurityPolicy-2015-05' + ]; + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`,region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application or Network load balancers found', region); + return rcb(); + } + + for (var alb of describeLoadBalancers.data){ + if (!alb.DNSName) continue; + + var depPolicies = []; + var SslPolicy = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, alb.DNSName]); + + if (describeListeners.err || !describeListeners.data || !describeListeners.data.Listeners) { + helpers.addResult(results, 3, `Unable to query for Listeners: ${helpers.addError(describeListeners)}`, region, alb.LoadBalancerArn); + continue; + } + + if (!describeListeners.data.Listeners.length) { + helpers.addResult(results, 0, 'No Listeners found for load balancer', region, alb.LoadBalancerArn); + continue; + } + + describeListeners.data.Listeners.forEach(function(listener){ + if (listener.SslPolicy) { + SslPolicy = true; + if (deprecatedALBPolicies.includes(listener.SslPolicy) || deprecatedNLBPolicies.includes(listener.SslPolicy)) { + depPolicies.push(listener.SslPolicy); + } + } + }); + if (!SslPolicy){ + helpers.addResult(results, 0,'No SSL policies found for load balancer', region, alb.LoadBalancerArn); + } else if (depPolicies && depPolicies.length){ + helpers.addResult(results, 2, 'Load balancer listeners are using these deprecated policies : ' + depPolicies.join(', '), region, alb.LoadBalancerArn); + } else { + helpers.addResult(results, 0, 'Load balancer listeners are using current SSL policies', region, alb.LoadBalancerArn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.spec.js b/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.spec.js new file mode 100644 index 000000000..ebf89c48f --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeprecatedSslPolicies.spec.js @@ -0,0 +1,199 @@ +var expect = require('chai').expect; +const elbv2DeprecatedSslPolicies = require('./elbv2DeprecatedSslPolicies.js'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + } +]; + +const describeListeners = [ + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 443, + "Protocol": "HTTPS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}], + "SslPolicy": "ELBSecurityPolicy-2015-05", + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + ], + }, + { + "Listeners":[] + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Protocol": "TLS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}], + "SslPolicy": "ELBSecurityPolicy-FS-1-2-Res-2020-10", + } + ], + }, + +]; + +const createCache = (elbv2, listeners) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeListeners: { + 'us-east-1': { + [lbDnsName]: { + data: listeners + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Application Load balancers' + }, + }, + }, + describeListeners: { + 'us-east-1': { + err: { + message: 'error describing load balancer listeners' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeListeners: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2DeprecatedSslPolicies', function () { + describe('run', function () { + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Application or Network load balancers found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for load balancers'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Listeners', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for Listeners'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Listeners found', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[2]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No Listeners found for load balancer'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no SSL policies found', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[1]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No SSL policies found for load balancer'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if listeners on Elbv2 are using deprecated policies', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[0]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('are using these deprecated policies'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if all listeners on Elbv2 are using current policies', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[3]); + elbv2DeprecatedSslPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('listeners are using current SSL policies'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2DeregistrationDelay.js b/plugins/aws/elbv2/elbv2DeregistrationDelay.js new file mode 100644 index 000000000..d699e5d96 --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeregistrationDelay.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Deregistration Delay', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that AWS ELBv2 target groups have deregistration delay configured.', + more_info: 'AWS ELBv2 target groups should have deregistration delay configured to help in-flight requests to the target to complete.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html#deregistration-delay', + recommended_action: 'Update ELBv2 target group attributes and set the deregistration delay value', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', 'ELBv2:describeTargetGroupAttributes'], + realtime_triggers: ['elasticloadbalancing:CreateTargetGroup', 'elasticloadbalancing:ModifyTargetGroupAttributes', 'elasticloadbalancing:DeleteTargetGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region]); + + if (!describeTargetGroups) return rcb(); + + if (describeTargetGroups.err || !describeTargetGroups.data) { + helpers.addResult(results, 3, + `Unable to query Application/Network load balancer target groups: ${helpers.addError(describeTargetGroups)}`, + region); + return rcb(); + } + + if (!describeTargetGroups.data.length) { + helpers.addResult(results, 0, + 'No Application/Network load balancer target groups found', region); + return rcb(); + } + + async.each(describeTargetGroups.data, function(targetGroup, tcb){ + var resource = targetGroup.TargetGroupArn; + var describeTargetGroupAttributes = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroupAttributes', region, resource]); + + if (!describeTargetGroupAttributes || describeTargetGroupAttributes.err || !describeTargetGroupAttributes.data + || !describeTargetGroupAttributes.data.Attributes) { + helpers.addResult(results, 3, + `Unable to query for Application/Network load balancer target group attributes: ${helpers.addError(describeTargetGroupAttributes)}`, + region, resource); + return tcb(); + } + + var deregistationDelayConfigured = false; + + if (describeTargetGroupAttributes.data.Attributes.length) { + for (var attribute of describeTargetGroupAttributes.data.Attributes) { + if (attribute.Key && attribute.Key === 'deregistration_delay.timeout_seconds' && + attribute.Value && parseInt(attribute.Value) > 0) { + deregistationDelayConfigured = true; + break; + } + } + } + + if (deregistationDelayConfigured) { + helpers.addResult(results, 0, + `Application/Network load balancer target group "${targetGroup.TargetGroupName}" has deregistration delay configured`, + region, resource); + } else { + helpers.addResult(results, 2, + `Application/Network load balancer target group "${targetGroup.TargetGroupName}" does not have deregistration delay configured`, + region, resource); + } + + tcb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elbv2/elbv2DeregistrationDelay.spec.js b/plugins/aws/elbv2/elbv2DeregistrationDelay.spec.js new file mode 100644 index 000000000..3ad4303cb --- /dev/null +++ b/plugins/aws/elbv2/elbv2DeregistrationDelay.spec.js @@ -0,0 +1,179 @@ +const expect = require('chai').expect; +var elbv2DeregistrationDelay = require('./elbv2DeregistrationDelay'); + +const describeTargetGroups = [ + { + "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/temp-tg/fee5b45af37af625", + "TargetGroupName": "temp-tg", + "Protocol": "HTTP", + "Port": 80, + "VpcId": "vpc-99de2fe4", + "HealthCheckProtocol": "HTTP", + "HealthCheckPort": "traffic-port", + "HealthCheckEnabled": true, + "HealthCheckIntervalSeconds": 30, + "HealthCheckTimeoutSeconds": 5, + "HealthyThresholdCount": 5, + "UnhealthyThresholdCount": 2, + "HealthCheckPath": "/", + "Matcher": { + "HttpCode": "200" + }, + "LoadBalancerArns": [ + "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/akd-43/c87a998367b02304" + ], + "TargetType": "instance", + "ProtocolVersion": "HTTP1" + } +]; + +const describeTargetGroupAttributes = [ + { + "Attributes": [ + { + "Key": "stickiness.enabled", + "Value": "false" + }, + { + "Key": "deregistration_delay.timeout_seconds", + "Value": "300" + }, + { + "Key": "stickiness.type", + "Value": "lb_cookie" + }, + { + "Key": "stickiness.lb_cookie.duration_seconds", + "Value": "86400" + }, + { + "Key": "slow_start.duration_seconds", + "Value": "0" + }, + { + "Key": "load_balancing.algorithm.type", + "Value": "round_robin" + } + ] + }, + { + "Attributes": [ + { + "Key": "stickiness.enabled", + "Value": "false" + }, + { + "Key": "stickiness.type", + "Value": "lb_cookie" + }, + { + "Key": "stickiness.lb_cookie.duration_seconds", + "Value": "86400" + }, + { + "Key": "slow_start.duration_seconds", + "Value": "0" + }, + { + "Key": "load_balancing.algorithm.type", + "Value": "round_robin" + } + ] + } +]; + +const createCache = (describeTargetGroups, describeTargetGroupAttributes, describeTargetGroupsErr, describeTargetGroupAttributesErr) => { + var targetGroupArn = (describeTargetGroups && describeTargetGroups.length) ? describeTargetGroups[0].TargetGroupArn : null; + + return { + elbv2: { + describeTargetGroups: { + 'us-east-1': { + err: describeTargetGroupsErr, + data: describeTargetGroups + } + }, + describeTargetGroupAttributes: { + 'us-east-1': { + [targetGroupArn]: { + err: describeTargetGroupAttributesErr, + data: describeTargetGroupAttributes + } + } + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeTargetGroups: { + 'us-east-1': null + } + } + }; +}; + + +describe('elbv2DeregistrationDelay', function () { + describe('run', function () { + it('should PASS if ELBv2 target group has deregistration delay configured', function (done) { + const cache = createCache([describeTargetGroups[0]], describeTargetGroupAttributes[0], null, null); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if ELBv2 target group does not have deregistration delay configured', function (done) { + const cache = createCache([describeTargetGroups[0]], describeTargetGroupAttributes[1], null, null); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no ELBv2 target groups found', function (done) { + const cache = createCache([]); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for ELBv2 target groups', function (done) { + const cache = createCache([describeTargetGroups[0]], null, { message: "Unable to describe target groups" }, null); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for ELBv2 target group attributes', function (done) { + const cache = createCache([describeTargetGroups[0]], describeTargetGroupAttributes[0], null, { message: "Unable to describe target group attributes" }); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe target groups response is not found', function (done) { + const cache = createNullCache(); + elbv2DeregistrationDelay.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2HasTags.js b/plugins/aws/elbv2/elbv2HasTags.js new file mode 100644 index 000000000..70a3911ff --- /dev/null +++ b/plugins/aws/elbv2/elbv2HasTags.js @@ -0,0 +1,47 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Has Tags', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that ELBv2 load balancers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_AddTags.html', + recommended_action: 'Modify ELBv2 and add tags.', + apis: ['ELBv2:describeLoadBalancers', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:AddTags', 'elasticloadbalancing:RemoveTags', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancers: ' + helpers.addError(describeLoadBalancers), region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application/Network load balancers found', region); + return rcb(); + } + const arnList = []; + for (let lb of describeLoadBalancers.data){ + arnList.push(lb.LoadBalancerArn); + } + helpers.checkTags(cache, 'ElasticLoadbalancing', arnList, region, results, settings); + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elbv2/elbv2HasTags.spec.js b/plugins/aws/elbv2/elbv2HasTags.spec.js new file mode 100644 index 000000000..6840ccbcc --- /dev/null +++ b/plugins/aws/elbv2/elbv2HasTags.spec.js @@ -0,0 +1,152 @@ +const expect = require('chai').expect; +var elbHasTags = require('./elbv2HasTags'); + +const getResources = [ + { + "ResourceARN": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/test-83", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/test-83", + "Tags": [{key: 'value'}], + } +] + +const describeLoadBalancers = [ + { + "LoadBalancerName": "test-83", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/test-83", + "CanonicalHostedZoneName": "test-83-1735080548.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneNameID": "Z35SXDOTRQ7X7K", + "ListenerDescriptions": [ + { + "Listener": { + "Protocol": "HTTP", + "LoadBalancerPort": 80, + "InstanceProtocol": "HTTP", + "InstancePort": 80 + }, + "PolicyNames": [] + } + ], + "Policies": { + "AppCookieStickinessPolicies": [], + "LBCookieStickinessPolicies": [], + "OtherPolicies": [] + }, + "BackendServerDescriptions": [], + "Subnets": [ + "subnet-06aa0f60", + "subnet-673a9a46", + "subnet-6a8b635b", + "subnet-aac6b3e7", + "subnet-c21b84cc", + "subnet-e83690b7" + ], + "VPCId": "vpc-99de2fe4", + "Instances": [], + "CreatedTime": "2021-01-22T03:49:32.680000+00:00", + "Scheme": "internet-facing" + }, +] + +const createCache = (describeLoadBalancers, rgData) => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: null, + data: describeLoadBalancers + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null + } + } + }; +}; + +describe('elbHasTags', function () { + describe('run', function () { + it('should PASS if classic load balancer have tags.', function (done) { + const cache = createCache([describeLoadBalancers[0]], [getResources[1]]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ElasticLoadbalancing has tags'); + done(); + }); + }); + + it('should FAIL if classic load balancer have no tags.', function (done) { + const cache = createCache([describeLoadBalancers[0]], [getResources[0]]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ElasticLoadbalancing does not have any tags'); + done(); + }); + }); + + it('should PASS if no load balancers found', function (done) { + const cache = createCache([]); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Application/Network load balancers found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe load balancers', function (done) { + const cache = createCache(null, { message: "Unable to describe Application/Network load balancers" }); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Application/Network load balancers'); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + elbHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources from group tagging api'); + done(); + }; + + const cache = createCache( + [describeLoadBalancers[0]],null); + elbHasTags.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2HttpsOnly.js b/plugins/aws/elbv2/elbv2HttpsOnly.js index 15f4b5160..0b76b8268 100644 --- a/plugins/aws/elbv2/elbv2HttpsOnly.js +++ b/plugins/aws/elbv2/elbv2HttpsOnly.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELBv2 HTTPS Only', category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures ELBs are configured to only accept' + ' connections on HTTPS ports.', more_info: 'For maximum security, ELBs can be configured to only'+ @@ -14,6 +16,12 @@ module.exports = { link: 'http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-security-policy-options.html', recommended_action: 'Remove non-HTTPS listeners from load balancer.', apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeListeners'], + remediation_description: 'All HTTP Listeners will be deleted', + remediation_min_version: '202011062139', + apis_remediate: ['ELBv2:describeLoadBalancers','ELBv2:describeListeners'], + actions: {remediate: ['ELBv2:deleteListener'], rollback: ['ELBv2:createListener']}, + permissions: {remediate: ['elasticloadbalancing:DeleteListener'], rollback: ['elasticloadbalancing:CreateListener']}, + realtime_triggers: ['elasticloadbalancing:CreateListener','elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteListener'], run: function(cache, settings, callback) { var results = []; @@ -45,11 +53,11 @@ module.exports = { var non_https_listener = []; var noListeners = true; var elbArn = lb.LoadBalancerArn; - if (describeListeners.data && describeListeners.data.Listeners && describeListeners.data.Listeners.length) { + if (describeListeners && describeListeners.data && describeListeners.data.Listeners && describeListeners.data.Listeners.length) { noListeners = false; describeListeners.data.Listeners.forEach(function(listener){ // if it is not https add errors to results - if (listener.Protocol != 'HTTPS'){ + if (listener.Protocol && listener.Port && (listener.Protocol !== 'HTTPS' && listener.Protocol !== 'SSL')) { non_https_listener.push( listener.Protocol + ' / ' + listener.Port @@ -62,7 +70,7 @@ module.exports = { var msg = 'The following listeners are not using HTTPS-only: '; helpers.addResult(results, 2, msg + non_https_listener.join(', '), region, elbArn); - }else if (non_https_listener && !non_https_listener.length) { + } else if (non_https_listener && !non_https_listener.length) { helpers.addResult(results, 0, 'All listeners are HTTPS-only', region, elbArn); } else if (noListeners) { helpers.addResult(results, 0, 'No listeners found', region, elbArn); @@ -74,5 +82,90 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'elbv2HttpsOnly'; + var actions = []; + var errors = []; + + if (resource && resource.length) { + config.region = resource.split(':')[3]; + } else { + return callback('No resource to remediate'); + } + var describeLoadBalancers; + + if (cache['elbv2'] && + cache['elbv2']['describeLoadBalancers'] && + cache['elbv2']['describeLoadBalancers'][config.region] && + cache['elbv2']['describeLoadBalancers'][config.region].data && + cache['elbv2']['describeLoadBalancers'][config.region].data.length) { + describeLoadBalancers = cache['elbv2']['describeLoadBalancers'][config.region].data; + } else { + return callback('Unable to query for load balancers'); + } + + resource = resource.replace('listener', 'loadbalancer'); + resource = resource.split('/'); + resource.pop(); + resource = resource.join('/'); + + var failingLoadBalancer = describeLoadBalancers.find(loadBalancer => { + return loadBalancer.LoadBalancerArn === resource; + }); + + if (!failingLoadBalancer || !failingLoadBalancer.DNSName) { + return callback('Unable to query for ELBv2 Listeners'); + } + var failingDNSName = failingLoadBalancer.DNSName; + var describeListeners; + + if (cache['elbv2'] && + cache['elbv2']['describeListeners'] && + cache['elbv2']['describeListeners'][config.region] && + cache['elbv2']['describeListeners'][config.region][failingDNSName] && + cache['elbv2']['describeListeners'][config.region][failingDNSName].data && + cache['elbv2']['describeListeners'][config.region][failingDNSName].data.Listeners) { + describeListeners = cache['elbv2']['describeListeners'][config.region][failingDNSName].data.Listeners; + } else { + return callback('Unable to query for ELBv2 Listeners'); + } + + var failingListeners = describeListeners.filter(listener => { + if (listener.Protocol === 'HTTP') { + return listener; + } + }); + if (!failingListeners || !failingListeners.length) { + return callback('No failing listeners found'); + } + + async.each(failingListeners, function(failingListener, cb) { + var params = { + 'ListenerArn': failingListener.ListenerArn + }; + + helpers.remediatePlugin(config, putCall[0], params, function(error, action) { + if (error && (error.length || Object.keys(error).length)) { + errors.push(error); + } else if (action && (action.length || Object.keys(action).length)){ + actions.push(action); + } + + cb(); + }); + }, function() { + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); + settings.remediation_file = remediation_file; + return callback(errors, null); + } else { + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; + settings.remediation_file = remediation_file; + return callback(null, actions); + } + }); } }; diff --git a/plugins/aws/elbv2/elbv2HttpsOnly.spec.js b/plugins/aws/elbv2/elbv2HttpsOnly.spec.js new file mode 100644 index 000000000..813a9f1a2 --- /dev/null +++ b/plugins/aws/elbv2/elbv2HttpsOnly.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +const elbv2HttpsOnly = require('./elbv2HttpsOnly'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + }, +]; + +const describeListeners = [ + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 443, + "Protocol": "HTTPS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}] + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + ], + }, +]; + +const createCache = (elbv2, listeners) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeListeners: { + 'us-east-1': { + [lbDnsName]: { + data: listeners + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Application Load balancers' + }, + }, + }, + describeListeners: { + 'us-east-1': { + err: { + message: 'error describing load balancer listeners' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeListeners: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2HttpsOnly', function () { + describe('run', function () { + it('should PASS if Application Load Balancer has All listeners are HTTPS-only', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[0]); + elbv2HttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('All listeners are HTTPS-only'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Application Load Balancer does not have All listeners are HTTPS-only', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[1]); + elbv2HttpsOnly.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The following listeners are not using HTTPS-only'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([]); + elbv2HttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No load balancers present'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2HttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Load Balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2HttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2InsecureCiphers.js b/plugins/aws/elbv2/elbv2InsecureCiphers.js new file mode 100644 index 000000000..703082ca7 --- /dev/null +++ b/plugins/aws/elbv2/elbv2InsecureCiphers.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Insecure Ciphers', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Elbv2 listeners are configured to use the predefined security policies containing secure ciphers.', + more_info: 'A security policy is a combination of protocols and ciphers. The protocol establishes a secure connection between a client and a server and ensures that all data passed between the client and your load balancer is private.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/network/create-tls-listener.htmll', + recommended_action: 'Modify ELBv2 listeners with the predefined AWS security policies containing secure ciphers.', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeListeners'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:CreateListener', 'elasticloadbalancing:ModifyListener', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteListener'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var deprecatedALBCipherPolicies = [ + 'ELBSecurityPolicy-TLS-1-2-2017-01', + 'ELBSecurityPolicy-TLS-1-2-Ext-2018-06', + 'ELBSecurityPolicy-FS-1-2-2019-08', + 'ELBSecurityPolicy-FS-1-2-Res-2019-08', + ]; + + var deprecatedNLBCipherPolicies = [ + 'ELBSecurityPolicy-TLS13-1-2-2021-06', + 'ELBSecurityPolicy-TLS13-1-2-Ext1-2021-06', + 'ELBSecurityPolicy-TLS13-1-2-Ext2-2021-06', + 'ELBSecurityPolicy-FS-1-2-Res-2020-10', + 'ELBSecurityPolicy-FS-1-2-Res-2019-08', + 'ELBSecurityPolicy-FS-1-2-2019-08', + 'ELBSecurityPolicy-TLS-1-2-Ext-2018-06', + 'ELBSecurityPolicy-TLS-1-2-2017-01', + ]; + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for load balancers: ${helpers.addError(describeLoadBalancers)}`,region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application or Network load balancers found', region); + return rcb(); + } + + for (var alb of describeLoadBalancers.data){ + if (!alb.DNSName) continue; + + var depPolicies = []; + var SslPolicy = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, alb.DNSName]); + + if (describeListeners.err || !describeListeners.data || !describeListeners.data.Listeners) { + helpers.addResult(results, 3, `Unable to query for ELBv2 Listeners: ${helpers.addError(describeListeners)}`, region, alb.LoadBalancerArn); + continue; + } + + if (!describeListeners.data.Listeners.length) { + helpers.addResult(results, 0, 'No Listeners found for load balancer', region, alb.LoadBalancerArn); + continue; + } + + describeListeners.data.Listeners.forEach(function(listener){ + if (listener.SslPolicy) { + SslPolicy = true; + if (deprecatedALBCipherPolicies.includes(listener.SslPolicy) || deprecatedNLBCipherPolicies.includes(listener.SslPolicy)) { + depPolicies.push(listener.SslPolicy); + } + } + }); + if (!SslPolicy){ + helpers.addResult(results, 0,'No SSL policies found for load balancer', region, alb.LoadBalancerArn); + } else if (depPolicies && depPolicies.length){ + helpers.addResult(results, 2, 'Load balancer listeners have these policies with insecure ciphers: ' + depPolicies.join(', '), region, alb.LoadBalancerArn); + } else { + helpers.addResult(results, 0, 'Load balancer listeners policies contain secure ciphers', region, alb.LoadBalancerArn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2InsecureCiphers.spec.js b/plugins/aws/elbv2/elbv2InsecureCiphers.spec.js new file mode 100644 index 000000000..55ab175f2 --- /dev/null +++ b/plugins/aws/elbv2/elbv2InsecureCiphers.spec.js @@ -0,0 +1,198 @@ +var expect = require('chai').expect; +const elbv2InsecureCiphers = require('./elbv2InsecureCiphers.js'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + } +]; + +const describeListeners = [ + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 443, + "Protocol": "HTTPS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}], + "SslPolicy": "ELBSecurityPolicy-TLS13-1-2-Ext1-2021-06", + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + ], + }, + { + "Listeners":[] + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Protocol": "TLS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}], + "SslPolicy": "ELBSecurityPolicy-TLS-1-1-2017-01", + } + ], + }, + +]; + +const createCache = (elbv2, listeners) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeListeners: { + 'us-east-1': { + [lbDnsName]: { + data: listeners + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Application Load balancers' + }, + }, + }, + describeListeners: { + 'us-east-1': { + err: { + message: 'error describing load balancer listeners' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeListeners: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2InsecureCiphers', function () { + describe('run', function () { + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Application or Network load balancers found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for load balancers'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Listeners', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for ELBv2 Listeners'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Listeners found', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[2]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No Listeners found for load balancer'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no SSL policies found', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[1]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No SSL policies found for load balancer'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if listeners on Elbv2 are using policies which support insecure ciphers', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[0]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Load balancer listeners have these policies with insecure ciphers'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if all listeners on Elbv2 are using current policies', function (done) { + const cache = createCache([describeLoadBalancers[0]],describeListeners[3]); + elbv2InsecureCiphers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Load balancer listeners policies contain secure ciphers'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2LoggingEnabled.js b/plugins/aws/elbv2/elbv2LoggingEnabled.js index 5fddfce6f..339968171 100644 --- a/plugins/aws/elbv2/elbv2LoggingEnabled.js +++ b/plugins/aws/elbv2/elbv2LoggingEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELBv2 Logging Enabled', category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures load balancers have request logging enabled.', more_info: 'Logging requests to ELB endpoints is a helpful way ' + 'of detecting and investigating potential attacks, ' + @@ -21,6 +23,7 @@ module.exports = { pci: 'PCI requires logging of all network access to environments containing ' + 'cardholder data. Enable ELB logs to log these network requests.' }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], run: function(cache, settings, callback) { var results = []; @@ -49,7 +52,8 @@ module.exports = { var describeLoadBalancerAttributes = helpers.addSource(cache, source, ['elbv2', 'describeLoadBalancerAttributes', region, lb.DNSName]); - if ( describeLoadBalancerAttributes.data && + if (describeLoadBalancerAttributes && + describeLoadBalancerAttributes.data && describeLoadBalancerAttributes.data.Attributes && describeLoadBalancerAttributes.data.Attributes.length) { for (let attribute of describeLoadBalancerAttributes.data.Attributes) { diff --git a/plugins/aws/elbv2/elbv2LoggingEnabled.spec.js b/plugins/aws/elbv2/elbv2LoggingEnabled.spec.js new file mode 100644 index 000000000..0610bcbdc --- /dev/null +++ b/plugins/aws/elbv2/elbv2LoggingEnabled.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +const elbv2LoggingEnabled = require('./elbv2LoggingEnabled'); + +const loadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/test-lb-43/8e680c7bace394a7", + "DNSName": "test-lb-43-148538634.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-08-30T22:55:21.030Z", + "LoadBalancerName": "test-lb-43", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "IpAddressType": "ipv4" + } +]; + +const loadBalancerAttributes = [ + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "true" + }, + ] + }, + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + ] + } +]; + +const createCache = (elbv2, attribute) => { + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [elbv2[0].DNSName]: { + data: attribute + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2LoggingEnabled', function () { + describe('run', function () { + it('should PASS if load balancer has Logging enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[0]); + elbv2LoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if load balancer does not have Logging enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[1]); + elbv2LoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Logging not enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if no load balancer attributes found', function (done) { + const cache = createCache([loadBalancers[0]],[]); + elbv2LoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('no load balancer attributes found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if error while describing load balancers', function (done) { + const cache = createErrorCache(); + elbv2LoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for load balancers'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if unable to describe load balancers', function (done) { + const cache = createNullCache(); + elbv2LoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/elbv2/elbv2MinimumTargetInstances.js b/plugins/aws/elbv2/elbv2MinimumTargetInstances.js new file mode 100644 index 000000000..ad42164cc --- /dev/null +++ b/plugins/aws/elbv2/elbv2MinimumTargetInstances.js @@ -0,0 +1,98 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Minimum Number of EC2 Target Instances', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that there is a minimum number of two healthy target instances associated with each AWS ELBv2 load balancer.', + more_info: 'There should be a minimum number of two healthy target instances associated with each AWS ELBv2 load balancer to ensure fault tolerance.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html', + recommended_action: 'Associate at least two healthy target instances to AWS ELBv2 load balancer', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', 'ELBv2:describeTargetHealth'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyTargetGroup','elasticloadbalancing:RegisterTarget', 'elasticloadbalancing:DeregisterTargets', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for Application/Network load balancers: ${helpers.addError(describeLoadBalancers)}`, + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, + 'No Application/Network load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(elb, cb){ + var resource = elb.LoadBalancerArn; + var healthyInstances = 0; + + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, elb.DNSName]); + + if (!describeTargetGroups || describeTargetGroups.err || !describeTargetGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Application/Network load balancer target groups: ${helpers.addError(describeTargetGroups)}`, + region, resource); + return cb(); + } + + if (!describeTargetGroups.data.TargetGroups || !describeTargetGroups.data.TargetGroups.length){ + helpers.addResult(results, 2, + 'No Application/Network load balancer target groups found', + region, resource); + return cb(); + } + + async.each(describeTargetGroups.data.TargetGroups, function(targetGroup, tcb){ + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, targetGroup.TargetGroupArn]); + + if (!describeTargetHealth || describeTargetHealth.err || !describeTargetHealth.data + || !describeTargetHealth.data.TargetHealthDescriptions || !describeTargetHealth.data.TargetHealthDescriptions.length) { + return tcb(); + } + + describeTargetHealth.data.TargetHealthDescriptions.forEach(healthDescription => { + if (healthDescription.Target && healthDescription.Target.Id && + healthDescription.TargetHealth && healthDescription.TargetHealth.State === 'healthy') { + healthyInstances = healthyInstances + 1; + } + }); + + tcb(); + }); + + if (healthyInstances >= 2) { + helpers.addResult(results, 0, + `Application/Network load balancer has ${healthyInstances} healthy instance(s) associated`, + region, resource); + } else { + helpers.addResult(results, 2, + `Application/Network load balancer has only ${healthyInstances} healthy instance(s) associated`, + region, resource); + } + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elbv2/elbv2MinimumTargetInstances.spec.js b/plugins/aws/elbv2/elbv2MinimumTargetInstances.spec.js new file mode 100644 index 000000000..bbdf6f21a --- /dev/null +++ b/plugins/aws/elbv2/elbv2MinimumTargetInstances.spec.js @@ -0,0 +1,315 @@ +var expect = require('chai').expect; +const elbv2MinimumTargetInstances = require('./elbv2MinimumTargetInstances'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/ak-39/97b5a03b63cb8fad", + "DNSName": "ak-39-1555922310.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-11-02T21:09:40.460Z", + "LoadBalancerName": "ak-39", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-673a9a46", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1e", + "SubnetId": "subnet-6a8b635b", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1f", + "SubnetId": "subnet-c21b84cc", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-aa941691" + ], + "IpAddressType": "ipv4" + } +]; + +const describeTargetGroups = [ + { + "TargetGroups" : [ + { + "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/ak-39/63d6e6082a3f6607", + "TargetGroupName": "ak-39", + "Protocol": "HTTP", + "Port": 80, + "VpcId": "vpc-99de2fe4", + "HealthCheckProtocol": "HTTP", + "HealthCheckPort": "traffic-port", + "HealthCheckEnabled": true, + "HealthCheckIntervalSeconds": 5, + "HealthCheckTimeoutSeconds": 4, + "HealthyThresholdCount": 2, + "UnhealthyThresholdCount": 2, + "HealthCheckPath": "/", + "Matcher": { + "HttpCode": "200" + }, + "LoadBalancerArns": [ + "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/ak-39/97b5a03b63cb8fad" + ], + "TargetType": "instance" + }, + ] + }, +]; + +const describeTargetHealth = [ + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + }, + { + "Target": { + "Id": "i-077d08ab9b73d4a0c", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + }, + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + }, + { + "Target": { + "Id": "i-077d08ab9b73d4a0c", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "unhealthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + }, + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "unhealthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + } +]; + +const createCache = (elbv2, target, health) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + var targetArn = (target && target.TargetGroups) ? target.TargetGroups[0].TargetGroupArn : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeTargetGroups: { + 'us-east-1': { + [lbDnsName]: { + data: target + }, + }, + }, + describeTargetHealth: { + 'us-east-1': { + [targetArn]: { + data: health + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancers' + }, + }, + }, + describeTargetGroups: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancer target groups' + }, + }, + }, + describeTargetHealth: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancer target healths' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeTargetGroups: { + 'us-east-1': null, + }, + describeTargetHealth: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2MinimumTargetInstances', function () { + describe('run', function () { + it('should PASS if Application/Network load balancer has 2 healthy instances associated', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], describeTargetHealth[0]); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Application/Network load balancer has 1 healthy instance associated', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], describeTargetHealth[1]); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Application/Network load balancer has 1 healthy instance associated', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], describeTargetHealth[2]); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no Application/Network load balancer target group health descriptions found', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], []); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Application/Network load balancers found', function (done) { + const cache = createCache([]); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no Application/Network load balancer target groups found', function (done) { + const cache = createCache([describeLoadBalancers[0]], [], describeTargetHealth[3]); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application/Network load balancers', function (done) { + const cache = createErrorCache(); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application/Network load balancer target groups', function (done) { + const cache = createCache([describeLoadBalancers[0]], null); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Application/Network load balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2MinimumTargetInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2NlbListenerSecurity.js b/plugins/aws/elbv2/elbv2NlbListenerSecurity.js new file mode 100644 index 000000000..0313734f1 --- /dev/null +++ b/plugins/aws/elbv2/elbv2NlbListenerSecurity.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 NLB Listener Security', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS Network Load Balancers have secured listener configured.', + more_info: 'AWS Network Load Balancer should have TLS protocol listener configured to terminate TLS traffic.', + link: 'https://docs.amazonaws.cn/en_us/elasticloadbalancing/latest/network/create-tls-listener.html', + recommended_action: 'Attach TLS listener to AWS Network Load Balancer', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeListeners'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:CreateListener', 'elasticloadbalancing:ModifyListener','elasticloadbalancing:DeleteListener', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for Network Load Balancers: ${helpers.addError(describeLoadBalancers)}`, + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Load Balancers found', region); + return rcb(); + } + + var networkElbFound = false; + async.each(describeLoadBalancers.data, function(elb, cb){ + if (elb.Type && elb.Type === 'network') { + networkElbFound = true; + var securedListenerFound = false; + var resource = elb.LoadBalancerArn; + + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, elb.DNSName]); + + if (!describeListeners || describeListeners.err || !describeListeners.data) { + helpers.addResult(results, 3, + `Unable to query for Network Load Balancer listeners: ${helpers.addError(describeListeners)}`, + region, resource); + return cb(); + } + + if (!describeListeners.data.Listeners || !describeListeners.data.Listeners.length){ + helpers.addResult(results, 2, + 'No Network Load Balancer listeners found', + region, resource); + return cb(); + } + + for (var l in describeListeners.data.Listeners) { + var listener = describeListeners.data.Listeners[l]; + if (listener.Protocol && listener.Protocol === 'TLS') { + securedListenerFound = true; + break; + } + } + + if (securedListenerFound) { + helpers.addResult(results, 0, + 'Network Load Balancer has secure listener configured', + region, resource); + } else { + helpers.addResult(results, 2, + 'Network Load Balancer does not have secure listener configured', + region, resource); + } + } + + cb(); + }); + + if (!networkElbFound) { + helpers.addResult(results, 0, + 'No Network Load Balancers found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2NlbListenerSecurity.spec.js b/plugins/aws/elbv2/elbv2NlbListenerSecurity.spec.js new file mode 100644 index 000000000..5a779ecb1 --- /dev/null +++ b/plugins/aws/elbv2/elbv2NlbListenerSecurity.spec.js @@ -0,0 +1,204 @@ +var expect = require('chai').expect; +const elbv2NlbListenerSecurity = require('./elbv2NlbListenerSecurity'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "network", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + }, + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + } +]; + +const describeListeners = [ + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "TLS", + "DefaultActions": [] + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "TCP", + "DefaultActions": [] + } + ], + }, +]; + +const createCache = (elbv2, listeners) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeListeners: { + 'us-east-1': { + [lbDnsName]: { + data: listeners + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancers' + }, + }, + }, + describeListeners: { + 'us-east-1': { + err: { + message: 'error describing load balancer listeners' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeListeners: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('elbv2NlbListenerSecurity', function () { + describe('run', function () { + it('should PASS if Network Load Balancer has secure listeners configured', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[0]); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Network Load Balancer does not have secure listeners configured', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[1]); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No Network Load Balancers found', function (done) { + const cache = createCache([]); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No Network Load Balancers found', function (done) { + const cache = createCache([describeLoadBalancers[1]]); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if No Network Load Balancer listeners found', function (done) { + const cache = createCache([describeLoadBalancers[0]], []); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Network Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Network Load Balancer listeners', function (done) { + const cache = createCache([describeLoadBalancers[0]]); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Load Balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2NlbListenerSecurity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2NoInstances.js b/plugins/aws/elbv2/elbv2NoInstances.js index d66f9c7f2..38b128609 100644 --- a/plugins/aws/elbv2/elbv2NoInstances.js +++ b/plugins/aws/elbv2/elbv2NoInstances.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELBv2 No Instances', category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', description: 'Detects ELBs that have no target groups attached', more_info: 'All ELBs should have backend server resources. ' + 'Those without any are consuming costs without providing ' + @@ -12,6 +14,18 @@ module.exports = { link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html', recommended_action: 'Delete old ELBs that no longer have backend resources.', apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups'], + remediation_description: 'ELBs that have no target groups attached will be deleted.', + remediation_min_version: '202101072000', + apis_remediate: ['ELBv2:describeLoadBalancers'], + actions: { + remediate: ['ELBv2:deleteLoadBalancer'], + rollback: ['ELBv2:createLoadBalancer'] + }, + permissions: { + remediate: ['elasticloadbalancing:DeleteLoadBalancer'], + rollback: ['elasticloadbalancing:CreateLoadBalancer'] + }, + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer','elasticloadbalancing:DeleteLoadBalancer'], run: function(cache, settings, callback) { var results = []; @@ -40,7 +54,7 @@ module.exports = { ['elbv2', 'describeTargetGroups', region, lb.DNSName]); var elbArn = lb.LoadBalancerArn; - if (describeTargetGroups.data && describeTargetGroups.data.TargetGroups && describeTargetGroups.data.TargetGroups.length){ + if (describeTargetGroups && describeTargetGroups.data && describeTargetGroups.data.TargetGroups && describeTargetGroups.data.TargetGroups.length){ helpers.addResult(results, 0, 'ELB has ' + describeTargetGroups.data.TargetGroups.length + ' target groups', region, elbArn); } else { @@ -53,5 +67,42 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'elbv2NoInstances'; + var lbNameArr = resource.split(':'); + + config.region = lbNameArr[3]; + + // create the params necessary for the remediation + var params = { + 'LoadBalancerArn': resource + }; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Deletion': 'NOT_DELETED', + 'ELB': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'DELETED', + 'ELB': resource + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; diff --git a/plugins/aws/elbv2/elbv2NoInstances.spec.js b/plugins/aws/elbv2/elbv2NoInstances.spec.js new file mode 100644 index 000000000..6cd3c08ab --- /dev/null +++ b/plugins/aws/elbv2/elbv2NoInstances.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +const elbv2NoInstances = require('./elbv2NoInstances'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:000011112222:loadbalancer/app/elbv2-1/b5b5c5e457722035", + "DNSName": "elbv2-1-866927309.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2021-01-07T14:56:37.620Z", + "LoadBalancerName": "elbv2-1", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "provisioning" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-673a9a46", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-aa941691" + ], + "IpAddressType": "ipv4" + } +]; + +const describeTargetGroups = [ + { + "TargetGroups": [ + { + "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000011112222:targetgroup/temp-tg/fee5b45af37af625", + "TargetGroupName": "temp-tg", + "Protocol": "HTTP", + "Port": 80, + "VpcId": "vpc-99de2fe4", + "Matcher": { + "HttpCode": "200" + }, + "LoadBalancerArns": [ + "arn:aws:elasticloadbalancing:us-east-1:000011112222:loadbalancer/app/elbv2-1/b5b5c5e457722035" + ], + "TargetType": "instance" + } + ] + }, + { + "TargetGroups": [] + } +]; + +const createCache = (elb, tg, elbErr) => { + var dnsName = (elb && elb.length) ? elb[0].DNSName : null; + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + err: elbErr, + data: elb + }, + }, + describeTargetGroups: { + 'us-east-1': { + [dnsName]: { + data: tg + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + elb: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2NoInstances', function () { + describe('run', function () { + it('should PASS if ELB has target groups', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0]); + elbv2NoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if ELB does not have target groups', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[1]); + elbv2NoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no load balancers present', function (done) { + const cache = createCache([]); + elbv2NoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for load balancers', function (done) { + const cache = createCache(describeLoadBalancers[0], describeTargetGroups[0], { message: 'Unable to query load balancers'}); + elbv2NoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe load balancers response not found', function (done) { + const cache = createNullCache(); + elbv2NoInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2SslTermination.js b/plugins/aws/elbv2/elbv2SslTermination.js new file mode 100644 index 000000000..60a6564b9 --- /dev/null +++ b/plugins/aws/elbv2/elbv2SslTermination.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELB SSL Termination', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Load Balancers has SSL certificate configured for SSL terminations.', + more_info: 'SSL termination or SSL offloading decrypts and verifies data on the load balancer instead of the application server which spares the server of having to organize incoming connections and prioritize on other tasks like loading web pages. This helps increase server speed.', + link: 'https://aws.amazon.com/blogs/aws/elastic-load-balancer-support-for-ssl-termination/', + recommended_action: 'Attach SSL certificate with the listener to AWS Elastic Load Balancer', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeListeners'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:CreateListeners','elasticloadbalancing:ModifyListener', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteListener'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + `Unable to query for Load Balancers: ${helpers.addError(describeLoadBalancers)}`, + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Load Balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(elb, cb){ + var resource = elb.LoadBalancerArn; + + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, elb.DNSName]); + + if (!describeListeners || describeListeners.err || !describeListeners.data) { + helpers.addResult(results, 3, + `Unable to query for Load Balancer listeners: ${helpers.addError(describeListeners)}`, + region, resource); + return cb(); + } + + if (!describeListeners.data.Listeners || !describeListeners.data.Listeners.length){ + helpers.addResult(results, 2, + 'No Load Balancer listeners found', + region, resource); + return cb(); + } + + let found = !!describeListeners.data.Listeners.find(listener => listener.Certificates && listener.Certificates.length); + + if (found) { + helpers.addResult(results, 0, + 'Elastic Load Balancer has SSL Termination configured', + region, resource); + } else { + helpers.addResult(results, 2, + 'Elastic Load Balancer does not have SSL Termination configured', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elbv2/elbv2SslTermination.spec.js b/plugins/aws/elbv2/elbv2SslTermination.spec.js new file mode 100644 index 000000000..14d37cf5f --- /dev/null +++ b/plugins/aws/elbv2/elbv2SslTermination.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +const elbv2SslTermination = require('./elbv2SslTermination'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "DNSName": "ak-40-network-ea75308eb0df27e4.elb.us-east-1.amazonaws.com", + "CanonicalHostedZoneId": "Z26RNL4JYFTOTI", + "CreatedTime": "2020-11-03T22:49:47.656Z", + "LoadBalancerName": "ak-40-network", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + } + ], + "IpAddressType": "ipv4" + }, + {} +]; + +const describeListeners = [ + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 443, + "Protocol": "HTTPS", + "Certificates": [{"CertArn":"arn:aws:acm:us-east-1:111122223333:test-elb"}] + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + ], + }, + { + "Listeners" : [ + { + "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/net/ak-40-network/ea75308eb0df27e4/467117f2e8cbb4cb", + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/ak-40-network/ea75308eb0df27e4", + "Port": 80, + "Protocol": "HTTP", + } + ], + }, +]; + +const createCache = (elbv2, listeners) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeListeners: { + 'us-east-1': { + [lbDnsName]: { + data: listeners + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Application Load balancers' + }, + }, + }, + describeListeners: { + 'us-east-1': { + err: { + message: 'error describing load balancer listeners' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeListeners: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2SslTermination', function () { + describe('run', function () { + it('should PASS if Application Load Balancer has SSL Termination configured', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[0]); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Application Load Balancer does not have SSL Termination configured', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeListeners[1]); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([]); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([]); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancer Listener', function (done) { + const cache = createCache([describeLoadBalancers[1]]); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Load Balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2SslTermination.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.js b/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.js new file mode 100644 index 000000000..47f1e2675 --- /dev/null +++ b/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 TLS Version and Cipher Header Disabled', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'low', + description: 'Ensures that AWS ELBv2 load balancers does not have TLS version and cipher headers enabled.', + more_info: 'TLS Version and Cipher Header provides information about the specific TLS version and cipher suite used during the establishment of the secure connection. Enabling the header might leak the sensitive information about the encryption protocols and algorithms being used', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html', + recommended_action: 'Update ELBv2 load balancer traffic configuration to disable TLS version and cipher headers', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeLoadBalancerAttributes'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancers: ' + helpers.addError(describeLoadBalancers), + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application/Network load balancers found', region); + return rcb(); + } + + async.each(describeLoadBalancers.data, function(elb, cb){ + var resource = elb.LoadBalancerArn; + + var elbv2Attributes = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancerAttributes', region, elb.DNSName]); + + if (!elbv2Attributes || elbv2Attributes.err || !elbv2Attributes.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancer attributes: ' + helpers.addError(elbv2Attributes), + region, resource); + return cb(); + } + + if (!elbv2Attributes.data.Attributes || !elbv2Attributes.data.Attributes.length){ + helpers.addResult(results, 2, + 'Application/Network load balancer attributes not found', + region, resource); + return cb(); + } + + let tlsCipherEnabled = elbv2Attributes.data.Attributes.find(attribute => + attribute.Key && attribute.Key === 'routing.http.x_amzn_tls_version_and_cipher_suite.enabled' && + attribute.Value && attribute.Value === 'true'); + + if (tlsCipherEnabled) { + helpers.addResult(results, 2, + 'Load balancer :' + elb.LoadBalancerName + ': has TLS version and cipher suite enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'Load balancer :' + elb.LoadBalancerName + ': has TLS version and cipher suite disabled', region, resource); + } + + cb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.spec.js b/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.spec.js new file mode 100644 index 000000000..5786a1ec5 --- /dev/null +++ b/plugins/aws/elbv2/elbv2TLSVersionCipherEnabled.spec.js @@ -0,0 +1,223 @@ +var expect = require('chai').expect; +const elbv2TLSCipherEnabled = require('./elbv2TLSVersionCipherEnabled'); + +const loadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/test-lb-43/8e680c7bace394a7", + "DNSName": "test-lb-43-148538634.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-08-30T22:55:21.030Z", + "LoadBalancerName": "test-lb-43", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "IpAddressType": "ipv4" + } +]; + +const loadBalancerAttributes = [ + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "routing.http.x_amzn_tls_version_and_cipher_suite.enabled", + "Value": "true" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + }, + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "routing.http.x_amzn_tls_version_and_cipher_suite.enabled", + "Value": "false" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + } +]; + +const createCache = (elbv2, attribute) => { + let dnsName =elbv2.length > 0 ? elbv2[0].DNSName : ''; + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [dnsName]: { + data: attribute + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2TLSCipherEnabled', function () { + describe('run', function () { + it('should PASS if load balancer has TLS Version and Cipher Suite disabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[1]); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if load balancer has TLS Version and Cipher Suite enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[0]); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + it('should PASS if no load balancer found', function (done) { + const cache = createCache([],[]); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Application/Network load balancers found'); + done(); + }); + }); + it('should FAIL if no load balancer attributes found', function (done) { + const cache = createCache([loadBalancers[0]],[]); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application/Network load balancer attributes not found'); + done(); + }); + }); + + it('should UNKNOWN if error while describing load balancers', function (done) { + const cache = createErrorCache(); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application/Network load balancers'); + done(); + }); + }); + + it('should not return anything if unable to describe load balancers', function (done) { + const cache = createNullCache(); + elbv2TLSCipherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/elbv2/elbv2UnhealthyInstance.js b/plugins/aws/elbv2/elbv2UnhealthyInstance.js new file mode 100644 index 000000000..e7b1acf66 --- /dev/null +++ b/plugins/aws/elbv2/elbv2UnhealthyInstance.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Unhealthy Instances', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that ELBv2 have healthy instances attached', + more_info: 'ELBs should have healthy instances to ensure proper load balancing and availability. ' + + 'Unhealthy instances can result in degraded performance or service disruptions.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html', + recommended_action: 'Investigate and resolve the health issues with the instances attached to the ELB.', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', 'ELBv2:describeTargetHealth'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyTargetGroups', 'elasticloadbalancing:RegisterTarget', 'elasticloadbalancing:DeregisterTargets', 'elasticloadbalancing:DeleteLoadBalancer', 'elasticloadbalancing:DeleteTargetGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elb, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancers: ' + helpers.addError(describeLoadBalancers), region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application/Network load balancers present', region); + return rcb(); + } + + describeLoadBalancers.data.forEach(function(lb) { + var resource = lb.LoadBalancerArn; + var unhealthyInstances = 0; + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (!describeTargetGroups || describeTargetGroups.err || !describeTargetGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Application/Network load balancer target groups: ${helpers.addError(describeTargetGroups)}`, + region, resource); + return; + } + + if (!describeTargetGroups.data.TargetGroups || !describeTargetGroups.data.TargetGroups.length) { + helpers.addResult(results, 2, 'No Application/Network load balancer target groups found', region, resource); + return; + } + + describeTargetGroups.data.TargetGroups.forEach(function(tg) { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + + if (!describeTargetHealth || describeTargetHealth.err || !describeTargetHealth.data + || !describeTargetHealth.data.TargetHealthDescriptions || !describeTargetHealth.data.TargetHealthDescriptions.length) { + return; + } + + describeTargetHealth.data.TargetHealthDescriptions.forEach(healthDescription => { + if (healthDescription.Target && healthDescription.Target.Id && + healthDescription.TargetHealth && healthDescription.TargetHealth.State === 'unhealthy') { + unhealthyInstances = unhealthyInstances + 1; + } + }); + }); + + if (unhealthyInstances > 0) { + helpers.addResult(results, 2, + `Application/Network load balancer has ${unhealthyInstances} unhealthy instance(s) associated`, + region, resource); + } else { + helpers.addResult(results, 0, + 'Application/Network load balancer does not have any unhealthy instance associated', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/elbv2/elbv2UnhealthyInstance.spec.js b/plugins/aws/elbv2/elbv2UnhealthyInstance.spec.js new file mode 100644 index 000000000..56387946f --- /dev/null +++ b/plugins/aws/elbv2/elbv2UnhealthyInstance.spec.js @@ -0,0 +1,302 @@ +var expect = require('chai').expect; +const elbv2UnhealthyInstance = require('./elbv2UnhealthyInstance'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/ak-39/97b5a03b63cb8fad", + "DNSName": "ak-39-1555922310.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-11-02T21:09:40.460Z", + "LoadBalancerName": "ak-39", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-06aa0f60", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-673a9a46", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1e", + "SubnetId": "subnet-6a8b635b", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1f", + "SubnetId": "subnet-c21b84cc", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-aa941691" + ], + "IpAddressType": "ipv4" + } +]; + +const describeTargetGroups = [ + { + "TargetGroups" : [ + { + "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/ak-39/63d6e6082a3f6607", + "TargetGroupName": "ak-39", + "Protocol": "HTTP", + "Port": 80, + "VpcId": "vpc-99de2fe4", + "HealthCheckProtocol": "HTTP", + "HealthCheckPort": "traffic-port", + "HealthCheckEnabled": true, + "HealthCheckIntervalSeconds": 5, + "HealthCheckTimeoutSeconds": 4, + "HealthyThresholdCount": 2, + "UnhealthyThresholdCount": 2, + "HealthCheckPath": "/", + "Matcher": { + "HttpCode": "200" + }, + "LoadBalancerArns": [ + "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/ak-39/97b5a03b63cb8fad" + ], + "TargetType": "instance" + }, + ] + }, +]; + +const describeTargetHealth = [ + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + }, + { + "Target": { + "Id": "i-077d08ab9b73d4a0c", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + }, + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "healthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + }, + { + "Target": { + "Id": "i-077d08ab9b73d4a0c", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "unhealthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + }, + { + "TargetHealthDescriptions": [ + { + "Target": { + "Id": "i-0524e0662835a974b", + "Port": 80 + }, + "HealthCheckPort": "80", + "TargetHealth": { + "State": "unhealthy", + "Reason": "Target.Timeout", + "Description": "Request timed out" + } + } + ] + } +]; + +const createCache = (elbv2, target, health) => { + var lbDnsName = (elbv2 && elbv2.length) ? elbv2[0].DNSName : null; + var targetArn = (target && target.TargetGroups) ? target.TargetGroups[0].TargetGroupArn : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeTargetGroups: { + 'us-east-1': { + [lbDnsName]: { + data: target + }, + }, + }, + describeTargetHealth: { + 'us-east-1': { + [targetArn]: { + data: health + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancers' + }, + }, + }, + describeTargetGroups: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancer target groups' + }, + }, + }, + describeTargetHealth: { + 'us-east-1': { + err: { + message: 'error describing Application/Network load balancer target healths' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeTargetGroups: { + 'us-east-1': null, + }, + describeTargetHealth: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2UnhealthyInstance', function () { + describe('run', function () { + it('should PASS if Application/Network load balancer has healthy instances associated', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], describeTargetHealth[0]); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Application/Network load balancer has unhealthy instance associated', function (done) { + const cache = createCache([describeLoadBalancers[0]], describeTargetGroups[0], describeTargetHealth[1]); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Application/Network load balancers found', function (done) { + const cache = createCache([]); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if no Application/Network load balancer target groups found', function (done) { + const cache = createCache([describeLoadBalancers[0]], [], describeTargetHealth[3]); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application/Network load balancers', function (done) { + const cache = createErrorCache(); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application/Network load balancer target groups', function (done) { + const cache = createCache([describeLoadBalancers[0]], null); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe Application/Network load balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2UnhealthyInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2WafEnabled.js b/plugins/aws/elbv2/elbv2WafEnabled.js index 371691cc5..f238e2805 100644 --- a/plugins/aws/elbv2/elbv2WafEnabled.js +++ b/plugins/aws/elbv2/elbv2WafEnabled.js @@ -4,94 +4,106 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'ELBv2 WAF Enabled', category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensure that all Application Load Balancers have WAF enabled.', more_info: 'Enabling WAF allows control over requests to the load balancer, allowing or denying traffic based off rules in the Web ACL', link: 'https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/', recommended_action: '1. Enter the WAF service. 2. Enter Web ACLs and filter by the region the Application Load Balancer is in. 3. If no Web ACL is found, Create a new Web ACL in the region the ALB resides and in Resource type to associate with web ACL, select the Load Balancer. ', - apis: ['WAFRegional:listWebACLs','WAFRegional:listResourcesForWebACL','ELBv2:describeLoadBalancers'], + apis: ['ELBv2:describeLoadBalancers', 'WAFV2:listWebACLs', 'WAFRegional:listWebACLs', 'WAFV2:listResourcesForWebACL', 'WAFRegional:listResourcesForWebACL'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'wafv2:CreateWebAcl', 'wafv2:UpdateWebAcl', 'wafregional:CreateWebAcl', 'wafregional:UpdateWebAcl', 'wafv2:DeleteWebAcl', 'wafregional:DeleteWebAcl'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); - var myResourceArns = []; + var resourcesToCheck = []; - async.each(regions.elbv2, function(loc, lcb){ - var loadBalancers = helpers.addSource(cache, source, - ['elbv2', 'describeLoadBalancers', loc]); + async.each(regions.wafv2, function(region, rcb){ + var listWebACLs = helpers.addSource(cache, source, + ['wafv2', 'listWebACLs', region]); - if (!loadBalancers) return lcb(); + var listRegionalACLs = helpers.addSource(cache, source, + ['wafregional', 'listWebACLs', region]); - if (loadBalancers.err || !loadBalancers.data) { + if (!listWebACLs || !listRegionalACLs) return rcb(); + + if (listWebACLs.err || !listWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to query for WAFv2: ' + helpers.addError(listWebACLs), region); + return rcb(); + } + if (listRegionalACLs.err || !listRegionalACLs.data) { helpers.addResult(results, 3, - 'Unable to query for Load Balancers: ' + helpers.addError(loadBalancers), loc); - return lcb(); + 'Unable to query for Regional WAF: ' + helpers.addError(listRegionalACLs), region); + return rcb(); } - if (!loadBalancers.data.length) { - helpers.addResult(results, 0, 'No Load Balancers found', loc); - return lcb(); + var combinedACLS = listWebACLs.data.concat(listRegionalACLs.data); + + if (!combinedACLS.length) { + return rcb(); } - loadBalancers.data.forEach(loadBalancer => { - var loadBalancerARN = loadBalancer.LoadBalancerArn; - myResourceArns.push(loadBalancerARN); - }); + combinedACLS.forEach(webACL => { + if (webACL.WebACLId) { + let listResources = helpers.addSource(cache, source, + ['wafregional', 'listResourcesForWebACL', region, webACL.WebACLId]); - lcb(); + if (listResources && listResources.data && listResources.data.ResourceArns && + listResources.data.ResourceArns.length) { + resourcesToCheck = resourcesToCheck.concat(listResources.data.ResourceArns); + } + } else if (webACL.ARN) { + let listResources = helpers.addSource(cache, source, + ['wafv2', 'listResourcesForWebACL', region, webACL.ARN]); + + if (listResources && listResources.data && listResources.data.ResourceArns && + listResources.data.ResourceArns.length) { + resourcesToCheck = resourcesToCheck.concat(listResources.data.ResourceArns); + } + } + }); + rcb(); }, function(){ - async.each(regions.wafregional, function(region, rcb){ - var listWebACLs = helpers.addSource(cache, source, - ['wafregional', 'listWebACLs', region]); + async.each(regions.elbv2, function(loc, lcb){ + var loadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', loc]); - if (!listWebACLs) return rcb(); + if (!loadBalancers) return lcb(); - if (listWebACLs.err || !listWebACLs.data) { + if (loadBalancers.err || !loadBalancers.data) { helpers.addResult(results, 3, - 'Unable to query for WAFs: ' + helpers.addError(listWebACLs), region); - return rcb(); + 'Unable to query for Load Balancers: ' + helpers.addError(loadBalancers), loc); + return lcb(); } - if (!listWebACLs.data.length) { - helpers.addResult(results, 0, 'No WAFs found', region); - return rcb(); + if (!loadBalancers.data.length) { + helpers.addResult(results, 0, 'No Load Balancers found', loc); + return lcb(); } - listWebACLs.data.forEach(webACL => { - - var webACLId = webACL.WebACLId; - - var listResources = helpers.addSource(cache, source, - ['wafregional', 'listResourcesForWebACL', region, webACLId]); - - if (!listResources || listResources.err || !listResources.data) { - helpers.addResult(results, 3, - 'Unable to query for WAf Resources: ' + helpers.addError(listResources), region, webACLId); - return; - } - - if (listResources.data.ResourceArns) { - listResources.data.ResourceArns.forEach(resourceARN => { - var resourceType = resourceARN.split(':')[2]; - if (resourceType == 'elasticloadbalancing') { - if (myResourceArns.indexOf(resourceARN) > -1) { - myResourceArns.splice(myResourceArns.indexOf(resourceARN), 1); - } - } - }); + var appElbFound = false; + + loadBalancers.data.forEach(loadBalancer => { + if (loadBalancer.Type && + loadBalancer.Type.toLowerCase() === 'application') { + appElbFound = true; + if (loadBalancer.LoadBalancerArn && (resourcesToCheck.indexOf(loadBalancer.LoadBalancerArn) > -1)) { + resourcesToCheck.splice(resourcesToCheck.indexOf(loadBalancer.LoadBalancerArn), 1); + helpers.addResult(results, 0, 'The Application Load Balancer has WAF enabled', loc, loadBalancer.LoadBalancerArn); + } else { + helpers.addResult(results, 2, 'The Application Load Balancer does not have WAF enabled', loc, loadBalancer.LoadBalancerArn); + } } }); - rcb(); - }, function() { - if (myResourceArns.length) { - var myResourceArnStr = myResourceArns.join(', '); - - helpers.addResult(results, 2, - `The following Application Load Balancers do not have WAF Enabled: ${myResourceArnStr}`); - } else { - helpers.addResult(results, 0, 'All Application Load Balancers have WAF enabled'); + if (!appElbFound) { + helpers.addResult(results, 0, 'No Application Load Balancers found', loc); } + + lcb(); + }, function() { callback(null, results, source); }); }); diff --git a/plugins/aws/elbv2/elbv2WafEnabled.spec.js b/plugins/aws/elbv2/elbv2WafEnabled.spec.js new file mode 100644 index 000000000..689ed1473 --- /dev/null +++ b/plugins/aws/elbv2/elbv2WafEnabled.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +const elbv2WafEnabled = require('./elbv2WafEnabled'); + +const describeLoadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:000111222333:loadbalancer/app/sad-elb/0c48be96f812e564", + "DNSName": "sad-elb-2137190229.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2021-12-28T11:00:35.740Z", + "LoadBalancerName": "sad-elb", + "Scheme": "internet-facing", + "VpcId": "vpc-0f4f4575a74fac014", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-02ed4181800d4658b", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-06629b4200870c740", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-0cb6c99daaa6b73c5" + ], + "IpAddressType": "ipv4" + }, + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:000111222333:loadbalancer/app/sad-elb2/c09ae5d45f51b7b3", + "DNSName": "sad-elb2-1999742259.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2021-12-28T11:03:12.930Z", + "LoadBalancerName": "sad-elb2", + "Scheme": "internet-facing", + "VpcId": "vpc-0f4f4575a74fac014", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1a", + "SubnetId": "subnet-02ed4181800d4658b", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1b", + "SubnetId": "subnet-06629b4200870c740", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-0cb6c99daaa6b73c5" + ], + "IpAddressType": "ipv4" + } +]; + +const listWebACLs = [ + { + "WebACLId": "5fde1d39-53e3-496b-ab3f-41d9aca7f4a7", + "Name": "sad-acl" + }, + { + "WebACLId": "e0f3d7c9-9f75-4ace-b004-04e888a1372a", + "Name": "sad-acl2" + } +]; + +const listResourcesForWebACL = [ + { + "ResourceArns": [] + }, + { + "ResourceArns": [ + "arn:aws:elasticloadbalancing:us-east-1:000111222333:loadbalancer/app/sad-elb/0c48be96f812e564" + ] + } +]; + + +const createCache = (elbv2, WebACLs, webData) => { + var webId = (WebACLs && WebACLs.length) ? WebACLs[0].WebACLId : null; + + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + }, + wafregional: { + listWebACLs: { + 'us-east-1': { + data: WebACLs + }, + }, + listResourcesForWebACL: { + 'us-east-1': { + [webId]: { + data: webData + }, + }, + } + }, + wafv2: { + listWebACLs: { + 'us-east-1': { + data: [] + }, + } + } + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing Application/Application Load balancers' + }, + }, + }, + }, + wafregional: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'Error listing web ACLS' + } + } + }, + listResourcesForWebACL: { + 'us-east-1': { + err: { + message: 'Error listing resources for web ACLS' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + }, + wafregional: { + listWebACLs: { + 'us-east-1': null, + }, + listResourcesForWebACL: { + 'us-east-1': null, + } + } + }; +}; + +describe('elbv2WafEnabled', function () { + describe('run', function () { + it('should PASS if Application Load Balancer has WAF enabled', function (done) { + const cache = createCache([describeLoadBalancers[0]], [listWebACLs[1]], listResourcesForWebACL[1]); + elbv2WafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Application Load Balancer does not have WAF enabled', function (done) { + const cache = createCache([describeLoadBalancers[1]], [listWebACLs[0]], listResourcesForWebACL[0]); + elbv2WafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No Application Load Balancers found', function (done) { + const cache = createCache([], []); + elbv2WafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Application Load Balancers', function (done) { + const cache = createErrorCache(); + elbv2WafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Load Balancers response is not found', function (done) { + const cache = createNullCache(); + elbv2WafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.js b/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.js new file mode 100644 index 000000000..fa7ac7243 --- /dev/null +++ b/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ELBv2 Cross-Zone Load Balancing', + category: 'ELBv2', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that AWS ELBv2 load balancers have cross-zone load balancing enabled.', + more_info: 'AWS ELBv2 should have cross-zone load balancing enabled to distribute the traffic evenly across the registered instances in all enabled Availability Zones.', + link: 'https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html', + recommended_action: 'Update AWS ELBv2 load balancers to enable cross zone load balancing.', + apis: ['ELBv2:describeLoadBalancers', 'ELBv2:describeLoadBalancerAttributes'], + realtime_triggers: ['elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyLoadBalancerAttributes', 'elasticloadbalancing:DeleteLoadBalancer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.elbv2, function(region, rcb){ + var describeLoadBalancers = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancers) return rcb(); + + if (describeLoadBalancers.err || !describeLoadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancers: ' + helpers.addError(describeLoadBalancers), + region); + return rcb(); + } + + if (!describeLoadBalancers.data.length) { + helpers.addResult(results, 0, 'No Application/Network load balancers found', region); + return rcb(); + } + + for (let elb of describeLoadBalancers.data) { + var resource = elb.LoadBalancerArn; + + var elbv2Attributes = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancerAttributes', region, elb.DNSName]); + + if (!elbv2Attributes || elbv2Attributes.err || !elbv2Attributes.data) { + helpers.addResult(results, 3, + 'Unable to query for Application/Network load balancer attributes: ' + helpers.addError(elbv2Attributes), + region, resource); + continue; + } + + if (!elbv2Attributes.data.Attributes || !elbv2Attributes.data.Attributes.length){ + helpers.addResult(results, 2, + 'Application/Network load balancer attributes not found', + region, resource); + continue; + } + + let found = elbv2Attributes.data.Attributes.find(attr => attr.Key && attr.Key === 'load_balancing.cross_zone.enabled' && attr.Value && attr.Value === 'true'); + if (found) { + helpers.addResult(results, 0, + 'Load balancer :' + elb.LoadBalancerName + ': has cross-zone load balancing enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Load balancer :' + elb.LoadBalancerName + ': does not have cross-zone load balancing enabled', region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.spec.js b/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.spec.js new file mode 100644 index 000000000..5628521c2 --- /dev/null +++ b/plugins/aws/elbv2/elbv2crosszoneLoadBalancing.spec.js @@ -0,0 +1,225 @@ +var expect = require('chai').expect; +const elbv2crosszoneLoadBalancing = require('./elbv2crosszoneLoadBalancing'); + +const loadBalancers = [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/test-lb-43/8e680c7bace394a7", + "DNSName": "test-lb-43-148538634.us-east-1.elb.amazonaws.com", + "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K", + "CreatedTime": "2020-08-30T22:55:21.030Z", + "LoadBalancerName": "test-lb-43", + "Scheme": "internet-facing", + "VpcId": "vpc-99de2fe4", + "State": { + "Code": "active" + }, + "Type": "application", + "AvailabilityZones": [ + { + "ZoneName": "us-east-1c", + "SubnetId": "subnet-aac6b3e7", + "LoadBalancerAddresses": [] + }, + { + "ZoneName": "us-east-1d", + "SubnetId": "subnet-e83690b7", + "LoadBalancerAddresses": [] + } + ], + "SecurityGroups": [ + "sg-06cccc47e5b3e1ee9" + ], + "IpAddressType": "ipv4" + } +]; + +const loadBalancerAttributes = [ + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "load_balancing.cross_zone.enabled", + "Value": "true" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + }, + { + "Attributes": [ + { + "Key": "access_logs.s3.enabled", + "Value": "false" + }, + { + "Key": "access_logs.s3.bucket", + "Value": "" + }, + { + "Key": "access_logs.s3.prefix", + "Value": "" + }, + { + "Key": "idle_timeout.timeout_seconds", + "Value": "60" + }, + { + "Key": "load_balancing.cross_zone.enabled", + "Value": "false" + }, + { + "Key": "routing.http2.enabled", + "Value": "true" + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false" + }, + { + "Key": "routing.http.desync_mitigation_mode", + "Value": "defensive" + } + ] + } +]; + +const createCache = (elbv2, attribute) => { + return { + elbv2:{ + describeLoadBalancers: { + 'us-east-1': { + data: elbv2 + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + [elbv2[0].DNSName]: { + data: attribute + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': { + err: { + message: 'error describing classic load balancers' + }, + }, + }, + describeLoadBalancerAttributes: { + 'us-east-1': { + err: { + message: 'error describing application/network load balancers' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + elbv2: { + describeLoadBalancers: { + 'us-east-1': null, + }, + describeLoadBalancerAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('elbv2crosszoneLoadBalancing', function () { + describe('run', function () { + it('should PASS if load balancer has cross-zone load balancing enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[0]); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has cross-zone load balancing enabled'); + done(); + }); + }); + + it('should FAIL if load balancer does not have cross-zone load balancing enabled', function (done) { + const cache = createCache([loadBalancers[0]], loadBalancerAttributes[1]); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have cross-zone load balancing enabled'); + done(); + }); + }); + + it('should FAIL if no load balancer attributes found', function (done) { + const cache = createCache([loadBalancers[0]],[]); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application/Network load balancer attributes not found'); + done(); + }); + }); + + it('should UNKNOWN if error while describing load balancers', function (done) { + const cache = createErrorCache(); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application/Network load balancers:'); + done(); + }); + }); + + it('should UNKNOWN if error while describing load balancer attributes', function (done) { + const cache = createCache([loadBalancers[0]],null); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application/Network load balancer attributes:'); + done(); + }); + }); + it('should not return anything if unable to describe load balancers', function (done) { + const cache = createNullCache(); + elbv2crosszoneLoadBalancing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/emr/emrClusterHasTags.js b/plugins/aws/emr/emrClusterHasTags.js new file mode 100644 index 000000000..f8f4df8d8 --- /dev/null +++ b/plugins/aws/emr/emrClusterHasTags.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Cluster Has Tags', + category: 'EMR', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that EMR clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-plan-tags-add-new.html', + recommended_action: 'Modify EMR cluster and add tags.', + apis: ['EMR:listClusters', 'EMR:describeCluster'], + realtime_triggers: ['emr:CreateCluster', 'emr:AddTags', 'emr:RemoveTags', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR clusters found', region); + return rcb(); + } + + for (var cluster of listClusters.data) { + if (!cluster.Id) continue; + + var resource = cluster.ClusterArn; + + var describeCluster = helpers.addSource(cache, source, + ['emr', 'describeCluster', region, cluster.Id]); + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.Cluster) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster', region, resource); + continue; + } + var clusterTags = describeCluster.data.Cluster.Tags; + + if (!clusterTags || !clusterTags.length) { + helpers.addResult(results, 2, 'EMR cluster does not have tags', region, resource); + } else { + helpers.addResult(results, 0, 'EMR cluster has tags', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/emr/emrClusterHasTags.spec.js b/plugins/aws/emr/emrClusterHasTags.spec.js new file mode 100644 index 000000000..2e8f27d58 --- /dev/null +++ b/plugins/aws/emr/emrClusterHasTags.spec.js @@ -0,0 +1,237 @@ +const expect = require('chai').expect; +const emrClusterHasTags = require('./emrClusterHasTags'); + +const listClusters = [ + { + "Id": "j-BH72QI9T25CL", + "Name": "emr-cluster-1", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "INTERNAL_ERROR", + "Message": "Failed to start the job flow due to an internal error" + }, + "Timeline": { + "CreationDateTime": "2020-09-17T09:43:13.125Z", + "EndDateTime": "2020-09-17T09:45:36.643Z" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-BH72QI9T25CL" + }, + { + Id: 'j-2FO4W2DL7JJAM', + Name: 'emr-cluster-2', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0ddd5cacbc6b0082c), application provisioning timed out' + }, + Timeline: { + CreationDateTime: '2020-09-08T11:36:07.027Z', + EndDateTime: '2020-09-08T11:52:29.966Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM' + } +]; + +const describeCluster = [ + { + Cluster: { + Id: 'j-BH72QI9T25CL', + Name: 'emr-cluster-1', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + LogUri: 's3n://aws-logs-123456654321-us-east-1/elasticmapreduce/', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: false, + TerminationProtected: true, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [ + { + "Key": "key1", + "Value": "value" + }, + { + "Key": "key2", + "Value": "true" + } + ], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-234-140-67.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-security-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'emr-cluster-2', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-237-81-241.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-sc-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM', + StepConcurrencyLevel: 1 + } + } +]; + +const createCache = (listClusters, describeCluster) => { + var clusterId = (listClusters && listClusters.length) ? listClusters[0].Id : null; + return { + emr: { + listClusters: { + 'us-east-1': { + data: listClusters + } + }, + describeCluster: { + 'us-east-1': { + [clusterId]: { + data: describeCluster + } + } + }, + } + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing emr clusters' + }, + }, + }, + }, + }; +}; + +const createDescribeClusterErrorCache = (clusters) => { + return { + emr: { + listClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeCluster: { + 'us-east-1': { + [clusters[0].ClusterId]: { + err: { + message: 'error describing EMR cluster' + } + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emrClusterHasTags', function () { + describe('run', function () { + it('should FAIL if EMR cluster doesnot have tag', function (done) { + const cache = createCache([listClusters[1]], describeCluster[1]); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EMR cluster does not have tags'); + done(); + }); + }); + + it('should PASS if EMR cluster has tag', function (done) { + const cache = createCache([listClusters[0]], describeCluster[0]); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EMR cluster has tags'); + done(); + }); + }); + + it('should PASS if no EMR clusters found', function (done) { + const cache = createCache([]); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No EMR clusters found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EMR cluster', function (done) { + const cache = createDescribeClusterErrorCache(listClusters); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EMR cluster'); + done(); + }); + }); + + it('should UNKNOWN if unable to list EMR clusters', function (done) { + const cache = createErrorCache(); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for EMR cluster'); + done(); + }); + }); + + it('should not return any results if list clusters response not found', function (done) { + const cache = createNullCache(); + emrClusterHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/emr/emrClusterInVPC.js b/plugins/aws/emr/emrClusterInVPC.js new file mode 100644 index 000000000..2bdad4a2c --- /dev/null +++ b/plugins/aws/emr/emrClusterInVPC.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Cluster In VPC', + category: 'EMR', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that your Amazon Elastic MapReduce (EMR) clusters are provisioned using the AWS VPC platform instead of EC2-Classic platform.', + more_info: 'AWS EMR clusters using VPC platform instead of EC2-Classic can bring multiple advantages such as better networking infrastructure, much more flexible control over access security .', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-vpc-launching-job-flows.htmll', + recommended_action: 'EMR clusters Available in VPC', + apis: ['EC2:describeAccountAttributes','EMR:listClusters', 'EMR:describeCluster'], + realtime_triggers: ['emr:CreateCluster','emr:TerminateJobFlows', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR cluster found', region); + return rcb(); + } + + var describeAccountAttributes = helpers.addSource(cache, source, + ['ec2', 'describeAccountAttributes', region]); + + if (!describeAccountAttributes || describeAccountAttributes.err || !describeAccountAttributes.data) { + helpers.addResult(results, 3, + 'Unable to query for supported platforms: ' + helpers.addError(describeAccountAttributes), region); + return rcb(); + } + + if (describeAccountAttributes.data.length) { + var supportedPlatforms = describeAccountAttributes.data.find(attribute => attribute.AttributeName == 'supported-platforms'); + + if (supportedPlatforms && supportedPlatforms.AttributeValues) { + let ec2ClassicFound = supportedPlatforms.AttributeValues.find(value => value.AttributeValue && value.AttributeValue.toUpperCase() === 'EC2'); + if (!ec2ClassicFound) { + helpers.addResult(results, 0, 'EC2 account attribute allows only VPC supported platform', region); + return rcb(); + } + } + } + + for (let cluster of listClusters.data) { + if (!cluster.Id) continue; + + var resource = cluster.ClusterArn; + + var describeCluster = helpers.addSource(cache, source, + ['emr', 'describeCluster', region, cluster.Id]); + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.Cluster) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster', region, resource); + continue; + } + + if (describeCluster.data.Cluster.Ec2InstanceAttributes && + describeCluster.data.Cluster.Ec2InstanceAttributes.Ec2SubnetId && + describeCluster.data.Cluster.Ec2InstanceAttributes.Ec2SubnetId.length) { + helpers.addResult(results, 0, + `EMR cluster "${cluster.Name}" is in VPC`, region, resource); + } else { + helpers.addResult(results, 2, + `EMR cluster "${cluster.Name}" is not in VPC`, region, resource); + } + } + + rcb(); + }, function() { + return callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/emr/emrClusterInVPC.spec.js b/plugins/aws/emr/emrClusterInVPC.spec.js new file mode 100644 index 000000000..11064d5d1 --- /dev/null +++ b/plugins/aws/emr/emrClusterInVPC.spec.js @@ -0,0 +1,248 @@ +const expect = require('chai').expect; +const emrClusterInVPC = require('./emrClusterInVPC'); + +const describeAccountAttributes = [ + { + "AttributeName": "supported-platforms", + "AttributeValues": [ + { + "AttributeValue": "VPC" + } + ] + }, + { + "AttributeName": "supported-platforms", + "AttributeValues": [ + { + "AttributeValue": "EC2" + }, + { + "AttributeValue": "VPC" + } + ] + }, +]; + +const listClusters = [ + { + "Id": "j-8PNQXHQF599L", + "Name": "MyCluster9", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "VALIDATION_ERROR", + "Message": "The requested instance type c1.medium is not supported in the requested availability zone. Learn more at https://docs.aws.amazon.com/console/elasticmapreduce/ERROR_noinstancetype" + }, + "Timeline": { + "CreationDateTime": "2021-11-04T02:01:43.422000-07:00", + "EndDateTime": "2021-11-04T02:03:04.638000-07:00" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:000011112222:cluster/j-8PNQXHQF599L" + }, + { + "Id": "j-11W10W1AXKL60", + "Name": "MyCluster8", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "VALIDATION_ERROR", + "Message": "The requested instance type m1.large is not supported in the requested availability zone. Learn more at https://docs.aws.amazon.com/console/elasticmapreduce/ERROR_noinstancetype" + } + } + } +]; + +const describeCluster= [ + { + "Cluster": { + "Id": "j-8PNQXHQF599L", + "Name": "MyCluster9", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "VALIDATION_ERROR", + "Message": "The requested instance type c1.medium is not supported in the requested availability zone. Learn more at https://docs.aws.amazon.com/console/elasticmapreduce/ERROR_noinstancetype" + }, + "Timeline": { + "CreationDateTime": "2021-11-04T02:01:43.422000-07:00", + "EndDateTime": "2021-11-04T02:03:04.638000-07:00" + } + }, + "Ec2InstanceAttributes": { + "Ec2KeyName": "minekp", + "Ec2SubnetId": "subnet-0970477bd56d55b76", + "RequestedEc2SubnetIds": [ + "subnet-0970477bd56d55b76" + ], + "Ec2AvailabilityZone": "us-east-1f", + "RequestedEc2AvailabilityZones": [], + "IamInstanceProfile": "EMR_EC2_DefaultRole", + "EmrManagedMasterSecurityGroup": "sg-03e37c1f01b5eeabb", + "EmrManagedSlaveSecurityGroup": "sg-0f30865beac41d4a3", + "AdditionalMasterSecurityGroups": [], + "AdditionalSlaveSecurityGroups": [] + }, + } + }, + { + "Cluster": { + "Id": "j-11W10W1AXKL60", + "Name": "MyCluster8", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "VALIDATION_ERROR", + "Message": "The requested instance type m1.large is not supported in the requested availability zone. Learn more at https://docs.aws.amazon.com/console/elasticmapreduce/ERROR_noinstancetype" + }, + "Timeline": { + "CreationDateTime": "2021-11-03T06:52:10.746000-07:00", + "EndDateTime": "2021-11-03T06:53:34.541000-07:00" + } + }, + "Ec2InstanceAttributes": { + "Ec2KeyName": "minekp", + "Ec2SubnetId": "", + "RequestedEc2SubnetIds": [ + "" + ], + "Ec2AvailabilityZone": "us-east-1e" + } + } + } +]; + +const createCache = (attributes,listClusters, describeCluster) => { + var clusterId = (listClusters && listClusters.length) ? listClusters[0].Id : null; + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': { + data: attributes + }, + }, + }, + emr: { + listClusters: { + 'us-east-1': { + data: listClusters + } + }, + describeCluster: { + 'us-east-1': { + [clusterId]: { + data: describeCluster + } + } + }, + } + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing emr clusters' + }, + }, + }, + }, + } +}; + +const createDescribeClusterErrorCache = (clusters) => { + return { + emr: { + listClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeCluster: { + 'us-east-1': { + [clusters[0].ClusterId]: { + err: { + message: 'error describing EMR cluster' + } + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + ec2:{ + describeAccountAttributes: { + 'us-east-1': null, + }, + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + } + }; +}; + +describe('emrClusterInVPC', function () { + describe('run', function () { + it('should FAIL if EMR cluster is not in VPC', function (done) { + const cache = createCache([describeAccountAttributes[1]],[listClusters[1]], describeCluster[1]); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if EMR cluster is in VPC', function (done) { + const cache = createCache(describeAccountAttributes[0],[listClusters[0]], describeCluster[0]); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EMR clusters found', function (done) { + const cache = createCache(describeAccountAttributes[0],[]); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EMR cluster', function (done) { + const cache = createDescribeClusterErrorCache(listClusters); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list EMR clusters or account attributes', function (done) { + const cache = createErrorCache(); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list clusters response not found', function (done) { + const cache = createNullCache(); + emrClusterInVPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/emr/emrClusterLogging.js b/plugins/aws/emr/emrClusterLogging.js new file mode 100644 index 000000000..da2eb827b --- /dev/null +++ b/plugins/aws/emr/emrClusterLogging.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Cluster Logging', + category: 'EMR', + domain: 'Compute', + severity: 'Low', + description: 'Ensure AWS Elastic MapReduce (EMR) clusters capture detailed log data to Amazon S3.', + more_info: 'EMR cluster logging should be enabled to save log files for troubleshooting purposes.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-plan-debugging.html', + recommended_action: 'Modify EMR clusters to enable cluster logging', + apis: ['EMR:listClusters', 'EMR:describeCluster'], + realtime_triggers: ['emr:CreateCluster','emr:TerminateJobFlows', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR cluster found', region); + return rcb(); + } + + async.each(listClusters.data, function(cluster, ccb){ + if (!cluster.Id) ccb(); + + var resource = cluster.ClusterArn; + + var describeCluster = helpers.addSource(cache, source, + ['emr', 'describeCluster', region, cluster.Id]); + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.Cluster) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster', region, resource); + return ccb(); + } + + if (describeCluster.data.Cluster.LogUri && + describeCluster.data.Cluster.LogUri !== '') { + helpers.addResult(results, 0, + `EMR cluster logging is enabled for "${cluster.Name}" cluster`, region, resource); + } else { + helpers.addResult(results, 2, + `EMR cluster logging is not enabled for "${cluster.Name}" cluster`, region, resource); + } + + ccb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/emr/emrClusterLogging.spec.js b/plugins/aws/emr/emrClusterLogging.spec.js new file mode 100644 index 000000000..fc08cdf8f --- /dev/null +++ b/plugins/aws/emr/emrClusterLogging.spec.js @@ -0,0 +1,217 @@ +const expect = require('chai').expect; +const emrClusterLogging = require('./emrClusterLogging'); + +const listClusters = [ + { + "Id": "j-BH72QI9T25CL", + "Name": "emr-cluster-1", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "INTERNAL_ERROR", + "Message": "Failed to start the job flow due to an internal error" + }, + "Timeline": { + "CreationDateTime": "2020-09-17T09:43:13.125Z", + "EndDateTime": "2020-09-17T09:45:36.643Z" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-BH72QI9T25CL" + }, + { + Id: 'j-2FO4W2DL7JJAM', + Name: 'emr-cluster-2', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0ddd5cacbc6b0082c), application provisioning timed out' + }, + Timeline: { + CreationDateTime: '2020-09-08T11:36:07.027Z', + EndDateTime: '2020-09-08T11:52:29.966Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM' + } +]; + +const describeCluster = [ + { + Cluster: { + Id: 'j-BH72QI9T25CL', + Name: 'emr-cluster-1', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + LogUri: 's3n://aws-logs-123456654321-us-east-1/elasticmapreduce/', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: false, + TerminationProtected: true, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-234-140-67.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-security-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'emr-cluster-2', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-237-81-241.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-sc-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM', + StepConcurrencyLevel: 1 + } + } +]; + +const createCache = (listClusters, describeCluster) => { + var clusterId = (listClusters && listClusters.length) ? listClusters[0].Id : null; + return { + emr: { + listClusters: { + 'us-east-1': { + data: listClusters + } + }, + describeCluster: { + 'us-east-1': { + [clusterId]: { + data: describeCluster + } + } + }, + } + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing emr clusters' + }, + }, + }, + }, + }; +}; + +const createDescribeClusterErrorCache = (clusters) => { + return { + emr: { + listClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeCluster: { + 'us-east-1': { + [clusters[0].ClusterId]: { + err: { + message: 'error describing EMR cluster' + } + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emrClusterLogging', function () { + describe('run', function () { + it('should FAIL if EMR cluster logging is not enabled', function (done) { + const cache = createCache([listClusters[1]], describeCluster[1]); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if EMR cluster logging is enabled', function (done) { + const cache = createCache([listClusters[0]], describeCluster[0]); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EMR clusters found', function (done) { + const cache = createCache([]); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe EMR cluster', function (done) { + const cache = createDescribeClusterErrorCache(listClusters); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list EMR clusters', function (done) { + const cache = createErrorCache(); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list clusters response not found', function (done) { + const cache = createNullCache(); + emrClusterLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/emr/emrDesiredInstanceType.js b/plugins/aws/emr/emrDesiredInstanceType.js new file mode 100644 index 000000000..e760dc879 --- /dev/null +++ b/plugins/aws/emr/emrDesiredInstanceType.js @@ -0,0 +1,113 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Cluster Desired Instance Type', + category: 'EMR', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure AWS Elastic MapReduce (EMR) clusters are using desired instance type.', + more_info: 'EMR cluster desired instance should be enabled to get the desired instance type.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-plan-debugging.html', + recommended_action: 'Modify EMR clusters to enable cluster logging', + apis: ['EMR:listClusters', 'EMR:listInstanceGroups'], + settings: { + emr_desired_master_instance_type: { + name: 'EMR Desired Master Instance Type', + description: 'Comma-separated list of desired master instance types for EMR', + regex: '^.*$', + default: '' + }, + emr_desired_core_instance_type: { + name: 'EMR Desired Core Instance Type', + description: 'Comma-separated list of desired core instance types for EMR', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['emr:CreateCluster', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + + const emr_desired_master_instance_type = settings.emr_desired_master_instance_type || this.settings.emr_desired_master_instance_type.default; + const emr_desired_core_instance_type = settings.emr_desired_core_instance_type || this.settings.emr_desired_core_instance_type.default; + + if (!emr_desired_master_instance_type.length && !emr_desired_core_instance_type.length) return callback(null, results, source); + + async.each(regions.emr, function(region, rcb) { + const listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult( + results, 3, + 'Unable to query for EMR cluster: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length){ + helpers.addResult(results, 0, 'No EMR cluster found', region); + return rcb(); + } + + for (const cluster of listClusters.data) { + if (!cluster.Id) continue; + + const resource = cluster.ClusterArn; + + const listInstanceGroups = helpers.addSource(cache, source, + ['emr', 'listInstanceGroups', region, cluster.Id]); + + if (!listInstanceGroups || listInstanceGroups.err || + !listInstanceGroups.data || !listInstanceGroups.data.InstanceGroups) { + helpers.addResult( + results, 3, + 'Unable to query for EMR cluster config: ' + helpers.addError(listInstanceGroups), region, resource); + continue; + } + + const config = listInstanceGroups.data.InstanceGroups; + const masterGroup = config.find(InstanceGroup => InstanceGroup.InstanceGroupType === 'MASTER'); + const coreGroup = config.find(InstanceGroup => InstanceGroup.InstanceGroupType === 'CORE'); + const masterInstanceType = masterGroup ? masterGroup.InstanceType : null; + const coreInstanceType = coreGroup ? coreGroup.InstanceType : null; + + if (!masterInstanceType || !coreInstanceType) { + helpers.addResult( + results, 3, + 'Unable to query for EMR cluster master or core instance type', + region, resource); + continue; + } + + if ((masterInstanceType && !emr_desired_master_instance_type.includes(masterInstanceType)) && + (coreInstanceType && !emr_desired_core_instance_type.includes(coreInstanceType))) { + helpers.addResult(results, 2, + `EMR cluster is using ${masterInstanceType} master and ${coreInstanceType} core instance types`, + region, resource); + } else if (masterInstanceType && !emr_desired_master_instance_type.includes(masterInstanceType)) { + helpers.addResult(results, 2, + `EMR cluster is using ${masterInstanceType} master instance type`, + region, resource); + } else if (coreInstanceType && !emr_desired_core_instance_type.includes(coreInstanceType)) { + helpers.addResult(results, 2, + `EMR cluster is using ${coreInstanceType} core instance type`, + region, resource); + } else { + helpers.addResult(results, 0, + 'EMR cluster is using allowed master and node instance types', + region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/emr/emrDesiredInstanceType.spec.js b/plugins/aws/emr/emrDesiredInstanceType.spec.js new file mode 100644 index 000000000..50896cccb --- /dev/null +++ b/plugins/aws/emr/emrDesiredInstanceType.spec.js @@ -0,0 +1,249 @@ +const expect = require('chai').expect; +const emrDesiredInstanceType = require('./emrDesiredInstanceType'); + +const listClusters = [ + { + "Id": "j-2C3R1T3QB6HBQ", + "Name": "My cluster", + "Status": { + "State": "RUNNING", + "StateChangeReason": {}, + "Timeline": { + "CreationDateTime": "2021-11-23T19:34:38.096000+05:00" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:000011112222:cluster/j-2C3R1T3QB6HBQ" + }, + { + "Id": "j-2GWDFSLQDWY54", + "Name": "My cluster12", + "Status": { + "State": "RUNNING", + "StateChangeReason": { + "Code": "BOOTSTRAP_FAILURE", + "Message": "On the master instance (i-07434010850e76576), application provisioning failed" + }, + "Timeline": { + "CreationDateTime": "2021-11-23T20:51:48.988000+05:00", + "EndDateTime": "2021-11-23T21:02:45.793000+05:00" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:000011112222:cluster/j-2GWDFSLQDWY54" + }, +]; + +const listInstanceGroups = [ + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + + }, + ], + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + ], + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + ], + + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + ], + + }, +]; + +const createCache = (listClusters, listInstanceGroups) => { + if (listClusters && listClusters.length) var id = listClusters[0].Id; + return { + emr: { + listClusters: { + 'us-east-1': { + data: listClusters, + }, + }, + listInstanceGroups: { + 'us-east-1': { + [id]: { + data: listInstanceGroups + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emrDesiredInstanceType', function () { + describe('run', function () { + it('should FAIL if master and core are not of desired instance type', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[3]); + emrDesiredInstanceType.run(cache, { emr_desired_master_instance_type: 'c1.medium', emr_desired_core_instance_type: 'c1.medium' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if master instance is not of desired type', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[2]); + emrDesiredInstanceType.run(cache, {emr_desired_master_instance_type: 'c1.medium', }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if core instance is not of desired type', function (done) { + const cache = createCache([listClusters[1]], listInstanceGroups[1]); + emrDesiredInstanceType.run(cache, {emr_desired_core_instance_type: 'c1.medium' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if master and core instances are of desired instance types', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[0]); + emrDesiredInstanceType.run(cache, {emr_desired_core_instance_type: 'c1.medium', emr_desired_master_instance_type: 'c1.medium'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no clusters found', function (done) { + const cache = createCache([], []); + emrDesiredInstanceType.run(cache, {emr_desired_core_instance_type: 'c1.medium', emr_desired_master_instance_type: 'c1.medium'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error listing clusters', function (done) { + const cache = createErrorCache(); + emrDesiredInstanceType.run(cache, {emr_desired_core_instance_type: 'c1.medium', emr_desired_master_instance_type: 'c1.medium'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for clusters', function (done) { + const cache = createNullCache(); + emrDesiredInstanceType.run(cache, {emr_desired_core_instance_type: 'c1.medium', emr_desired_master_instance_type: 'c1.medium'}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any results if settings are not provided', function (done) { + const cache = createNullCache(); + emrDesiredInstanceType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/emr/emrEncryptionAtRest.js b/plugins/aws/emr/emrEncryptionAtRest.js new file mode 100644 index 000000000..31b7a5c97 --- /dev/null +++ b/plugins/aws/emr/emrEncryptionAtRest.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Encryption At Rest', + category: 'EMR', + domain: 'Compute', + severity: 'High', + description: 'Ensures encryption at rest for local disks is enabled for EMR clusters', + more_info: 'EMR clusters should be configured to enable encryption at rest for local disks.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-data-encryption-options.html', + recommended_action: 'Update security configuration associated with EMR cluster to enable encryption at rest for local disks.', + apis: ['EMR:listClusters', 'EMR:describeCluster', 'EMR:describeSecurityConfiguration'], + realtime_triggers: ['emr:CreateCluster', 'emr:CreateSecurityConfiguration','emr:DeleteSecurityConfiguration', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR cluster found', region); + return rcb(); + } + + async.each(listClusters.data, function(cluster, lcb){ + if (!cluster.Id) lcb(); + + var describeCluster = helpers.addSource(cache, source, + ['emr', 'describeCluster', region, cluster.Id]); + + var resource = cluster.ClusterArn; + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.Cluster) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster', region, resource); + return lcb(); + } + if (!describeCluster.data.Cluster.SecurityConfiguration) { + helpers.addResult(results, 2, + 'No security configuration found for :' + cluster.Name + ': EMR cluster', + region, resource); + return lcb(); + } + + var securityConfigurationName = describeCluster.data.Cluster.SecurityConfiguration; + + var describeSecurityConfiguration = helpers.addSource(cache, source, + ['emr', 'describeSecurityConfiguration', region, securityConfigurationName]); + + if (!describeSecurityConfiguration || + describeSecurityConfiguration.err || + !describeSecurityConfiguration.data || + !describeSecurityConfiguration.data.SecurityConfiguration) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster security configuration', region, resource); + return lcb(); + } + + try { + var clusterSecurityConfiguration = JSON.parse(describeSecurityConfiguration.data.SecurityConfiguration); + } catch (e) { + helpers.addResult(results, 3, + 'Cluster security configuration is not valid JSON.', + region, resource); + + return lcb(); + } + + if (clusterSecurityConfiguration.EncryptionConfiguration && + clusterSecurityConfiguration.EncryptionConfiguration.EnableAtRestEncryption && + clusterSecurityConfiguration.EncryptionConfiguration.EnableAtRestEncryption === true && + clusterSecurityConfiguration.EncryptionConfiguration.AtRestEncryptionConfiguration && + clusterSecurityConfiguration.EncryptionConfiguration.AtRestEncryptionConfiguration.LocalDiskEncryptionConfiguration) { + helpers.addResult(results, 0, + 'Encryption at rest for local disks is enabled for :' + cluster.Name + ': EMR cluster', + region, resource); + } else { + helpers.addResult(results, 2, + 'Encryption at rest for local disks is not enabled for :' + cluster.Name + ': EMR cluster', + region, resource); + } + lcb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/emr/emrEncryptionAtRest.spec.js b/plugins/aws/emr/emrEncryptionAtRest.spec.js new file mode 100644 index 000000000..29d264813 --- /dev/null +++ b/plugins/aws/emr/emrEncryptionAtRest.spec.js @@ -0,0 +1,309 @@ +const expect = require('chai').expect; +const emrEncryptionAtRest = require('./emrEncryptionAtRest'); + +const listClusters = [ + { + "Id": "j-BH72QI9T25CL", + "Name": "My cluster", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "INTERNAL_ERROR", + "Message": "Failed to start the job flow due to an internal error" + }, + "Timeline": { + "CreationDateTime": "2020-09-17T09:43:13.125Z", + "EndDateTime": "2020-09-17T09:45:36.643Z" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-BH72QI9T25CL" + }, + { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0ddd5cacbc6b0082c), application provisioning timed out' + }, + Timeline: { + CreationDateTime: '2020-09-08T11:36:07.027Z', + EndDateTime: '2020-09-08T11:52:29.966Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM' + }, + { + Id: 'j-3MTKFUU3FOIOD', + Name: 'test-cluster-104-1', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0b72f161549b8dbe0), application provisioning failed' + }, + Timeline: { + CreationDateTime: '2020-09-08T09:29:40.434Z', + EndDateTime: '2020-09-08T09:37:01.743Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-3MTKFUU3FOIOD' + }, + { + Id: 'j-2RF0ACE2SIAWT', + Name: 'My cluster', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'VALIDATION_ERROR', + Message: 'On the master instance (i-07c57ec89814ff6fb), Invalid S3Provider: Cannot recognize cacert.pem from the S3Provider zip file.' + }, + Timeline: { + CreationDateTime: '2020-09-06T09:55:19.177Z', + EndDateTime: '2020-09-06T09:59:11.533Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT' + } +]; + +const describeCluster = [ + { + Cluster: { + Id: 'j-2RF0ACE2SIAWT', + Name: 'My cluster', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: false, + TerminationProtected: true, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-234-140-67.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-security-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-237-81-241.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-sc-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-3MTKFUU3FOIOD', + Name: 'test-cluster-104-1', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-83-21-230.compute-1.amazonaws.com', + SecurityConfiguration: 'test-sc-100', + Configurations: [], + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-3MTKFUU3FOIOD', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: false, + TerminationProtected: true, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-234-140-67.compute-1.amazonaws.com', + Configurations: [], + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT', + StepConcurrencyLevel: 1 + } + } +]; + +const describeSecurityConfiguration = [ + { + Name: 'sc-test-100-2', + SecurityConfiguration: '{"EncryptionConfiguration":{"AtRestEncryptionConfiguration":{"LocalDiskEncryptionConfiguration":{"EncryptionKeyProviderType":"AwsKms","AwsKmsKey":"arn:aws:kms:us-east-1:123456654321:alias/test138","EnableEbsEncryption":true}},"EnableInTransitEncryption":false,"EnableAtRestEncryption":true}}', + CreationDateTime: '2020-09-17T09:39:01.657Z' + }, + { + Name: 'test-security-100', + SecurityConfiguration: '{"EncryptionConfiguration":{"AtRestEncryptionConfiguration":{"S3EncryptionConfiguration":{"EncryptionMode":"SSE-S3"}},"EnableInTransitEncryption":false,"EnableAtRestEncryption":true}}', + CreationDateTime: '2020-09-08T11:34:44.884Z' + }, + { + Name: 'test-sc-100', + SecurityConfiguration: '{"EncryptionConfiguration":{"InTransitEncryptionConfiguration":{"TLSCertificateConfiguration":{"CertificateProviderType":"PEM","S3Object":"s3://test-bucket-sploit-100/cacert.zip"}},"AtRestEncryptionConfiguration":{"S3EncryptionConfiguration":{"EncryptionMode":"SSE-S3"}},"EnableInTransitEncryption":true,"EnableAtRestEncryption":true}}', + CreationDateTime: '2020-09-06T09:50:42.222Z' + } +]; + +const createCache = (listClusters, describeCluster, describeSecurityConfiguration) => { + var clusterId = (listClusters && listClusters.length) ? listClusters[0].Id : null; + var securtiyConfigurationName = (describeCluster && + describeCluster.Cluster && + describeCluster.Cluster.SecurityConfiguration) ? describeCluster.Cluster.SecurityConfiguration : null; + + return { + emr: { + listClusters: { + 'us-east-1': { + data: listClusters + } + }, + describeCluster: { + 'us-east-1': { + [clusterId]: { + data: describeCluster + } + } + }, + describeSecurityConfiguration: { + 'us-east-1': { + [securtiyConfigurationName]: { + data: describeSecurityConfiguration + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emrEncryptionAtRest', function () { + describe('run', function () { + it('should FAIL if encryption at rest for local disks is not enabled for EMR cluster', function (done) { + const cache = createCache([listClusters[1]], describeCluster[0], describeSecurityConfiguration[1]); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no security configuration found for EMR cluster', function (done) { + const cache = createCache([listClusters[1]], describeCluster[2], describeSecurityConfiguration[1]); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if encryption at rest for local disks is enabled for EMR cluster', function (done) { + const cache = createCache([listClusters[0]], describeCluster[2], describeSecurityConfiguration[0]); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EMR clusters found', function (done) { + const cache = createCache([]); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for EMR clusters', function (done) { + const cache = createErrorCache(); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for EMR clusters', function (done) { + const cache = createNullCache(); + emrEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/emr/emrEncryptionInTransit.js b/plugins/aws/emr/emrEncryptionInTransit.js new file mode 100644 index 000000000..0e794f78e --- /dev/null +++ b/plugins/aws/emr/emrEncryptionInTransit.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Encryption In Transit', + category: 'EMR', + domain: 'Compute', + severity: 'High', + description: 'Ensures encryption in transit is enabled for EMR clusters', + more_info: 'EMR clusters should be configured to enable encryption in transit.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-data-encryption-options.html', + recommended_action: 'Update security configuration associated with EMR cluster to enable encryption in transit.', + apis: ['EMR:listClusters', 'EMR:describeCluster', 'EMR:describeSecurityConfiguration'], + realtime_triggers: ['emr:CreateCluster', 'emr:CreateSecurityConfiguration','emr: DeleteSecurityConfiguration', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR cluster found', region); + return rcb(); + } + + async.each(listClusters.data, function(cluster, lcb){ + if (!cluster.Id) lcb(); + + var describeCluster = helpers.addSource(cache, source, + ['emr', 'describeCluster', region, cluster.Id]); + + var resource = cluster.ClusterArn; + + if (!describeCluster || describeCluster.err || !describeCluster.data || !describeCluster.data.Cluster) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster', region, resource); + return lcb(); + } + + if (!describeCluster.data.Cluster.SecurityConfiguration) { + helpers.addResult(results, 2, + 'No security configuration found for :' + cluster.Name + ': EMR cluster', + region, resource); + return lcb(); + } + + var securityConfigurationName = describeCluster.data.Cluster.SecurityConfiguration; + + var describeSecurityConfiguration = helpers.addSource(cache, source, + ['emr', 'describeSecurityConfiguration', region, securityConfigurationName]); + + if (!describeSecurityConfiguration || + describeSecurityConfiguration.err || + !describeSecurityConfiguration.data || + !describeSecurityConfiguration.data.SecurityConfiguration) { + helpers.addResult(results, 3, + 'Unable to query for EMR cluster security configuration', region, resource); + return lcb(); + } + + try { + var clusterSecurityConfiguration = JSON.parse(describeSecurityConfiguration.data.SecurityConfiguration); + } catch (e) { + helpers.addResult(results, 3, + 'Cluster security configuration is not valid JSON.', + region, resource); + + return lcb(); + } + + if (clusterSecurityConfiguration.EncryptionConfiguration && + clusterSecurityConfiguration.EncryptionConfiguration.EnableInTransitEncryption && + clusterSecurityConfiguration.EncryptionConfiguration.EnableInTransitEncryption === true && + clusterSecurityConfiguration.EncryptionConfiguration.InTransitEncryptionConfiguration) { + helpers.addResult(results, 0, + 'Encryption in transit is enabled for :' + cluster.Name + ': EMR cluster', + region, resource); + } else { + helpers.addResult(results, 2, + 'Encryption in transit is not enabled for :' + cluster.Name + ': EMR cluster', + region, resource); + } + lcb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/emr/emrEncryptionInTransit.spec.js b/plugins/aws/emr/emrEncryptionInTransit.spec.js new file mode 100644 index 000000000..ddb066814 --- /dev/null +++ b/plugins/aws/emr/emrEncryptionInTransit.spec.js @@ -0,0 +1,261 @@ +const expect = require('chai').expect; +const emrEncryptionInTransit = require('./emrEncryptionInTransit'); + +const listClusters = [ + { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0ddd5cacbc6b0082c), application provisioning timed out' + }, + Timeline: { + CreationDateTime: '2020-09-08T11:36:07.027Z', + EndDateTime: '2020-09-08T11:52:29.966Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM' + }, + { + Id: 'j-3MTKFUU3FOIOD', + Name: 'test-cluster-104-1', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'BOOTSTRAP_FAILURE', + Message: 'On the master instance (i-0b72f161549b8dbe0), application provisioning failed' + }, + Timeline: { + CreationDateTime: '2020-09-08T09:29:40.434Z', + EndDateTime: '2020-09-08T09:37:01.743Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-3MTKFUU3FOIOD' + }, + { + Id: 'j-2RF0ACE2SIAWT', + Name: 'My cluster', + Status: { + State: 'TERMINATED_WITH_ERRORS', + StateChangeReason: { + Code: 'VALIDATION_ERROR', + Message: 'On the master instance (i-07c57ec89814ff6fb), Invalid S3Provider: Cannot recognize cacert.pem from the S3Provider zip file.' + }, + Timeline: { + CreationDateTime: '2020-09-06T09:55:19.177Z', + EndDateTime: '2020-09-06T09:59:11.533Z' + } + }, + NormalizedInstanceHours: 0, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT' + } +]; + +const describeCluster = [ + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-237-81-241.compute-1.amazonaws.com', + Configurations: [], + SecurityConfiguration: 'test-sc-100', + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2FO4W2DL7JJAM', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-3MTKFUU3FOIOD', + Name: 'test-cluster-104-1', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: true, + TerminationProtected: false, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-83-21-230.compute-1.amazonaws.com', + SecurityConfiguration: 'test-sc-100', + Configurations: [], + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-3MTKFUU3FOIOD', + StepConcurrencyLevel: 1 + } + }, + { + Cluster: { + Id: 'j-2FO4W2DL7JJAM', + Name: 'My cluster', + Status: [Object], + Ec2InstanceAttributes: [Object], + InstanceCollectionType: 'INSTANCE_GROUP', + ReleaseLabel: 'emr-5.30.1', + AutoTerminate: false, + TerminationProtected: true, + VisibleToAllUsers: true, + Applications: [Array], + Tags: [], + ServiceRole: 'EMR_DefaultRole', + NormalizedInstanceHours: 0, + MasterPublicDnsName: 'ec2-3-234-140-67.compute-1.amazonaws.com', + Configurations: [], + AutoScalingRole: 'EMR_AutoScaling_DefaultRole', + ScaleDownBehavior: 'TERMINATE_AT_TASK_COMPLETION', + EbsRootVolumeSize: 10, + KerberosAttributes: {}, + ClusterArn: 'arn:aws:elasticmapreduce:us-east-1:123456654321:cluster/j-2RF0ACE2SIAWT', + StepConcurrencyLevel: 1 + } + } +]; + +const describeSecurityConfiguration = [ + { + Name: 'test-security-100', + SecurityConfiguration: '{"EncryptionConfiguration":{"AtRestEncryptionConfiguration":{"S3EncryptionConfiguration":{"EncryptionMode":"SSE-S3"}},"EnableInTransitEncryption":false,"EnableAtRestEncryption":true}}', + CreationDateTime: '2020-09-08T11:34:44.884Z' + }, + { + Name: 'test-sc-100', + SecurityConfiguration: '{"EncryptionConfiguration":{"InTransitEncryptionConfiguration":{"TLSCertificateConfiguration":{"CertificateProviderType":"PEM","S3Object":"s3://test-bucket-sploit-100/cacert.zip"}},"AtRestEncryptionConfiguration":{"S3EncryptionConfiguration":{"EncryptionMode":"SSE-S3"}},"EnableInTransitEncryption":true,"EnableAtRestEncryption":true}}', + CreationDateTime: '2020-09-06T09:50:42.222Z' + } +]; + +const createCache = (listClusters, describeCluster, describeSecurityConfiguration) => { + var clusterId = (listClusters && listClusters.length) ? listClusters[0].Id : null; + var securtiyConfigurationName = (describeCluster && + describeCluster.Cluster && + describeCluster.Cluster.SecurityConfiguration) ? describeCluster.Cluster.SecurityConfiguration : null; + + return { + emr: { + listClusters: { + 'us-east-1': { + data: listClusters + } + }, + describeCluster: { + 'us-east-1': { + [clusterId]: { + data: describeCluster + } + } + }, + describeSecurityConfiguration: { + 'us-east-1': { + [securtiyConfigurationName]: { + data: describeSecurityConfiguration + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': { + err: { + message: 'error describing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + emr: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emrEncryptionInTransit', function () { + describe('run', function () { + it('should FAIL if encryption in transit is not enabled for EMR cluster', function (done) { + const cache = createCache([listClusters[0]], describeCluster[2], describeSecurityConfiguration[0]); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if no security configuration found for EMR cluster', function (done) { + const cache = createCache([listClusters[1]], describeCluster[2], describeSecurityConfiguration[1]); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if encryption in transit is enabled for EMR cluster', function (done) { + const cache = createCache([listClusters[1]], describeCluster[1], describeSecurityConfiguration[1]); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no EMR clusters found', function (done) { + const cache = createCache([]); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for EMR clusters', function (done) { + const cache = createErrorCache(); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for EMR clusters', function (done) { + const cache = createNullCache(); + emrEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/emr/emrInstanceCount.js b/plugins/aws/emr/emrInstanceCount.js new file mode 100644 index 000000000..9089bbca4 --- /dev/null +++ b/plugins/aws/emr/emrInstanceCount.js @@ -0,0 +1,119 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EMR Instances Counts', + category: 'EMR', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that the number of EMR cluster instances provisioned in your AWS account has not reached the desired threshold established by your organization.', + more_info: 'Setting threshold for the number of EMR cluster instances provisioned within your AWS account will help to manage EMR compute resources and' + + 'prevent unexpected charges on your AWS bill.', + link: 'https://docs.aws.amazon.com/emr/latest/ManagementGuide/emr-manage-view-clusters.html', + recommended_action: 'Ensure that the number of running EMR cluster instances matches the expected count. If instances are launched above the threshold, investigate to ensure they are legitimate.', + apis: ['EMR:listClusters', 'EMR:listInstanceGroups'], + settings: { + emr_instance_count_global_threshold: { + name: 'EMR Instance Count Global Threshold', + description: 'Checks for the number of running EMR instances across all regions and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 200 + }, + emr_instance_count_regional_threshold: { + name: 'EMR Instance Count Regional Threshold', + description: 'Checks for the number of running EMR instances per region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + } + }, + realtime_triggers: ['emr:CreateCluster', 'emr:TerminateClusters'], + + run: function(cache, settings, callback) { + var config = { + emr_instance_count_global_threshold: settings.emr_instance_count_global_threshold || this.settings.emr_instance_count_global_threshold.default, + emr_instance_count_regional_threshold: settings.emr_instance_count_regional_threshold || this.settings.emr_instance_count_regional_threshold.default + }; + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var instanceCountGlobal = 0; + + async.each(regions.emr, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['emr', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for EMR clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No EMR clusters found', region); + return rcb(); + } + + let instanceCount = 0; + + for (const cluster of listClusters.data) { + if (!cluster.Id) continue; + + const resource = cluster.ClusterArn; + + const listInstanceGroups = helpers.addSource(cache, source, + ['emr', 'listInstanceGroups', region, cluster.Id]); + + if (!listInstanceGroups || listInstanceGroups.err || + !listInstanceGroups.data || !listInstanceGroups.data.InstanceGroups) { + helpers.addResult( + results, 3, + 'Unable to query for EMR cluster instance groups: ' + helpers.addError(listInstanceGroups), region, resource); + continue; + } + + const instanceGroups = listInstanceGroups.data.InstanceGroups; + const masterGroup = instanceGroups.find(InstanceGroup => InstanceGroup.InstanceGroupType === 'MASTER'); + const coreGroup = instanceGroups.find(InstanceGroup => InstanceGroup.InstanceGroupType === 'CORE'); + const masterInstanceCount = masterGroup ? masterGroup.RunningInstanceCount : 0; + const coreInstanceCount = coreGroup ? coreGroup.RunningInstanceCount : 0; + + if (masterInstanceCount) { + instanceCountGlobal += masterInstanceCount; + instanceCount += masterInstanceCount; + } + if (coreInstanceCount){ + instanceCountGlobal += coreInstanceCount; + instanceCount += coreInstanceCount; + } + } + + const regionThreshold = config.emr_instance_count_regional_threshold; + + if (instanceCount > config.emr_instance_count_regional_threshold) { + helpers.addResult(results, 2, + instanceCount + ' EMR instances running in ' + region + ' region, exceed limit of: ' + regionThreshold, + region); + } else { + helpers.addResult(results, 0, + instanceCount + ' EMR instances in the region are within the regional expected count of: ' + regionThreshold, region); + } + + rcb(); + }, function() { + var globalThreshold = config.emr_instance_count_global_threshold; + + if (instanceCountGlobal > globalThreshold) { + helpers.addResult(results, 2, + instanceCountGlobal + ' EMR instances running in all regions, exceed limit of: ' + globalThreshold, 'global'); + } else { + helpers.addResult(results, 0, + instanceCountGlobal + ' EMR instances in the account are within the global expected count of: ' + globalThreshold, 'global'); + } + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/emr/emrInstanceCount.spec.js b/plugins/aws/emr/emrInstanceCount.spec.js new file mode 100644 index 000000000..a983e84a9 --- /dev/null +++ b/plugins/aws/emr/emrInstanceCount.spec.js @@ -0,0 +1,255 @@ +var expect = require('chai').expect; +const emrInstanceCount = require('./emrInstanceCount'); + +const listClusters = [ + { + "Id": "j-2WEMA6IZNEA1R", + "Name": "My cluster", + "Status": { + "State": "TERMINATED_WITH_ERRORS", + "StateChangeReason": { + "Code": "BOOTSTRAP_FAILURE", + "Message": "On the master instance (i-02dd18e1bef968b9a), application provisioning failed" + }, + "Timeline": { + "CreationDateTime": "2021-11-24T18:54:26.015000+05:00", + "EndDateTime": "2021-11-24T19:14:04.676000+05:00" + } + }, + "NormalizedInstanceHours": 0, + "ClusterArn": "arn:aws:elasticmapreduce:us-east-1:000111222333:cluster/j-2WEMA6IZNEA1R" + }, +]; + +const listInstanceGroups= [ + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 2, + + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 2, + "RunningInstanceCount": 3, + + }, + ], + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 2, + + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 4, + }, + ], + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "c1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 0, + }, + ], + + }, + { + "InstanceGroups": [ + { + "Id": "ig-2XSMNS4YJGOX4", + "Name": "Core - 2", + "Market": "ON_DEMAND", + "InstanceGroupType": "CORE", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 2, + }, + { + "Id": "ig-AIE5QGDR3LE1", + "Name": "Master - 1", + "Market": "ON_DEMAND", + "InstanceGroupType": "MASTER", + "InstanceType": "m1.medium", + "RequestedInstanceCount": 1, + "RunningInstanceCount": 1, + }, + ] + }, +]; + +const createCache = (instances, listInstanceGroups) => { + if (listClusters && listClusters.length) var id = listClusters[0].Id; + return { + emr:{ + listClusters: { + 'us-east-1': { + data: instances + }, + }, + listInstanceGroups: { + 'us-east-1': { + [id]: { + data: listInstanceGroups + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + emr:{ + listClusters: { + 'us-east-1': { + err: { + message: 'error describing instances' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + emr:{ + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('emrInstanceCount', function () { + describe('run', function () { + it('should PASS if instances are within the regional and global expected count', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[2]); + var settings = { + emr_instance_count_global_threshold: 2, + emr_instance_count_regional_threshold: 1 + }; + + emrInstanceCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instances are not in the regional expected count', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[1]); + var settings = { + emr_instance_count_global_threshold: 2, + emr_instance_count_regional_threshold: 1 + }; + + emrInstanceCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if instances are not in the global expected count', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[0]); + var settings = { + emr_instance_count_global_threshold: 2, + emr_instance_count_regional_threshold: 2 + }; + + emrInstanceCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if instances are not in the regional and global expected count', function (done) { + const cache = createCache([listClusters[0]], listInstanceGroups[3]); + var settings = { + emr_instance_count_global_threshold: 1, + emr_instance_count_regional_threshold: 1 + }; + + emrInstanceCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no instances found', function (done) { + const cache = createCache([]); + emrInstanceCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if unable to list instance groups', function (done) { + const cache = createErrorCache(); + emrInstanceCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should PASS if instance groups response not found', function (done) { + const cache = createNullCache(); + emrInstanceCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/es/esEncryptedDomain.js b/plugins/aws/es/esEncryptedDomain.js deleted file mode 100644 index 7151eed8a..000000000 --- a/plugins/aws/es/esEncryptedDomain.js +++ /dev/null @@ -1,66 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/aws'); - -module.exports = { - title: 'ElasticSearch Encrypted Domain', - category: 'ES', - description: 'Ensures ElasticSearch domains are encrypted with KMS', - more_info: 'ElasticSearch domains should be encrypted to ensure data at rest is secured.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/encryption-at-rest.html', - recommended_action: 'Ensure encryption-at-rest is enabled for all ElasticSearch domains.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var regions = helpers.regions(settings); - - async.each(regions.es, function(region, rcb) { - var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); - - if (!listDomainNames) return rcb(); - - if (listDomainNames.err || !listDomainNames.data) { - helpers.addResult( - results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); - return rcb(); - } - - if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); - return rcb(); - } - - listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); - - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { - helpers.addResult( - results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); - } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; - - if (localDomain.EncryptionAtRestOptions && - localDomain.EncryptionAtRestOptions.Enabled) { - helpers.addResult(results, 0, - 'ES domain is configured to use encryption at rest', region, localDomain.ARN); - } else { - helpers.addResult(results, 2, - 'ES domain is not configured to use encryption at rest', region, localDomain.ARN); - } - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; diff --git a/plugins/aws/es/esHttpsOnly.js b/plugins/aws/es/esHttpsOnly.js deleted file mode 100644 index c47c2eff1..000000000 --- a/plugins/aws/es/esHttpsOnly.js +++ /dev/null @@ -1,66 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/aws'); - -module.exports = { - title: 'ElasticSearch HTTPS Only', - category: 'ES', - description: 'Ensures ElasticSearch domains are configured to enforce HTTPS connections', - more_info: 'ElasticSearch domains should be configured to enforce HTTPS connections for all clients to ensure encryption of data in transit.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html', - recommended_action: 'Ensure HTTPS connections are enforced for all ElasticSearch domains.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var regions = helpers.regions(settings); - - async.each(regions.es, function(region, rcb) { - var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); - - if (!listDomainNames) return rcb(); - - if (listDomainNames.err || !listDomainNames.data) { - helpers.addResult( - results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); - return rcb(); - } - - if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); - return rcb(); - } - - listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); - - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { - helpers.addResult( - results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); - } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; - - if (localDomain.DomainEndpointOptions && - localDomain.DomainEndpointOptions.EnforceHTTPS) { - helpers.addResult(results, 0, - 'ES domain is configured to enforce HTTPS', region, localDomain.ARN); - } else { - helpers.addResult(results, 2, - 'ES domain is not configured to enforce HTTPS', region, localDomain.ARN); - } - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; diff --git a/plugins/aws/es/esNodeToNodeEncryption.js b/plugins/aws/es/esNodeToNodeEncryption.js deleted file mode 100644 index 9a81447b1..000000000 --- a/plugins/aws/es/esNodeToNodeEncryption.js +++ /dev/null @@ -1,66 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/aws'); - -module.exports = { - title: 'ElasticSearch Node To Node Encryption', - category: 'ES', - description: 'Ensures ElasticSearch domain traffic is encrypted in transit between nodes', - more_info: 'ElasticSearch domains should use node-to-node encryption to ensure data in transit remains encrypted using TLS 1.2.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/ntn.html', - recommended_action: 'Ensure node-to-node encryption is enabled for all ElasticSearch domains.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var regions = helpers.regions(settings); - - async.each(regions.es, function(region, rcb) { - var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); - - if (!listDomainNames) return rcb(); - - if (listDomainNames.err || !listDomainNames.data) { - helpers.addResult( - results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); - return rcb(); - } - - if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); - return rcb(); - } - - listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); - - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { - helpers.addResult( - results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); - } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; - - if (localDomain.NodeToNodeEncryptionOptions && - localDomain.NodeToNodeEncryptionOptions.Enabled) { - helpers.addResult(results, 0, - 'ES domain is configured to use node-to-node encryption', region, localDomain.ARN); - } else { - helpers.addResult(results, 2, - 'ES domain is not configured to use node-to-node encryption', region, localDomain.ARN); - } - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; diff --git a/plugins/aws/es/esPublicEndpoint.js b/plugins/aws/es/esPublicEndpoint.js deleted file mode 100644 index e0f17d0aa..000000000 --- a/plugins/aws/es/esPublicEndpoint.js +++ /dev/null @@ -1,108 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/aws'); - -module.exports = { - title: 'ElasticSearch Public Service Domain', - category: 'ES', - description: 'Ensures ElasticSearch domains are created with private VPC endpoint options', - more_info: 'ElasticSearch domains can either be created with a public endpoint or with a VPC configuration that enables internal VPC communication. Domains should be created without a public endpoint to prevent potential public access to the domain.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-vpc.html', - recommended_action: 'Configure the ElasticSearch domain to use a VPC endpoint for secure VPC communication.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], - settings: { - allow_es_public_endpoint_if_ip_condition_policy: { - name: 'Allow Public Only If IP Condition Policy or Restricted Principal', - description: 'Allows public ElasticSearch endpoints if set to true and if there is an IP Condition policy and/or a restricted non-star principal.', - regex: '^(true|false)$', - default: 'false' - }, - }, - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var regions = helpers.regions(settings); - var config = { - allow_es_public_endpoint_if_ip_condition_policy: settings.allow_es_public_endpoint_if_ip_condition_policy || this.settings.allow_es_public_endpoint_if_ip_condition_policy.default - }; - - config.allow_es_public_endpoint_if_ip_condition_policy = (config.allow_es_public_endpoint_if_ip_condition_policy === 'true' || config.allow_es_public_endpoint_if_ip_condition_policy === true); - - async.each(regions.es, function(region, rcb) { - var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); - - if (!listDomainNames) return rcb(); - - if (listDomainNames.err || !listDomainNames.data) { - helpers.addResult( - results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); - return rcb(); - } - - if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); - return rcb(); - } - - listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); - - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { - helpers.addResult( - results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); - } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; - - // assume we have no bad policies - var validPolicy = true; - - if (config.allow_es_public_endpoint_if_ip_condition_policy && - localDomain.AccessPolicies) { // evaluate policies if the setting is enabled. - var policies = helpers.normalizePolicyDocument(localDomain.AccessPolicies); - if (!policies) policies = []; // if no policy document then no statements - - for (var p in policies) { - var policy = policies[p]; - var containsIpPolicy = policy.Condition && policy.Condition.IpAddress; - - if (!containsIpPolicy && helpers.globalPrincipal(policy.Principal)) { - validPolicy = false; - } - } - } - - if (localDomain.VPCOptions && - localDomain.VPCOptions.VPCId && - localDomain.VPCOptions.VPCId.length) { - helpers.addResult(results, 0, - 'ES domain is configured to use a VPC endpoint', region, localDomain.ARN); - } else { - if (config.allow_es_public_endpoint_if_ip_condition_policy) { - if (validPolicy) { - helpers.addResult(results, 0, - 'ES domain is configured to use a public endpoint, but is allowed since there are no public access policies.', region, localDomain.ARN); - } else { - helpers.addResult(results, 2, - 'ES domain is configured to use a public endpoint and has disallowed public access policies.', region, localDomain.ARN); - } - } else { - helpers.addResult(results, 2, - 'ES domain is configured to use a public endpoint.', region, localDomain.ARN); - } - } - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; diff --git a/plugins/aws/es/esUpgradeAvailable.js b/plugins/aws/es/esUpgradeAvailable.js deleted file mode 100644 index 677bab9c1..000000000 --- a/plugins/aws/es/esUpgradeAvailable.js +++ /dev/null @@ -1,69 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/aws'); - -module.exports = { - title: 'ElasticSearch Upgrade Available', - category: 'ES', - description: 'Ensures ElasticSearch domains are running the latest service software', - more_info: 'ElasticSearch domains should be configured to run the latest service software which often contains security updates.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-version-migration.html', - recommended_action: 'Ensure each ElasticSearch domain is running the latest service software and update out-of-date domains.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var regions = helpers.regions(settings); - - async.each(regions.es, function(region, rcb) { - var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); - - if (!listDomainNames) return rcb(); - - if (listDomainNames.err || !listDomainNames.data) { - helpers.addResult( - results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); - return rcb(); - } - - if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); - return rcb(); - } - - listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); - - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { - helpers.addResult( - results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); - } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; - var currentVersion = localDomain.ServiceSoftwareOptions.CurrentVersion; - var upgradeVersion = localDomain.ServiceSoftwareOptions.NewVersion; - var upgradeAvailable = localDomain.ServiceSoftwareOptions.UpdateAvailable; - var upgradeStatus = localDomain.ServiceSoftwareOptions.UpdateStatus; - - if (upgradeAvailable && upgradeStatus !== 'NOT_ELIGIBLE') { - helpers.addResult(results, 2, - 'ES domain service software version: ' + currentVersion + ' is eligible for an upgrade to version: ' + upgradeVersion, region, localDomain.ARN); - } else { - helpers.addResult(results, 0, - 'ES domain service software version: ' + currentVersion + ' is the latest eligible upgraded version', region, localDomain.ARN); - } - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; diff --git a/plugins/aws/eventbridge/eventBusCrossAccountAccess.js b/plugins/aws/eventbridge/eventBusCrossAccountAccess.js new file mode 100644 index 000000000..ee831821c --- /dev/null +++ b/plugins/aws/eventbridge/eventBusCrossAccountAccess.js @@ -0,0 +1,156 @@ +var helpers = require('../../../helpers/aws'); +var async = require('async'); + +module.exports = { + title: 'Event Bus Cross Account Access', + category: 'EventBridge', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that EventBridge event bus is configured to allow access to whitelisted AWS account principals.', + more_info: 'EventBridge event bus policy should be configured to allow access only to whitelisted/trusted cross-account principals.', + link: 'https://docs.amazonaws.cn/en_us/eventbridge/latest/userguide/eb-event-bus-perms.html', + recommended_action: 'Configure EventBridge event bus policies that allow access to whitelisted/trusted cross-account principals.', + apis: ['EventBridge:listEventBuses', 'STS:getCallerIdentity', 'Organizations:listAccounts'], + settings: { + eventbridge_whitelisted_aws_account_principals: { + name: 'Whitelisted AWS Account Principals', + description: 'A comma-separated list of trusted cross account principals', + regex: '^.*$', + default: '' + }, + eventbridge_whitelisted_aws_account_principals_regex: { + name: 'Whitelisted AWS Account Principals Regex', + description: 'If set, plugin will compare cross account principals against this regex instead of otherwise given comma-separated list' + + 'Example regex: ^arn:aws:iam::(111111111111|222222222222|):.+$', + regex: '^.*$', + default: '' + }, + eventbridge_whitelist_aws_organization_accounts: { + name: 'Whitelist AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + }, + eventbridge_policy_condition_keys: { + name: 'EventBridge Event Bus Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount,aws:PrincipalArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner' + }, + }, + realtime_triggers: ['eventbridge:CreateEventBus','eventbridge:PutPermission', 'eventbridge:DeleteEventBus'], + + run: function(cache, settings, callback) { + var config= { + eventbridge_whitelisted_aws_account_principals : settings.eventbridge_whitelisted_aws_account_principals || this.settings.eventbridge_whitelisted_aws_account_principals.default, + eventbridge_whitelisted_aws_account_principals_regex : settings.eventbridge_whitelisted_aws_account_principals_regex || this.settings.eventbridge_whitelisted_aws_account_principals_regex.default, + eventbridge_whitelist_aws_organization_accounts: settings.eventbridge_whitelist_aws_organization_accounts || this.settings.eventbridge_whitelist_aws_organization_accounts.default, + eventbridge_policy_condition_keys: settings.eventbridge_policy_condition_keys || this.settings.eventbridge_policy_condition_keys.default, + }; + var allowedConditionKeys = config.eventbridge_policy_condition_keys.split(','); + var makeRegexBased = (config.eventbridge_whitelisted_aws_account_principals_regex.length) ? true : false; + var whitelistOrganization = (config.eventbridge_whitelist_aws_organization_accounts == 'true'); + config.eventbridge_whitelisted_aws_account_principals_regex = new RegExp(config.eventbridge_whitelisted_aws_account_principals_regex); + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', regions.default, 'data']); + + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', defaultRegion]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, defaultRegion); + return callback(null, results, source); + } + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + + async.each(regions.eventbridge, function(region, rcb){ + var listEventBuses = helpers.addSource(cache, source, + ['eventbridge', 'listEventBuses', region]); + + if (!listEventBuses) return rcb(); + + if (listEventBuses.err || !listEventBuses.data) { + helpers.addResult(results, 3, + `Unable to query for Event Bus: ${helpers.addError(listEventBuses)}`, region); + return rcb(); + } + + if (!listEventBuses.data.length) { + helpers.addResult(results, 0, 'No Event Buses found', region); + return rcb(); + } + + listEventBuses.data.forEach(eventBus => { + if (!eventBus.Arn) return; + + if (!eventBus.Policy) { + helpers.addResult(results, 0, 'Event bus does not use custom policy', region, eventBus.Arn); + return; + } + + var statements = helpers.normalizePolicyDocument(eventBus.Policy); + + if (!statements){ + helpers.addResult(results, 0, 'No statement exists for the policy', region, eventBus.Arn); + return; + } + var restrictedAccountPrincipals = []; + var crossAccountEventBus = false; + + statements.forEach(statement => { + if (!statement.Principal) return; + + let conditionalPrincipals = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, true, accountId, settings); + if (helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings) || + (conditionalPrincipals && conditionalPrincipals.length)) { + let crossAccountPrincipals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + + if (conditionalPrincipals && conditionalPrincipals.length) { + conditionalPrincipals.forEach(conPrincipal => { + if (!conPrincipal.includes(accountId)) crossAccountPrincipals.push(conPrincipal); + }); + } + + if (!crossAccountPrincipals.length) return; + + crossAccountEventBus = true; + crossAccountPrincipals.forEach(principal => { + if (whitelistOrganization) { + if (organizationAccounts.find(account => principal.includes(account))) return; + } + if (makeRegexBased) { + if (!config.eventbridge_whitelisted_aws_account_principals_regex.test(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + } else if (!config.eventbridge_whitelisted_aws_account_principals.includes(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + }); + } + }); + + if (crossAccountEventBus && !restrictedAccountPrincipals.length) { + helpers.addResult(results, 0, + 'Event bus contains trusted account principals only', region, eventBus.Arn); + } else if (crossAccountEventBus) { + helpers.addResult(results, 2, + `Event bus contains these untrusted account principals: ${restrictedAccountPrincipals.join(', ')}`, + region, eventBus.Arn); + } else { + helpers.addResult(results, 0, + 'Event bus does not contain cross-account policy statement', region, eventBus.Arn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/eventbridge/eventBusCrossAccountAccess.spec.js b/plugins/aws/eventbridge/eventBusCrossAccountAccess.spec.js new file mode 100644 index 000000000..c39e6d02a --- /dev/null +++ b/plugins/aws/eventbridge/eventBusCrossAccountAccess.spec.js @@ -0,0 +1,149 @@ +const expect = require('chai').expect; +var eventBusCrossAccountAccess = require('./eventBusCrossAccountAccess'); + +const listEventBuses = [ + { + Name: 'test-bus', + Arn: 'arn:aws:events:us-east-1:211111111111:event-bus/test-bus', + Policy: '{"Version":"2012-10-17","Statement":[{"Sid":"allow_account_to_put_events","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::211111111111:user/y"},"Action":"events:PutEvents","Resource":"arn:aws:events:us-east-1:111111111111:event-bus/test-bus"}]}' + }, + { + Name: 'test-bus', + Arn: 'arn:aws:events:us-east-1:111111111111:event-bus/test-bus', + Policy: '{"Version":"2012-10-17","Statement":[{"Sid":"allow_account_to_put_events","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::111111111111:user/x"},"Action":"events:PutEvents","Resource":"arn:aws:events:us-east-1:111111111111:event-bus/test-bus"}]}' + }, + { + Name: 'test-bus', + Arn: 'arn:aws:events:us-east-1:111111111111:event-bus/test-bus', + }, +]; + +const organizationAccounts = [ + { + "Id": "211111111111", + "Arn": "arn:aws:organizations::211111111111:account/o-sb9qmv2zif/111111111111", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + }, + { + "Id": "123456654322", + "Arn": "arn:aws:organizations::123456654322:account/o-sb9qmv2zif/123456654322", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + } +] + +const createCache = (eventBus, accounts, eventBusErr) => { + return { + eventbridge: { + listEventBuses: { + 'us-east-1': { + data: eventBus, + err: eventBusErr + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1':{ + data: '111111111111' + } + } + }, + organizations: { + listAccounts: { + 'us-east-1': { + data: accounts + } + } + } + }; +}; + +const createNullCache = () => { + return { + eventbridge: { + listEventBuses: { + 'us-east-1': null + } + } + }; +}; + +describe('eventBusCrossAccountAccess', function () { + describe('run', function () { + + it('should PASS if Event Bus has cross-account access policy attached', function (done) { + const cache = createCache([listEventBuses[0]]); + eventBusCrossAccountAccess.run(cache, {"eventbridge_whitelisted_aws_account_principals":['arn:aws:iam::211111111111:user/y']}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if cross-account role contains organization account ID and setting to allow organization account is true', function (done) { + const cache = createCache([listEventBuses[0]], [organizationAccounts[0]]); + eventBusCrossAccountAccess.run(cache, { "eventbridge_whitelist_aws_organization_accounts": "true" }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Event Bus does not have cross-account access policy attached', function (done) { + const cache = createCache([listEventBuses[1]]); + eventBusCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Event Bus policy found', function (done) { + const cache = createCache(listEventBuses[2]); + eventBusCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Event Bus found', function (done) { + const cache = createCache([]); + eventBusCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query event bus', function (done) { + const cache = createCache([], {},{ message: 'Unable to list event bus' }); + eventBusCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if query to list event bus response not found', function (done) { + const cache = createNullCache(); + eventBusCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/eventbridge/eventBusPublicAccess.js b/plugins/aws/eventbridge/eventBusPublicAccess.js new file mode 100644 index 000000000..8d592a9ff --- /dev/null +++ b/plugins/aws/eventbridge/eventBusPublicAccess.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Event Bus Public Access', + category: 'EventBridge', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that EventBridge event bus is configured to prevent exposure to public access.', + more_info: 'The default event bus in your Amazon account only allows events from one account. You can grant additional permissions to an event bus by attaching a resource-based policy to it.', + link: 'https://docs.amazonaws.cn/en_us/eventbridge/latest/userguide/eb-event-bus-perms.html', + recommended_action: 'Configure EventBridge event bus policies that allow access to whitelisted/trusted account principals but not public access.', + apis: ['EventBridge:listEventBuses', 'STS:getCallerIdentity'], + settings: { + event_bus_policy_condition_keys: { + name: 'Event Bus Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount, aws:SourceArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceOwner,aws:SourceArn,aws:SourceAccount' + } + }, + realtime_triggers: ['eventbridge:CreateEventBus', 'eventbridge:PutRule', 'eventbridge:PutTarget','eventbridge:PutPermission', 'eventbridge:DeleteEventBus', 'eventbridge:DeleteRule'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var config = { + event_bus_policy_condition_keys: settings.event_bus_policy_condition_keys || this.settings.event_bus_policy_condition_keys.default + }; + config.event_bus_policy_condition_keys = config.event_bus_policy_condition_keys.replace(/\s/g, ''); + var allowedConditionKeys = config.event_bus_policy_condition_keys.split(','); + + async.each(regions.eventbridge, function(region, rcb){ + var listEventBuses = helpers.addSource(cache, source, + ['eventbridge', 'listEventBuses', region]); + + if (!listEventBuses) return rcb(); + + if (listEventBuses.err || !listEventBuses.data) { + helpers.addResult(results, 3, + 'Unable to list event bus: ' + helpers.addError(listEventBuses), region); + return rcb(); + } + + if (!listEventBuses.data.length) { + helpers.addResult(results, 0, 'No Event buses found', region); + return rcb(); + } + + listEventBuses.data.forEach(eventBus => { + if (!eventBus.Arn) return; + + if (!eventBus.Policy) { + helpers.addResult(results, 0, 'Event bus does not use custom policy', region, eventBus.Arn); + return; + } + + var statements = helpers.normalizePolicyDocument(eventBus.Policy); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'Event bus policy does not have statements', + region, eventBus.Arn); + return; + } + + var publicActions = []; + + for (var statement of statements) { + var effectEval = (statement.Effect && statement.Effect == 'Allow' ? true : false); + var principalEval = helpers.globalPrincipal(statement.Principal, settings); + let scopedCondition; + if (statement.Condition) scopedCondition = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, false, accountId, settings); + + if (!scopedCondition && principalEval && effectEval) { + if (statement.Action && typeof statement.Action === 'string') { + if (publicActions.indexOf(statement.Action) === -1) { + publicActions.push(statement.Action); + } + } else if (statement.Action && statement.Action.length) { + for (var a in statement.Action) { + if (publicActions.indexOf(statement.Action[a]) === -1) { + publicActions.push(statement.Action[a]); + } + } + } + } + } + + if (publicActions.length) { + helpers.addResult(results, 2, + 'Event bus policy is exposed to everyone' , + region, eventBus.Arn); + } else { + helpers.addResult(results, 0, + 'Event bus policy is not exposed to everyone', + region, eventBus.Arn); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/eventbridge/eventBusPublicAccess.spec.js b/plugins/aws/eventbridge/eventBusPublicAccess.spec.js new file mode 100644 index 000000000..eab872497 --- /dev/null +++ b/plugins/aws/eventbridge/eventBusPublicAccess.spec.js @@ -0,0 +1,117 @@ +const expect = require('chai').expect; +var eventBusPublicAccess = require('./eventBusPublicAccess'); + +const listEventBuses = [ + { + Name: "default", + Arn: "arn:aws:events:us-east-1:000011112222:event-bus/default", + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"allow_all_accounts_from_organization_to_put_events\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"events:PutEvents\",\"Resource\":\"arn:aws:events:us-east-1:000011112222:event-bus/default\"}]}" + }, + { + Name: "mine1", + Arn: "arn:aws:events:us-east-1:000011112222:event-bus/mine1", + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"allow_all_accounts_from_organization_to_put_events\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"000011112222\"},\"Action\":\"events:PutEvents\",\"Resource\":\"arn:aws:events:us-east-1:000011112222:event-bus/mine1\",\"Condition\":{\"StringEquals\":{\"aws:PrincipalOrgID\":\"o-lcjto3x5wd\"}}}]}" + }, + { + Name: 'test-bus', + Arn: 'arn:aws:events:us-east-1:111111111111:event-bus/test-bus', + }, + { + Name: "mine2", + Arn: "arn:aws:events:us-east-1:000011112222:event-bus/mine2", + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"allow_all_accounts_from_organization_to_put_events\",\"Effect\":\"Allow\",\"Principal\":{\"*\"},\"Action\":\"events:PutEvents\",\"Resource\":\"arn:aws:events:us-east-1:000011112222:event-bus/mine2\",\"Condition\":{\"StringEquals\":{\"aws:PrincipalOrgID\":\"o-lcjto3x5wd\"}}}]}" + } +]; + +const createCache = (eventBus, eventBusErr) => { + return { + eventbridge: { + listEventBuses: { + 'us-east-1': { + data: eventBus, + err: eventBusErr + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1':{ + data: '111111111111' + } + } + }, + }; +}; + +const createNullCache = () => { + return { + eventbridge: { + listEventBuses: { + 'us-east-1': null + } + } + }; +}; + +describe('eventBusPublicAccess', function () { + describe('run', function () { + it('should PASS if Event bus policy is not exposed to everyone', function (done) { + const cache = createCache([listEventBuses[1], listEventBuses[3]]); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[1].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Event bus policy is exposed to everyone ', function (done) { + const cache = createCache([listEventBuses[0]]); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Event bus does not use custom policy', function (done) { + const cache = createCache([listEventBuses[2]]); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Event Bus found', function (done) { + const cache = createCache([]); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list event bus', function (done) { + const cache = createCache([],{ message: 'Unable to list event bus' }); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if query to list event bus response not found', function (done) { + const cache = createNullCache(); + eventBusPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/eventbridge/eventsInUse.js b/plugins/aws/eventbridge/eventsInUse.js new file mode 100644 index 000000000..b6db257d4 --- /dev/null +++ b/plugins/aws/eventbridge/eventsInUse.js @@ -0,0 +1,43 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EventBridge Event Rules In Use', + category: 'EventBridge', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that Amazon EventBridge Events service is in use in order to enable you to react selectively and efficiently to system events.', + more_info: 'Amazon EventBridge Events delivers a near real-time stream of system events that describe changes in Amazon Web Services (AWS) resources. Using simple rules that you can quickly set up, you can match events and route them to one or more target functions or streams.', + link: 'https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rules.html', + recommended_action: 'Create EventBridge event rules to meet regulatory and compliance requirement within your organization.', + apis: ['EventBridge:listRules'], + realtime_triggers: ['eventbridge:PutRule', 'eventbridge:EnableRule', 'eventbridge:DeleteRule'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listRules = helpers.addSource(cache, source, + ['eventbridge', 'listRules', region]); + + if (!listRules) return callback(null, results, source); + + if (listRules.err || !listRules.data) { + helpers.addResult(results, 3, + 'Unable to list EventBridge event rules: ' + helpers.addError(listRules), region); + return callback(null, results, source); + } + + if (listRules.data.length) { + helpers.addResult(results, 0, + 'EventBridge event rules are in use', + region); + } else { + helpers.addResult(results, 2, + 'EventBridge event rules are not in use', + region); + } + + return callback(null, results, source); + } +}; diff --git a/plugins/aws/eventbridge/eventsInUse.spec.js b/plugins/aws/eventbridge/eventsInUse.spec.js new file mode 100644 index 000000000..40cac7d89 --- /dev/null +++ b/plugins/aws/eventbridge/eventsInUse.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +const eventsInUse = require('./eventsInUse'); + +const listRules = [ + { + Name: 'AutoScalingManagedRule', + Arn: 'arn:aws:events:us-east-1:000011112222:rule/AutoScalingManagedRule', + EventPattern: '{"source":["aws.ec2"],"detail-type":["EC2 Instance Rebalance Recommendation","EC2 Spot Instance Interruption Warning"]}', + State: 'ENABLED', + Description: 'This rule is used to route Instance notifications to EC2 Auto Scaling', + ManagedBy: 'autoscaling.amazonaws.com', + EventBusName: 'default' + }, + {} +]; + +const createCache = (data, err) => { + return { + eventbridge: { + listRules: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +const createNullCache = () => { + return { + eventbridge: { + listRules: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('eventsInUse', function () { + describe('run', function () { + it('should PASS if EventBridge event rules are in use', function (done) { + const cache = createCache([listRules[0]]); + eventsInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EventBridge event rules are in use') + done(); + }); + }); + + it('should FAIL if EventBridge event rules are not in use', function (done) { + const cache = createCache(listRules[1]); + eventsInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EventBridge event rules are not in use') + done(); + }); + }); + + it('should UNKNOWN if Unable to list EventBridge events rules', function (done) { + const cache = createCache(null, { message: 'Unable to list EventBridge event rules' }); + eventsInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list EventBridge event rules') + done(); + }); + }); + + it('should not return any results if list EventBridge event rules response not found', function (done) { + const cache = createNullCache(); + eventsInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/finspace/finspaceEnvironmentEncrypted.js b/plugins/aws/finspace/finspaceEnvironmentEncrypted.js new file mode 100644 index 000000000..fb8aff20a --- /dev/null +++ b/plugins/aws/finspace/finspaceEnvironmentEncrypted.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'FinSpace Environment Encrypted', + category: 'FinSpace', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS FinSpace Environments are using desired encryption level.', + more_info: 'Amazon FinSpace is a fully managed data management and analytics service that makes it easy to store, catalog, and prepare financial industry data at scale.' + + 'To encrypt this data, use a KMS key with desired encrypted level to meet regulatory compliance requirements within your organization.', + recommended_action: 'Create FinSpace Environment with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/finspace/latest/userguide/data-encryption.html', + apis: ['Finspace:listEnvironments', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + finspace_environment_desired_encryption_level: { + name: 'FinSpace Environment Desired Encryption Level', + description: 'In order (lowest to highest) awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awscmk|externalcmk|cloudhsm)$', + default: 'awscmk' + } + }, + realtime_triggers: ['finspace:CreateEnviromennt', 'finspace:DeleteEnviroment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.finspace_environment_desired_encryption_level || this.settings.finspace_environment_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.ecr, function(region, rcb){ + var listEnvironments = helpers.addSource(cache, source, + ['finspace', 'listEnvironments', region]); + + if (!listEnvironments) return rcb(); + + if (listEnvironments.err || !listEnvironments.data) { + helpers.addResult(results, 3, `Unable to query FinSpace Environment: ${helpers.addError(listEnvironments)}`, region); + return rcb(); + } + + + if (!listEnvironments.data.length) { + helpers.addResult(results, 0, 'No FinSpace Environment found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let environment of listEnvironments.data) { + if (!environment.environmentArn) continue; + + let resource = environment.environmentArn; + + if (environment.kmsKeyId) { + var keyId = environment.kmsKeyId.split('/')[1] ? environment.kmsKeyId.split('/')[1] : environment.kmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, environment.kmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + helpers.addResult(results, 3, + 'Unable to find encryption key for environment', region, resource); + continue; + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `FinSpace environment is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `FinSpace environment is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/finspace/finspaceEnvironmentEncrypted.spec.js b/plugins/aws/finspace/finspaceEnvironmentEncrypted.spec.js new file mode 100644 index 000000000..b1a2a23f5 --- /dev/null +++ b/plugins/aws/finspace/finspaceEnvironmentEncrypted.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +var finspaceEnvironmentEncrypted = require('./finspaceEnvironmentEncrypted'); + +const listEnvironments = [ + { + "name": "sadeed1", + "environmentId": "yk7kg7l6ab3yr42gse4jv3", + "awsAccountId": "000011112222", + "status": "CREATED", + "environmentUrl": "yk7kg7l6ab3yr42gse4jv3.us-east-1.amazonfinspace.com", + "environmentArn": "arn:aws:finspace:us-east-1:000011112222:environment/yk7kg7l6ab3yr42gse4jv3", + "sageMakerStudioDomainUrl": "https://d-7mzarbqaxxay.studio.us-east-1.sagemaker.aws", + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "dedicatedServiceAccountId": "000011112222", + "federationMode": "LOCAL", + "federationParameters": {} + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (environments, keys, describeKey, environmentsErr, keysErr, describeKeyErr) => { + var keyId = (environments && environments.length && environments[0].kmsKeyId) ? environments[0].kmsKeyId.split('/')[1] : null; + return { + finspace: { + listEnvironments: { + 'us-east-1': { + err: environmentsErr, + data: environments + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + + +describe('finspaceEnvironmentEncrypted', function () { + describe('run', function () { + it('should PASS if FinSpace Environment is encrypted with desired encryption level', function (done) { + const cache = createCache([listEnvironments[0]], listKeys, describeKey[0]); + finspaceEnvironmentEncrypted.run(cache, { finspace_environment_encryption: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('FinSpace environment is encrypted with awscmk'); + done(); + }); + }); + + it('should FAIL if FinSpace Environment is not encrypted with desired encyption level', function (done) { + const cache = createCache([listEnvironments[0]], listKeys, describeKey[1]); + finspaceEnvironmentEncrypted.run(cache, { finspace_environment_encryption: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('FinSpace environment is encrypted with awskms'); + done(); + }); + }); + + it('should PASS if no FinSpace Environment found', function (done) { + const cache = createCache([]); + finspaceEnvironmentEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No FinSpace Environment found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list FinSpace Environment', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list FinSpace Environment encryption" }); + finspaceEnvironmentEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + finspaceEnvironmentEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/firehose/deliveryStreamEncrypted.js b/plugins/aws/firehose/deliveryStreamEncrypted.js new file mode 100644 index 000000000..c7579cebe --- /dev/null +++ b/plugins/aws/firehose/deliveryStreamEncrypted.js @@ -0,0 +1,181 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Firehose Delivery Stream Destination CMK Encrypted', + category: 'Firehose', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures Firehose delivery stream data records are encrypted at destination bucket using AWS KMS key of desired encryption level.', + more_info: 'Encrypting Kinesis Firehose delivery stream data records at the destination S3 bucket is crucial for compliance and data security. This ensures that data is protected at rest, meeting regulatory requirements and providing an additional layer of security, essential for organizations with strict data protection mandates.', + recommended_action: 'Enable encryption using desired level for all Firehose Delivery Streams destination S3 bucket.', + link: 'https://docs.aws.amazon.com/firehose/latest/dev/encryption.html', + apis: ['Firehose:listDeliveryStreams', 'Firehose:describeDeliveryStream', 'KMS:describeKey', 'KMS:listKeys', + 'STS:getCallerIdentity','S3:getBucketEncryption', 'S3:listBuckets'], + settings: { + delivery_stream_desired_encryption_level: { + name: 'Firehose Delivery Stream Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['firehose:CreateDeliveryStreams','firehose:UpdateDestination', 'firehose:DeleteliveryStreams'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.delivery_stream_desired_encryption_level || this.settings.delivery_stream_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + + async.each(regions.firehose, function(region, rcb){ + var listDeliveryStreams = helpers.addSource(cache, source, + ['firehose', 'listDeliveryStreams', region]); + + if (!listDeliveryStreams) return rcb(); + + if (listDeliveryStreams.err || !listDeliveryStreams.data) { + helpers.addResult(results, 3, + 'Unable to list Firehose delivery streams: ' + helpers.addError(listDeliveryStreams), region); + return rcb(); + } + + if (!listDeliveryStreams.data.length) { + helpers.addResult(results, 0, 'No Firehose delivery streams found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let stream of listDeliveryStreams.data) { + var resource = `arn:${awsOrGov}:firehose:${region}:${accountId}:deliverystream/${stream}`; + + var describeDeliveryStream = helpers.addSource(cache, source, + ['firehose', 'describeDeliveryStream', region, stream]); + + if (!describeDeliveryStream || describeDeliveryStream.err || !describeDeliveryStream.data ) { + helpers.addResult(results, 3, + 'Unable to query Firehose for delivery streams: ', + region, resource); + continue; + } + + let deliveryStreamDesc = describeDeliveryStream.data && describeDeliveryStream.data.DeliveryStreamDescription ? describeDeliveryStream.data.DeliveryStreamDescription : null; + let kmsKeyId; + + if (!deliveryStreamDesc || + !deliveryStreamDesc.Destinations || + !deliveryStreamDesc.Destinations[0] || + !deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription) { + helpers.addResult(results, 0, + 'The Firehose delivery stream does not have an S3 destination', + region, resource); + continue; + } + if (desiredEncryptionLevel === 2) { + helpers.addResult(results, 0, + `Firehose delivery stream is encrypted with awskms \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + if (deliveryStreamDesc && + deliveryStreamDesc.Destinations && + deliveryStreamDesc.Destinations[0] && + deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription && + deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.EncryptionConfiguration && + deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.EncryptionConfiguration.KMSEncryptionConfig && + deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.EncryptionConfiguration.KMSEncryptionConfig.AWSKMSKeyARN) { + + kmsKeyId = deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.EncryptionConfiguration.KMSEncryptionConfig.AWSKMSKeyARN; + processEncryptionLevels(kmsKeyId, region, resource); + + } else { + var bucketName = deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.BucketARN ? deliveryStreamDesc.Destinations[0].ExtendedS3DestinationDescription.BucketARN.split(':::')[1] : null; + var getBucketEncryption = helpers.addSource(cache, source, + ['s3', 'getBucketEncryption', region, bucketName]); + + if (getBucketEncryption && getBucketEncryption.err && + getBucketEncryption.err.code && getBucketEncryption.err.code == 'ServerSideEncryptionConfigurationNotFoundError') { + helpers.addResult(results, 2,'Firehose delivery stream destination bucket does not have encryption enable' ,region, resource); + continue; + } + if (!getBucketEncryption || getBucketEncryption.err || !getBucketEncryption.data) { + helpers.addResult(results, 3, + 'Error querying bucket encryption for: ' + bucketName + + ': ' + helpers.addError(getBucketEncryption), + region, resource); + + } else { + var encryption = getBucketEncryption.data.ServerSideEncryptionConfiguration && + getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules && + getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules[0] && + getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault? getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault : {}; + if (encryption.SSEAlgorithm && encryption.SSEAlgorithm === 'AES256') { + helpers.addResult(results, 2, + `Firehose delivery stream destination bucket is encrypted with awskms \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + kmsKeyId = encryption.KMSMasterKeyID ? encryption.KMSMasterKeyID : null; + processEncryptionLevels(kmsKeyId, region, resource); + } + } + } + } + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + function processEncryptionLevels(keyArn, region, resource) { + var currentEncryptionLevel; + + var keyId = keyArn && keyArn.split('/')[1] ? keyArn.split('/')[1] : ''; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, keyArn); + return; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Firehose delivery stream destination bucket is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Firehose delivery stream destination bucket is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + + } + } +}; + diff --git a/plugins/aws/firehose/deliveryStreamEncrypted.spec.js b/plugins/aws/firehose/deliveryStreamEncrypted.spec.js new file mode 100644 index 000000000..39ccf0b75 --- /dev/null +++ b/plugins/aws/firehose/deliveryStreamEncrypted.spec.js @@ -0,0 +1,372 @@ +var expect = require('chai').expect; +var deliveryStreamEncrypted = require('./deliveryStreamEncrypted'); + +const listDeliveryStreams = [ + "PUT-S3-YuZ2N" +]; + + +const describeDeliveryStream = [ + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "PUT-S3-YuZ2N", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:000011112222:deliverystream/PUT-S3-YuZ2N", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "2", + "CreateTimestamp": "2021-12-29T19:11:13.718000+05:00", + "LastUpdateTimestamp": "2021-12-29T19:39:12.160000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": { + "AWSKMSKeyARN": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": { + "AWSKMSKeyARN": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + }, + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "PUT-S3-YuZ2N", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:000011112222:deliverystream/PUT-S3-YuZ2N", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "4", + "CreateTimestamp": "2021-12-29T19:11:13.718000+05:00", + "LastUpdateTimestamp": "2021-12-29T19:50:27.485000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": { + "AWSKMSKeyARN": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": { + "AWSKMSKeyARN": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + }, + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "PUT-S3-YuZ2N", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:000011112222:deliverystream/PUT-S3-YuZ2N", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "3", + "CreateTimestamp": "2021-12-29T19:11:13.718000+05:00", + "LastUpdateTimestamp": "2021-12-29T19:40:18.084000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::000011112222:role/service-role/KinesisFirehoseServiceRole-PUT-S3-YuZ2N-us-east-1-1640786994734", + "BucketARN": "arn:aws:s3:::amazon-connect-5bc142a71067", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/PUT-S3-YuZ2N", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, +] + +const createCache = (streams, keys, describeDeliveryStream, describeKey, streamsErr, keysErr, describeKeyErr, describeDeliveryStreamErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var stream = (streams && streams.length) ? streams[0]: null; + return { + firehose: { + listDeliveryStreams: { + 'us-east-1': { + err: streamsErr, + data: streams + }, + }, + describeDeliveryStream: { + 'us-east-1': { + [stream]: { + data: describeDeliveryStream, + err: describeDeliveryStreamErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('deliveryStreamEncrypted', function () { + describe('run', function () { + it('should PASS if Firehose Delivery Stream is encrypted with desired encryption level', function (done) { + const cache = createCache([listDeliveryStreams[0]], listKeys, describeDeliveryStream[0], describeKey[0]); + deliveryStreamEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Firehose delivery stream is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Firehose Delivery Stream not encrypted with desired encryption level', function (done) { + const cache = createCache([listDeliveryStreams[0]], listKeys, describeDeliveryStream[1], describeKey[1]); + deliveryStreamEncrypted.run(cache, {delivery_stream_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Firehose delivery stream destination bucket is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if no Firehose Delivery Streams found', function (done) { + const cache = createCache([]); + deliveryStreamEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Firehose delivery streams found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Firehose Delivery Streams', function (done) { + const cache = createCache(null, null, null, null, { message: "Unable to list Firehose Delivery Streams" }); + deliveryStreamEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list Firehose delivery streams'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query Firehose for delivery streams', function (done) { + const cache = createCache([listDeliveryStreams[0]], listKeys, null, null, null, null, null, { message: "query Firehose for delivery streams" }); + deliveryStreamEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Firehose for delivery streams'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDeliveryStreams, null, null, null, null, { message: "Unable to list KMS keys" }); + deliveryStreamEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/firehose/firehoseEncrypted.js b/plugins/aws/firehose/firehoseEncrypted.js index 793158136..f4df3edce 100644 --- a/plugins/aws/firehose/firehoseEncrypted.js +++ b/plugins/aws/firehose/firehoseEncrypted.js @@ -6,6 +6,8 @@ var defaultKmsKey = 'alias/aws/firehose'; module.exports = { title: 'Firehose Delivery Streams Encrypted', category: 'Firehose', + domain: 'Content Delivery', + severity: 'High', description: 'Ensures Firehose Delivery Stream encryption is enabled', more_info: 'Data sent through Firehose Delivery Streams can be encrypted using KMS server-side encryption. Existing delivery streams can be modified to add encryption with minimal overhead.', recommended_action: 'Enable encryption using KMS for all Firehose Delivery Streams.', @@ -16,6 +18,7 @@ module.exports = { 'AWS KMS encryption ensures that the Firehose payload meets the ' + 'encryption in transit and at rest requirements of HIPAA.' }, + realtime_triggers: ['firehose:CreateDeliveryStreams','firehose:StartDeliveryStreamEncryption', 'kinesis:StartStreamEncryption', 'firehose:DeleteliveryStreams'], run: function(cache, settings, callback) { var results = []; @@ -30,7 +33,7 @@ module.exports = { if (listDeliveryStreams.err) { helpers.addResult(results, 3, - 'Unable to query for firehose delivery streams: ' + helpers.addError(listDeliveryStreams), region); + 'Unable to list Firehose delivery streams: ' + helpers.addError(listDeliveryStreams), region); return rcb(); } @@ -59,7 +62,6 @@ module.exports = { var deliveryStreamDesc = describeDeliveryStream.data.DeliveryStreamDescription; var deliveryStreamARN = deliveryStreamDesc.DeliveryStreamARN; - //console.log(describeDeliveryStream.data.DeliveryStreamDescription.Destinations[0].ExtendedS3DestinationDescription.EncryptionConfiguration.KMSEncryptionConfig); if (!deliveryStreamDesc || !deliveryStreamDesc.Destinations || diff --git a/plugins/aws/firehose/firehoseEncrypted.spec.js b/plugins/aws/firehose/firehoseEncrypted.spec.js new file mode 100644 index 000000000..e12e69ac0 --- /dev/null +++ b/plugins/aws/firehose/firehoseEncrypted.spec.js @@ -0,0 +1,291 @@ +var expect = require('chai').expect; +var firehoseEncrypted = require('./firehoseEncrypted'); + +const listDeliveryStreams = [ + "KDS-S3-VzT52" +]; + +const describeDeliveryStream = [ + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "KDS-S3-VzT52", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:111122223333:deliverystream/KDS-S3-VzT52", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "3", + "CreateTimestamp": "2021-12-27T20:10:18.843000+05:00", + "LastUpdateTimestamp": "2021-12-27T20:25:45.912000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + }, + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "KDS-S3-VzT52", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:111122223333:deliverystream/KDS-S3-VzT52", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "3", + "CreateTimestamp": "2021-12-27T20:10:18.843000+05:00", + "LastUpdateTimestamp": "2021-12-27T20:25:45.912000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": "NoEncryption" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": "arn:aws:kms:us-east-1:111122223333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + }, + { + "DeliveryStreamDescription": { + "DeliveryStreamName": "KDS-S3-VzT52", + "DeliveryStreamARN": "arn:aws:firehose:us-east-1:111122223333:deliverystream/KDS-S3-VzT52", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamEncryptionConfiguration": { + "KeyType": "AWS_OWNED_CMK", + "Status": "ENABLED" + }, + "DeliveryStreamType": "DirectPut", + "VersionId": "3", + "CreateTimestamp": "2021-12-27T20:10:18.843000+05:00", + "LastUpdateTimestamp": "2021-12-27T20:25:45.912000+05:00", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "S3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": "defaultKmsKey" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + } + }, + "ExtendedS3DestinationDescription": { + "RoleARN": "arn:aws:iam::111122223333:role/service-role/KinesisFirehoseServiceRole-KDS-S3-VzT52-us-east-1-1640617752463", + "BucketARN": "arn:aws:s3:::guardduty-bucket-viteace", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": { + "SizeInMBs": 5, + "IntervalInSeconds": 300 + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "KMSEncryptionConfig": "defaultKmsKey" + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "/aws/kinesisfirehose/KDS-S3-VzT52", + "LogStreamName": "DestinationDelivery" + }, + "ProcessingConfiguration": { + "Enabled": false, + "Processors": [] + }, + "S3BackupMode": "Disabled", + "DataFormatConversionConfiguration": { + "Enabled": false + } + } + } + ], + "HasMoreDestinations": false + } + } +]; + + +const createCache = (streams, describeDeliveryStream, streamsErr, describeDeliveryStreamErr) => { + var stream = (streams && streams.length) ? streams[0]: null; + return { + firehose: { + listDeliveryStreams: { + 'us-east-1': { + err: streamsErr, + data: streams + }, + }, + describeDeliveryStream: { + 'us-east-1': { + [stream]: { + data: describeDeliveryStream, + err: describeDeliveryStreamErr + } + } + } + }, + }; +}; + +describe('firehoseEncrypted', function () { + describe('run', function () { + it('should PASS if Firehose delivery stream uses a KMS key for SSE', function (done) { + const cache = createCache([listDeliveryStreams[0]], describeDeliveryStream[1]); + firehoseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Firehose delivery stream uses a KMS key for SSE'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Firehose delivery stream does not use a KMS key for SSE', function (done) { + const cache = createCache([listDeliveryStreams[0]], describeDeliveryStream[0]); + firehoseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The Firehose delivery stream does not use a KMS key for SSE'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if No Firehose delivery streams found', function (done) { + const cache = createCache([]); + firehoseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Firehose delivery streams found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to list Firehose delivery streams', function (done) { + const cache = createCache(null, null, { message: "Unable to list Firehose delivery streams" }); + firehoseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list Firehose delivery streams'); + done(); + }); + }); + + it('should UNKNOWN if unable to query Firehose for delivery streams', function (done) { + const cache = createCache([listDeliveryStreams[0]], null, null, { message: "Unable to Unable to query Firehose for delivery streams" }); + firehoseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query Firehose for delivery streams'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/forecast/datasetExportEncrypted.js b/plugins/aws/forecast/datasetExportEncrypted.js new file mode 100644 index 000000000..83aa164de --- /dev/null +++ b/plugins/aws/forecast/datasetExportEncrypted.js @@ -0,0 +1,109 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Forecast Dataset Export Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS Forecast exports have encryption enabled before they are being saved on S3.', + more_info: 'In AWS Forecast, you can save forecast reports on S3 in CSV format. Make sure to encrypt these export before writing them to the bucket in order to follow your organizations\'s security and compliance requirements.', + recommended_action: 'Create Forecast exports with encryption enabled', + link: 'https://docs.aws.amazon.com/forecast/latest/dg/howitworks-forecast.html', + apis: ['ForecastService:listForecastExportJobs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + forecast_dataset_desired_encryption_level: { + name: 'Forecast Dataset Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awscmk|externalcmk|cloudhsm)$', + default: 'awscmk' + } + }, + realtime_triggers: ['forecast:CreateForecastExportJob', 'forecast:DeleteForecastExportjob'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.forecast_dataset_desired_encryption_level || this.settings.forecast_dataset_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.forecastservice, function(region, rcb){ + var listForecastExportJobs = helpers.addSource(cache, source, + ['forecastservice', 'listForecastExportJobs', region]); + + if (!listForecastExportJobs) return rcb(); + + if (listForecastExportJobs.err || !listForecastExportJobs.data) { + helpers.addResult(results, 3, + 'Unable to query Forecast exports: ' + helpers.addError(listForecastExportJobs), region); + return rcb(); + } + + if (!listForecastExportJobs.data.length) { + helpers.addResult(results, 0, 'No Forecast exports found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let forecastExportJob of listForecastExportJobs.data) { + if (!forecastExportJob.Destination) { + continue; + } + + let { S3Config } = forecastExportJob.Destination; + let resource = forecastExportJob.ForecastExportJobArn; + + if (S3Config && S3Config.KMSKeyArn) { + let encryptionKey = S3Config.KMSKeyArn; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Forecast dataset export is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Forecast dataset export is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Forecast dataset export does not have encryption enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/forecast/datasetExportEncrypted.spec.js b/plugins/aws/forecast/datasetExportEncrypted.spec.js new file mode 100644 index 000000000..bf07b0b3c --- /dev/null +++ b/plugins/aws/forecast/datasetExportEncrypted.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +var datasetExportEncrypted = require('./datasetExportEncrypted'); + +const listForecastExportJobs = [ + { + "ForecastExportJobArn": "arn:aws:forecast:us-east-1:111122223333:forecast-export-job/test_fc/ewd", + "ForecastExportJobName": "ewd", + "Destination": { + "S3Config": { + "Path": "s3://amazon-connect-5bc142a71067/data", + "RoleArn": "arn:aws:iam::111122223333:role/service-role/AmazonForecast-ExecutionRole-1637334836508" + } + }, + "Status": "ACTIVE", + "CreationTime": "2021-12-07T01:30:33.065000-08:00", + "LastModificationTime": "2021-12-07T01:45:11.639000-08:00" + }, + { + "ForecastExportJobArn": "arn:aws:forecast:us-east-1:111122223333:forecast-export-job/test_fc/samxbqwd", + "ForecastExportJobName": "samxbqwd", + "Destination": { + "S3Config": { + "Path": "s3://viteace-data-bucket/data", + "RoleArn": "arn:aws:iam::111122223333:role/service-role/AmazonForecast-ExecutionRole-1637334836508", + "KMSKeyArn": "arn:aws:kms:us-east-1:111122223333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + "Status": "ACTIVE", + "CreationTime": "2021-12-05T01:27:11.782000-08:00", + "LastModificationTime": "2021-12-05T01:34:50.102000-08:00" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (forecastExportJobs, keys, describeKey) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + forecastservice: { + listForecastExportJobs: { + 'us-east-1': { + err: null, + data: forecastExportJobs + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('datasetExportEncrypted', function () { + describe('run', function () { + it('should PASS if Forecast Dataset Export is encrypted with desired encryption level', function (done) { + const cache = createCache([listForecastExportJobs[1]], listKeys, describeKey[0]); + datasetExportEncrypted.run(cache, { forecast_dataset_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Forecast Dataset Export is not encrypted with desired encryption level', function (done) { + const cache = createCache([listForecastExportJobs[1]], listKeys, describeKey[1]); + datasetExportEncrypted.run(cache, { forecast_dataset_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Forecast Dataset Export is found', function (done) { + const cache = createCache([]); + datasetExportEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Forecast Dataset Exports', function (done) { + const cache = createCache(null, null, null); + datasetExportEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to find Wisdom domain encryption key', function (done) { + const cache = createCache([listForecastExportJobs[1]], listKeys, null); + datasetExportEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache([listForecastExportJobs[0]], null, null); + datasetExportEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/forecast/forecastDatasetEncrypted.js b/plugins/aws/forecast/forecastDatasetEncrypted.js new file mode 100644 index 000000000..70c4c256d --- /dev/null +++ b/plugins/aws/forecast/forecastDatasetEncrypted.js @@ -0,0 +1,116 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Forecast Dataset Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS Forecast datasets are using desired KMS key for data encryption.', + more_info: 'Datasets contain the data used to train a predictor. You create one or more Amazon Forecast datasets and import your training data into them. ' + + 'Make sure to enable encryption for these datasets using customer-managed keys (CMKs) in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create Forecast datasets using customer-manager KMS keys (CMKs).', + link: 'https://docs.aws.amazon.com/forecast/latest/dg/API_CreateDataset.html', + apis: ['ForecastService:listDatasets', 'ForecastService:describeDataset', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + forecast_dataset_desired_encryption_level: { + name: 'Forecast Dataset Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awscmk|externalcmk|cloudhsm)$', + default: 'awscmk' + } + }, + realtime_triggers: ['forecastservice:CreateDataset', 'forecastservice:DeleteDataset'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.forecast_dataset_desired_encryption_level || this.settings.forecast_dataset_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.forecastservice, function(region, rcb){ + var listDatasets = helpers.addSource(cache, source, + ['forecastservice', 'listDatasets', region]); + + if (!listDatasets) return rcb(); + + if (listDatasets.err || !listDatasets.data) { + helpers.addResult(results, 3, `Unable to query Forecast datasets: ${helpers.addError(listDatasets)}`, region); + return rcb(); + } + + if (!listDatasets.data.length) { + helpers.addResult(results, 0, 'No Forecast datasets found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let dataset of listDatasets.data) { + if (!dataset.DatasetArn) continue; + + let resource = dataset.DatasetArn; + + let describeDataset = helpers.addSource(cache, source, + ['forecastservice', 'describeDataset', region, resource]); + + if (!describeDataset || describeDataset.err || !describeDataset.data) { + helpers.addResult(results, 3, + `Unable to query Forecast dataset: ${helpers.addError(describeDataset)}`, region, resource); + continue; + } + + if (describeDataset.data.EncryptionConfig && + describeDataset.data.EncryptionConfig.KMSKeyArn) { + let kmsKey = describeDataset.data.EncryptionConfig.KMSKeyArn; + let kmsKeyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + let describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Forecast dataset is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Forecast dataset is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Forecast dataset does not have encryption enabled', region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/forecast/forecastDatasetEncrypted.spec.js b/plugins/aws/forecast/forecastDatasetEncrypted.spec.js new file mode 100644 index 000000000..e28a6db43 --- /dev/null +++ b/plugins/aws/forecast/forecastDatasetEncrypted.spec.js @@ -0,0 +1,195 @@ +var expect = require('chai').expect; +var forecastDatasetEncrypted = require('./forecastDatasetEncrypted'); + + +const listDatasets = [ + { + "DatasetArn": "arn:aws:forecast:us-east-1:000011112222:dataset/testdataset", + "DatasetName": "testdataset", + "DatasetType": "ITEM_METADATA", + "Domain": "RETAIL", + "CreationTime": "2021-12-05T14:55:22.021000+05:00", + "LastModificationTime": "2021-12-05T14:55:22.021000+05:00" + }, +]; + +const describeDataset = [ + { + "DatasetArn": "arn:aws:forecast:us-east-1:000011112222:dataset/testdataset", + "DatasetName": "testdataset", + "Domain": "RETAIL", + "DatasetType": "ITEM_METADATA", + "Schema": { + "Attributes": [ + { + "AttributeName": "item_name", + "AttributeType": "string" + }, + { + "AttributeName": "item_id", + "AttributeType": "string" + } + ] + }, + "EncryptionConfig": { + "RoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonForecast-ExecutionRole-1637334836508", + "KMSKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + "Status": "ACTIVE", + "CreationTime": "2021-12-05T14:55:22.021000+05:00", + "LastModificationTime": "2021-12-05T14:55:22.021000+05:00" + }, + { + "DatasetArn": "arn:aws:forecast:us-east-1:000011112222:dataset/testdataset", + "DatasetName": "testdataset", + "Domain": "RETAIL", + "DatasetType": "ITEM_METADATA", + "Schema": { + "Attributes": [ + { + "AttributeName": "item_name", + "AttributeType": "string" + }, + { + "AttributeName": "item_id", + "AttributeType": "string" + } + ] + }, + "Status": "ACTIVE", + "CreationTime": "2021-12-05T14:55:22.021000+05:00", + "LastModificationTime": "2021-12-05T14:55:22.021000+05:00" + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (datasets, describeDataset, keys, describeKey, datasetsErr, keysErr) => { + let datasetArn = (datasets && datasets.length) ? datasets[0].DatasetArn : null; + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + return { + forecastservice: { + listDatasets: { + 'us-east-1': { + err: datasetsErr, + data: datasets + }, + }, + describeDataset: { + 'us-east-1': { + [datasetArn]: { + data: describeDataset + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('forecastDatasetEncrypted', function () { + describe('run', function () { + it('should PASS if Forecast dataset is encrypted with desired KMS key', function (done) { + const cache = createCache(listDatasets, describeDataset[0], listKeys, describeKey[0]); + forecastDatasetEncrypted.run(cache, { healthLake_data_store_encryption: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Forecast dataset is encrypted with awscmk'); + done(); + }); + }); + + + it('should FAIL if Forecast dataset is not encrypted with desired KMS key', function (done) { + const cache = createCache(listDatasets, describeDataset[0], listKeys, describeKey[0]); + forecastDatasetEncrypted.run(cache, { forecast_dataset_desired_encryption_level: 'externalcmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Forecast dataset is encrypted with awscmk'); + done(); + }); + }); + + it('should FAIL if Forecast dataset is not encrypted', function (done) { + const cache = createCache(listDatasets, describeDataset[1], listKeys, describeKey[0]); + forecastDatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Forecast dataset does not have encryption enabled'); + done(); + }); + }); + + + it('should PASS if no Forecast datasets found', function (done) { + const cache = createCache([]); + forecastDatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Forecast datasets found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Forecast datasets', function (done) { + const cache = createCache(null, null, null, null, { message: "Unable to obtain data" }); + forecastDatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Forecast datasets'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDatasets, describeDataset[0], null, null, null, { message: "Unable to obtain data" }); + forecastDatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/frauddetector/fraudDetectorDataEncrypted.js b/plugins/aws/frauddetector/fraudDetectorDataEncrypted.js new file mode 100644 index 000000000..803225679 --- /dev/null +++ b/plugins/aws/frauddetector/fraudDetectorDataEncrypted.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Fraud Detector Data Encrypted', + category: 'AI & ML', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Amazon Fraud Detector has encryption enabled for data at rest with desired KMS encryption level.', + more_info: 'Amazon Fraud Detector encrypts your data at rest with AWS-managed KMS key. Use customer-manager KMS keys (CMKs) instead in order to follow your organizations\'s security and compliance requirements.', + recommended_action: 'Enable encryption for data at rest using PutKMSEncryptionKey API', + link: 'https://docs.aws.amazon.com/frauddetector/latest/ug/encryption-at-rest.html', + apis: ['FraudDetector:getDetectors', 'FraudDetector:getKMSEncryptionKey', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + fraud_detector_data_encryption_level: { + name: 'Fraud Detector Data Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['frauddetector:PutKMSEncryptionKey', 'frauddetector:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.fraud_detector_data_encryption_level || this.settings.fraud_detector_data_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listDetectors = helpers.addSource(cache, source, + ['frauddetector', 'getDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, + `Unable to query Fraud Detectors: ${helpers.addError(listDetectors)}`, region); + return rcb(); + } + + if (!listDetectors.data.length) { + helpers.addResult(results, 0, 'No Fraud Detectors found', region); + return rcb(); + } + + var fraudDetectorsEncryptionKey = helpers.addSource(cache, source, + ['frauddetector', 'getKMSEncryptionKey', region]); + + if (fraudDetectorsEncryptionKey.err || !fraudDetectorsEncryptionKey.data) { + helpers.addResult(results, 3, + `Unable to query Fraud Detectors Key: ${helpers.addError(listDetectors)}`, region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + if (fraudDetectorsEncryptionKey.data && fraudDetectorsEncryptionKey.data.kmsEncryptionKeyArn + && fraudDetectorsEncryptionKey.data.kmsEncryptionKeyArn.toUpperCase() !== 'DEFAULT') { + let encryptionKey = fraudDetectorsEncryptionKey.data.kmsEncryptionKeyArn; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Fraud Detectors Data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } else { + helpers.addResult(results, 2, + `Fraud Detectors Data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/frauddetector/fraudDetectorDataEncrypted.spec.js b/plugins/aws/frauddetector/fraudDetectorDataEncrypted.spec.js new file mode 100644 index 000000000..145d08d61 --- /dev/null +++ b/plugins/aws/frauddetector/fraudDetectorDataEncrypted.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +const fraudDetectorDataEncrypted = require('./fraudDetectorDataEncrypted'); + +const getDetectors = [ + { + "detectorId": "qwdqw", + "eventTypeName": "asda", + "lastUpdatedTime": "2021-11-29T15:55:31.116Z", + "createdTime": "2021-11-29T15:55:31.116Z", + "arn": "arn:aws:frauddetector:us-east-1:111222333444:detector/qwdqw" + }, + { + "detectorId": "test_detector", + "description": "Testing Detectors", + "eventTypeName": "test", + "lastUpdatedTime": "2021-12-16T13:10:44.896Z", + "createdTime": "2021-12-16T12:57:03.748Z", + "arn": "arn:aws:frauddetector:us-east-1:111222333444:detector/test_detector" + } +]; + +const getKMSEncryptionKeyCMK = { + "kmsEncryptionKeyArn": "arn:aws:kms:us-east-1:111222333444:key/ad013a33-b01d-4d88-ac97-127399c18b3e" +}; + +const getKMSEncryptionKeyAWSKMS = { + "kmsEncryptionKeyArn": "DEFAULT" +} + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:111222333444:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (fraudDetectors, fraudKMSEncryptionKey, keys, describeKey) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + frauddetector: { + getDetectors: { + 'us-east-1': { + err: null, + data: fraudDetectors + }, + }, + getKMSEncryptionKey: { + 'us-east-1': { + err: null, + data: fraudKMSEncryptionKey + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('fraudDetectorDataEncrypted', function () { + describe('run', function () { + it('should PASS if Fraud Detector data is encrypted with desired encryption level', function (done) { + const cache = createCache(getDetectors, getKMSEncryptionKeyCMK, listKeys, describeKey[0]); + fraudDetectorDataEncrypted.run(cache, { fraud_detector_data_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Fraud Detector data is not encrypted with desired encryption level', function (done) { + const cache = createCache(getDetectors, getKMSEncryptionKeyAWSKMS, listKeys, describeKey[1]); + fraudDetectorDataEncrypted.run(cache, { fraud_detector_data_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Fraud Detector data is found', function (done) { + const cache = createCache([]); + fraudDetectorDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Fraud Detector data', function (done) { + const cache = createCache(); + fraudDetectorDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query Fraud Detectors Key', function (done) { + const cache = createCache(); + fraudDetectorDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(getDetectors, null, null); + fraudDetectorDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/fsx/fsxFileSystemEncrypted.js b/plugins/aws/fsx/fsxFileSystemEncrypted.js new file mode 100644 index 000000000..418c3b7d4 --- /dev/null +++ b/plugins/aws/fsx/fsxFileSystemEncrypted.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'FSx File System Encrypted', + category: 'FSx', + domain: 'Storage', + severity: 'High', + description: 'Ensure that Amazon FSx for Windows File Server file systems are encrypted using desired KMS encryption level.', + more_info: 'If your organization is subject to corporate or regulatory policies that require encryption of data and metadata at rest, AWS recommends creating encrypted file systems.', + recommended_action: 'Enable encryption for file systems created under Amazon FSx for Windows File Server', + link: 'https://docs.aws.amazon.com/fsx/latest/WindowsGuide/encryption.html', + apis: ['FSx:describeFileSystems', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + fsx_file_systems_encryption_level: { + name: 'FSx File Systems Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['fsx:CreateFileSystem', 'fsx:DeleteFileSystem'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.fsx_file_systems_encryption_level || this.settings.fsx_file_systems_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.connect, function(region, rcb){ + var listFileSystems = helpers.addSource(cache, source, + ['fsx', 'describeFileSystems', region]); + + if (!listFileSystems) return rcb(); + + if (listFileSystems.err || !listFileSystems.data) { + helpers.addResult(results, 3, + `Unable to query FSx file systems: ${helpers.addError(listFileSystems)}`, region); + return rcb(); + } + + if (!listFileSystems.data.length) { + helpers.addResult(results, 0, 'No FSx file systems found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let fileSystem of listFileSystems.data) { + if (fileSystem.FileSystemType && fileSystem.FileSystemType.toLowerCase() !== 'windows') { + continue; + } + + var resource = fileSystem.ResourceARN; + + if (fileSystem.KmsKeyId) { + let encryptionKey = fileSystem.KmsKeyId; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `FSx file system is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `FSx file system is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/fsx/fsxFileSystemEncrypted.spec.js b/plugins/aws/fsx/fsxFileSystemEncrypted.spec.js new file mode 100644 index 000000000..0e3aee04c --- /dev/null +++ b/plugins/aws/fsx/fsxFileSystemEncrypted.spec.js @@ -0,0 +1,163 @@ +var expect = require('chai').expect; +const fileSystemEncrypted = require('./fsxFileSystemEncrypted'); + +const describeFileSystems = [ + { + CreationTime: "2020-12-15T01:16:53.045000+05:00", + DNSName: "fs-0498eed5fe91001ec.fsx.com", + FileSystemId: "fs-0498eed5fe91001ec", + FileSystemType: "WINDOWS", + KmsKeyId: "arn:aws:kms:us-east-1:012345678912:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + Lifecycle: "AVAILABLE", + NetworkInterfaceIds: ["eni-abcd1234"], + OwnerId: "012345678912", + ResourceARN: "arn:aws:fsx:us-east-1:012345678912:file-system/fs-0498eed5fe91001ec", + StorageCapacity: 300, + SubnetIds: ["subnet-1234abcd"], + Tags: [ + { + Key: "Name", + Value: "MyFileSystem" + } + ], + VpcId: "vpc-ab1234cd", + WindowsConfiguration: { + ActiveDirectoryId: "d-1234abcd12", + AutomaticBackupRetentionDays: 30, + DailyAutomaticBackupStartTime: "05:00", + ThroughputCapacity: 8, + WeeklyMaintenanceStartTime: "1:05:00" + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const createCache = (fileSystems, keys, describeKey) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + fsx: { + describeFileSystems: { + 'us-east-1': { + err: null, + data: fileSystems + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('fileSystemEncrypted', function () { + describe('run', function () { + it('should PASS if FSx file system is encrypted with desired encryption level', function (done) { + const cache = createCache(describeFileSystems, listKeys, describeKey[0]); + fileSystemEncrypted.run(cache, { fsx_file_systems_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if FSx file system is not encrypted with desired encryption level', function (done) { + const cache = createCache(describeFileSystems, listKeys, describeKey[1]); + fileSystemEncrypted.run(cache, { fsx_file_systems_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no FSx file system is found', function (done) { + const cache = createCache([]); + fileSystemEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list FSx file systems', function (done) { + const cache = createCache(null, null, null); + fileSystemEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(describeFileSystems, null, null); + fileSystemEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/glue/bookmarkEncryptionEnabled.js b/plugins/aws/glue/bookmarkEncryptionEnabled.js new file mode 100644 index 000000000..2f7c65301 --- /dev/null +++ b/plugins/aws/glue/bookmarkEncryptionEnabled.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue Job Bookmark Encryption Enabled', + category: 'Glue', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS Glue job bookmark encryption is enabled.', + more_info: 'AWS Glue security configuration should have job bookmark encryption enabled in order to encrypt the bookmark data before it is sent to Amazon S3.', + recommended_action: 'Recreate Glue security configurations and enable job bookmark encryption', + link: 'https://docs.aws.amazon.com/glue/latest/dg/console-security-configurations.html', + apis: ['Glue:getSecurityConfigurations', 'STS:getCallerIdentity'], + realtime_triggers: ['glue:CreateSecurityConfiguration', 'glue:DeleteSecurityConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.glue, function(region, rcb){ + var getSecurityConfigurations = helpers.addSource(cache, source, + ['glue', 'getSecurityConfigurations', region]); + + if (!getSecurityConfigurations) return rcb(); + + if (getSecurityConfigurations.err || !getSecurityConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query Glue security configurations: ${helpers.addError(getSecurityConfigurations)}`, region); + return rcb(); + } + + if (!getSecurityConfigurations.data.length) { + helpers.addResult(results, 0, + 'No AWS Glue security configurations found', region); + return rcb(); + } + + for (var configuration of getSecurityConfigurations.data) { + if (!configuration.Name) continue; + + var resource = `arn:${awsOrGov}:glue:${region}:${accountId}:/securityConfiguration/${configuration.Name}`; + + if (configuration && configuration.EncryptionConfiguration && + configuration.EncryptionConfiguration.JobBookmarksEncryption && + configuration.EncryptionConfiguration.JobBookmarksEncryption.JobBookmarksEncryptionMode && + configuration.EncryptionConfiguration.JobBookmarksEncryption.JobBookmarksEncryptionMode.toUpperCase() !== 'DISABLED') { + + helpers.addResult(results, 0, + `Glue Security Configuration "${configuration.Name}" has job bookmark encryption enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `Glue Security Configuration "${configuration.Name}" does not have job bookmark encryption enabled`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/glue/bookmarkEncryptionEnabled.spec.js b/plugins/aws/glue/bookmarkEncryptionEnabled.spec.js new file mode 100644 index 000000000..73362401d --- /dev/null +++ b/plugins/aws/glue/bookmarkEncryptionEnabled.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var bookmarkEncryptionEnabled = require('./bookmarkEncryptionEnabled'); + +const getSecurityConfigurations = [ + { + "Name": "config-test", + "CreatedTimeStamp": "2020-12-15T03:32:22.300000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "SSE-KMS", + "KmsKeyArn": "arn:aws:kms:us-east-1:111122223333:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "DISABLED" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "CSE-KMS", + "KmsKeyArn": "arn:aws:kms:us-east-1:111122223333:key/e400fb3c-7bb5-4e7e-8ecc-25098282573a" + } + } + }, + { + "Name": "config-test2", + "CreatedTimeStamp": "2020-12-15T02:20:28.329000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "DISABLED" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "DISABLED" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "DISABLED" + } + } + } +]; + +const createCache = (configurations, configurationsErr) => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': { + err: configurationsErr, + data: configurations + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': null + } + }, + }; +}; + +describe('bookmarkEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if Glue security configuration has job bookmark encryption enabled', function (done) { + const cache = createCache([getSecurityConfigurations[0]]); + bookmarkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Glue security configuration has job bookmark encryption disabled', function (done) { + const cache = createCache([getSecurityConfigurations[1]]); + bookmarkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No AWS security configurations found', function (done) { + const cache = createCache([]); + bookmarkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get Glue security configurations', function (done) { + const cache = createCache(null, { message: "Unable to get security configurations" }); + bookmarkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get security configurations response not found', function (done) { + const cache = createNullCache(); + bookmarkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/glue/dataCatalogCmkEncrypted.js b/plugins/aws/glue/dataCatalogCmkEncrypted.js new file mode 100644 index 000000000..e48d4a39d --- /dev/null +++ b/plugins/aws/glue/dataCatalogCmkEncrypted.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue Data Catalog CMK Encrypted', + category: 'Glue', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS Glue has data catalog encryption enabled with KMS Customer Master Key (CMK).', + more_info: 'AWS Glue should have data catalog encryption enabled with KMS Customer Master Key (CMK) instead of AWS-managed Key in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Modify Glue data catalog to use CMK instead of AWS-managed Key to encrypt Metadata', + link: 'https://docs.aws.amazon.com/glue/latest/dg/encrypt-glue-data-catalog.html', + apis: ['Glue:getDataCatalogEncryptionSettings', 'KMS:listKeys', 'KMS:describeKey'], + realtime_triggers: ['glue:PutDataCatalogEncryptionSettings'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.glue, function(region, rcb){ + var getDataCatalogEncryptionSettings = helpers.addSource(cache, source, + ['glue', 'getDataCatalogEncryptionSettings', region]); + + if (!getDataCatalogEncryptionSettings) return rcb(); + + if (getDataCatalogEncryptionSettings.err || !getDataCatalogEncryptionSettings.data) { + helpers.addResult(results, 3, + `Unable to query Glue data catalog encryption settings: ${helpers.addError(getDataCatalogEncryptionSettings)}`, region); + return rcb(); + } + + var encryptionSettings = getDataCatalogEncryptionSettings.data; + + if (encryptionSettings && encryptionSettings.EncryptionAtRest && + encryptionSettings.EncryptionAtRest.CatalogEncryptionMode && + encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId && + encryptionSettings.EncryptionAtRest.CatalogEncryptionMode.toUpperCase() !== 'DISABLED') { + + var kmsKeyId = encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId.split('/')[1]; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || + !describeKey.data.KeyMetadata || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyId); + return rcb(); + } + + if (helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS) == 3) { + helpers.addResult(results, 0, + 'Glue data catalog has encryption at-rest enabled for metadata using Customer Master Key', + region); + } else { + helpers.addResult(results, 2, + 'Glue data catalog has encryption at-rest enabled for metadata using AWS-managed key', + region); + } + } else { + helpers.addResult(results, 2, + 'Glue data catalog does not have encryption at-rest enabled for metadata', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/glue/dataCatalogCmkEncrypted.spec.js b/plugins/aws/glue/dataCatalogCmkEncrypted.spec.js new file mode 100644 index 000000000..9c052ab87 --- /dev/null +++ b/plugins/aws/glue/dataCatalogCmkEncrypted.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +var dataCatalogCmkEncrypted = require('./dataCatalogCmkEncrypted'); + +const getDataCatalogEncryptionSettings = [ + { + "EncryptionAtRest": { + "CatalogEncryptionMode": "SSE-KMS", + "SseAwsKmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1" + }, + "ConnectionPasswordEncryption": { + "ReturnConnectionPasswordEncrypted": false + } + }, + { + "EncryptionAtRest": { + "CatalogEncryptionMode": "DISABLED" + }, + "ConnectionPasswordEncryption": { + "ReturnConnectionPasswordEncrypted": false + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +] + +const createCache = (configurations, describeKey, configurationsErr, describeKeyErr) => { + var keyId = (configurations && configurations.EncryptionAtRest && configurations.EncryptionAtRest.SseAwsKmsKeyId) ? configurations.EncryptionAtRest.SseAwsKmsKeyId.split('/')[1] : null; + return { + glue: { + getDataCatalogEncryptionSettings: { + 'us-east-1': { + err: configurationsErr, + data: configurations + }, + }, + }, + kms: { + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + glue: { + getDataCatalogEncryptionSettings: { + 'us-east-1': null + } + }, + kms: { + describeKey: { + 'us-east-1': null + } + }, + }; +}; + +describe('dataCatalogCmkEncrypted', function () { + describe('run', function () { + it('should PASS if Glue data catalog has encryption at-rest enabled for metadata using Customer Master Key', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[0]); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Glue data catalog has encryption at-rest enabled for metadata using AWS-managed key', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[1]); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Glue data catalog does not have encryption at-rest enabled for metadata', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[1]); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get AWS Glue data catalog encryption settings', function (done) { + const cache = createCache(null, null, { message: "Unable to get AWS Glue data catalog encryption settings" }); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe KMS key', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[0], null, { message: "Unable to describe KMS key" }); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get datalog encryption settings response not found', function (done) { + const cache = createNullCache(); + dataCatalogCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/glue/dataCatalogEncryptionEnabled.js b/plugins/aws/glue/dataCatalogEncryptionEnabled.js new file mode 100644 index 000000000..2f283c436 --- /dev/null +++ b/plugins/aws/glue/dataCatalogEncryptionEnabled.js @@ -0,0 +1,173 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue Data Catalog Encryption Enabled', + category: 'Glue', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS Glue Data Catalogs has encryption at-rest enabled.', + more_info: 'Encryption should be enabled for metadata objects stored in your AWS Glue Data Catalog to secure sensitive data.', + recommended_action: 'Modify Glue data catalog settings and enable metadata encryption', + link: 'https://docs.aws.amazon.com/glue/latest/dg/encrypt-glue-data-catalog.html', + apis: ['Glue:getDataCatalogEncryptionSettings', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + glue_datacatalog_encryption_level: { + name: 'Glue Data Catalog Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + remediation_description: 'Glue data catalog encryption for the affected regions will be enabled.', + remediation_min_version: '202116032330', + apis_remediate: ['Glue:getDataCatalogEncryptionSettings'], + remediation_inputs: { + kmsKeyIdforDataCatalog: { + name: '(Optional) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + actions: { + remediate: ['Glue:putDataCatalogEncryptionSettings'], + rollback: ['Glue:putDataCatalogEncryptionSettings'] + }, + permissions: { + remediate: ['glue:PutDataCatalogEncryptionSettings'], + rollback: ['glue:PutDataCatalogEncryptionSettings'] + }, + realtime_triggers: ['glue:PutDataCatalogEncryptionSettings'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.glue_datacatalog_encryption_level || this.settings.glue_datacatalog_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.glue, function(region, rcb){ + var getDataCatalogEncryptionSettings = helpers.addSource(cache, source, + ['glue', 'getDataCatalogEncryptionSettings', region]); + + if (!getDataCatalogEncryptionSettings) return rcb(); + + if (getDataCatalogEncryptionSettings.err || !getDataCatalogEncryptionSettings.data) { + helpers.addResult(results, 3, + `Unable to query Glue data catalog encryption settings: ${helpers.addError(getDataCatalogEncryptionSettings)}`, region); + return rcb(); + } + + var encryptionSettings = getDataCatalogEncryptionSettings.data; + + if (encryptionSettings && encryptionSettings.EncryptionAtRest && + encryptionSettings.EncryptionAtRest.CatalogEncryptionMode && + encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId && + encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId.length && + encryptionSettings.EncryptionAtRest.CatalogEncryptionMode.toUpperCase() !== 'DISABLED') { + + var kmsKeyId = encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId.split('/')[1] ? encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId.split('/')[1] : encryptionSettings.EncryptionAtRest.SseAwsKmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyId); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Glue data catalog has encryption at-rest enabled for metadata at encryption level ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } else { + helpers.addResult(results, 2, + `Glue data catalog has encryption at-rest enabled for metadata at encryption level ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region); + } + } else { + helpers.addResult(results, 2, + 'Glue data catalog does not have encryption at-rest enabled for metadata', + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'dataCatalogEncryptionEnabled'; + var defaultKeyDesc = 'Default master key that protects my Glue data when no other key is defined'; + + var region = settings.region; + + config.region = region; + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforDataCatalog) { + params = { + DataCatalogEncryptionSettings: { + EncryptionAtRest: { + CatalogEncryptionMode: 'SSE-KMS', + SseAwsKmsKeyId: settings.input.kmsKeyIdforDataCatalog + } + } + }; + } else { + let defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default Glue key for the region ${config.region}`); + params = { + DataCatalogEncryptionSettings: { + EncryptionAtRest: { + CatalogEncryptionMode: 'SSE-KMS', + SseAwsKmsKeyId: defaultKmsKeyId + } + } + }; + } + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][region] = { + 'DataCatalogEncryption': 'Disabled', + 'Glue': region + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'DATA_CATALOG_ENCRYPTED', + 'Glue': region + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/glue/dataCatalogEncryptionEnabled.spec.js b/plugins/aws/glue/dataCatalogEncryptionEnabled.spec.js new file mode 100644 index 000000000..37aef4cf3 --- /dev/null +++ b/plugins/aws/glue/dataCatalogEncryptionEnabled.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var dataCatalogEncryptionEnabled = require('./dataCatalogEncryptionEnabled'); + +const getDataCatalogEncryptionSettings = [ + { + "EncryptionAtRest": { + "CatalogEncryptionMode": "SSE-KMS", + "SseAwsKmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1" + }, + "ConnectionPasswordEncryption": { + "ReturnConnectionPasswordEncrypted": false + } + }, + { + "EncryptionAtRest": { + "CatalogEncryptionMode": "DISABLED" + }, + "ConnectionPasswordEncryption": { + "ReturnConnectionPasswordEncrypted": false + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +] + +const createCache = (configurations, describeKey, configurationsErr, describeKeyErr) => { + var keyId = (configurations && configurations.EncryptionAtRest && configurations.EncryptionAtRest.SseAwsKmsKeyId) ? configurations.EncryptionAtRest.SseAwsKmsKeyId.split('/')[1] : null; + return { + glue: { + getDataCatalogEncryptionSettings: { + 'us-east-1': { + err: configurationsErr, + data: configurations + }, + }, + }, + kms: { + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + glue: { + getDataCatalogEncryptionSettings: { + 'us-east-1': null + } + }, + kms: { + describeKey: { + 'us-east-1': null + } + }, + }; +}; + +describe('dataCatalogEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if Glue data catalog has encryption at-rest enabled for metadata at encryption level greater than or equal to target encryption level', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[0]); + dataCatalogEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Glue data catalog has encryption at-rest enabled for metadata at encryption level less than target encryption level', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[1]); + dataCatalogEncryptionEnabled.run(cache, { glue_datacatalog_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get AWS Glue data catalog encryption settings', function (done) { + const cache = createCache(null, null, { message: "Unable to get AWS Glue data catalog encryption settings" }); + dataCatalogEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe KMS key', function (done) { + const cache = createCache(getDataCatalogEncryptionSettings[0], describeKey[0], null, { message: "Unable to describe KMS key" }); + dataCatalogEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get datalog encryption settings response not found', function (done) { + const cache = createNullCache(); + dataCatalogEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/glue/glueCloudwatchLogsEncrypted.js b/plugins/aws/glue/glueCloudwatchLogsEncrypted.js new file mode 100644 index 000000000..48169347e --- /dev/null +++ b/plugins/aws/glue/glueCloudwatchLogsEncrypted.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue CloudWatch Encrypted Logs', + category: 'Glue', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that encryption at-rest is enabled when writing AWS Glue logs to Amazon CloudWatch.', + more_info: 'AWS Glue should have encryption at-rest enabled for AWS Glue logs to ensure security of AWS Glue logs.', + recommended_action: 'Modify Glue Security Configurations to enable CloudWatch logs encryption at-rest', + link: 'https://docs.aws.amazon.com/glue/latest/dg/console-security-configurations.html', + apis: ['Glue:getSecurityConfigurations', 'STS:getCallerIdentity'], + realtime_triggers: ['glue:CreateSecurityConfiguration', 'glue:DeleteSecurityConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.glue, function(region, rcb){ + var getSecurityConfigurations = helpers.addSource(cache, source, + ['glue', 'getSecurityConfigurations', region]); + + if (!getSecurityConfigurations) return rcb(); + + if (getSecurityConfigurations.err || !getSecurityConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for Glue security configurations: ${helpers.addError(getSecurityConfigurations)}`, region); + return rcb(); + } + + if (!getSecurityConfigurations.data.length) { + helpers.addResult(results, 0, + 'No Glue security configurations found', region); + return rcb(); + } + + getSecurityConfigurations.data.forEach(configuration => { + if (!configuration.Name) return; + + var resource = `arn:${awsOrGov}:glue:${region}:${accountId}:/securityConfiguration/${configuration.Name}`; + + if (configuration.EncryptionConfiguration && + configuration.EncryptionConfiguration.CloudWatchEncryption && + configuration.EncryptionConfiguration.CloudWatchEncryption.CloudWatchEncryptionMode && + configuration.EncryptionConfiguration.CloudWatchEncryption.CloudWatchEncryptionMode === 'SSE-KMS') { + helpers.addResult(results, 0, + `Glue Security Configuration "${configuration.Name}" has CloudWatch logs encryption enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `Glue Security Configuration "${configuration.Name}" has CloudWatch logs encryption disabled`, + region, resource); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/glue/glueCloudwatchLogsEncrypted.spec.js b/plugins/aws/glue/glueCloudwatchLogsEncrypted.spec.js new file mode 100644 index 000000000..c361bbd65 --- /dev/null +++ b/plugins/aws/glue/glueCloudwatchLogsEncrypted.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var glueCloudwatchLogsEncrypted = require('./glueCloudwatchLogsEncrypted'); + +const getSecurityConfigurations = [ + { + "Name": "glue-test-config", + "CreatedTimeStamp": "2020-12-15T01:18:18.391000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "DISABLED" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "SSE-KMS", + "KmsKeyArn": "arn:aws:kms:us-east-1:111122223333:key/e400fb3c-7bb5-4e7e-8ecc-250982820000" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "DISABLED" + } + } + }, + { + "Name": "config-test2", + "CreatedTimeStamp": "2020-12-15T02:20:28.329000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "DISABLED" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "DISABLED" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "DISABLED" + } + } + } +]; + +const createCache = (configurations) => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': { + data: configurations + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': { + err: { + message: 'error getting AWS Glue security configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': null + } + }, + }; +}; + +describe('glueCloudwatchLogsEncrypted', function () { + describe('run', function () { + it('should PASS if AWS Glue security configuration has CloudWatch logs encryption enabled', function (done) { + const cache = createCache([getSecurityConfigurations[0]]); + glueCloudwatchLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if AWS Glue security configuration has CloudWatch logs encryption disabled', function (done) { + const cache = createCache([getSecurityConfigurations[1]]); + glueCloudwatchLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No AWS Glue security configurations found', function (done) { + const cache = createCache([]); + glueCloudwatchLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get AWS Glue security configurations', function (done) { + const cache = createErrorCache(); + glueCloudwatchLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if get security configurations response not found', function (done) { + const cache = createNullCache(); + glueCloudwatchLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/glue/glueS3EncryptionEnabled.js b/plugins/aws/glue/glueS3EncryptionEnabled.js new file mode 100644 index 000000000..fc0c09142 --- /dev/null +++ b/plugins/aws/glue/glueS3EncryptionEnabled.js @@ -0,0 +1,122 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue S3 Encryption Enabled', + category: 'Glue', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that encryption at-rest is enabled when writing AWS Glue data to Amazon S3.', + more_info: 'AWS Glue should have encryption at-rest enabled for Amazon S3 to ensure security of data at rest and to prevent unauthorized access.', + recommended_action: 'Recreate AWS Glue Security Configuration to enable Amazon S3 encryption at-rest', + link: 'https://docs.aws.amazon.com/glue/latest/dg/console-security-configurations.html', + apis: ['Glue:getSecurityConfigurations', 'STS:getCallerIdentity', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + glue_s3_encryption_level: { + name: 'Glue S3 Encryption Level', + description: 'In order (lowest to highest) sse=S3 Server-Side; awskms=AWS-managed KMS; awscmk=Customer managed KMS;', + regex: '^(sse|awskms|awscmk)$', + default: 'awskms', + } + }, + realtime_triggers: ['glue:CreateSecurityConfiguration','glue:DeleteSecurityConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + glue_s3_encryption_level: settings.glue_s3_encryption_level || this.settings.glue_s3_encryption_level.default, + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.glue_s3_encryption_level); + var currentEncryptionLevel; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.glue, function(region, rcb){ + var getSecurityConfigurations = helpers.addSource(cache, source, + ['glue', 'getSecurityConfigurations', region]); + + if (!getSecurityConfigurations) return rcb(); + + if (getSecurityConfigurations.err || !getSecurityConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for AWS Glue security configurations: ${helpers.addError(getSecurityConfigurations)}`, region); + return rcb(); + } + + if (!getSecurityConfigurations.data.length) { + helpers.addResult(results, 0, + 'No AWS Glue security configurations found', region); + return rcb(); + } + + async.each(getSecurityConfigurations.data, function(configuration, cb) { + if (!configuration.Name) return cb(); + + var resource = `arn:${awsOrGov}:glue:${region}:${accountId}:/securityConfiguration/${configuration.Name}`; + var encryptionEnabled = false; + + if (configuration && configuration.EncryptionConfiguration && + configuration.EncryptionConfiguration.S3Encryption && + configuration.EncryptionConfiguration.S3Encryption.length) { + for (var encryptionConfig of configuration.EncryptionConfiguration.S3Encryption) { + if (encryptionConfig.S3EncryptionMode && encryptionConfig.S3EncryptionMode.toUpperCase() !== 'DISABLED') { + encryptionEnabled = true; + if (encryptionConfig.S3EncryptionMode.toUpperCase() === 'SSE-S3') { + currentEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf('sse'); + } else { + if (encryptionConfig.KmsKeyArn) { + var keyId = encryptionConfig.KmsKeyArn.split('/')[1]; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, `Unable to query associated KMS Key for configuration "${configuration.Name}": ${helpers.addError(describeKey)}`, + region, resource); + return cb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + helpers.addResult(results, 3, 'Unable to find associated KMS key for security configuration', + region, resource); + return cb(); + } + } + break; + } + } + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + if (encryptionEnabled) { + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Glue security configuration "${configuration.Name}" has S3 encryption enabled at encryption level ${currentEncryptionLevelString} which is greater than or equal to target level ${config.glue_s3_encryption_level}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Glue security configuration "${configuration.Name}" has S3 encryption enabled at encryption level ${currentEncryptionLevelString} which is less than target level ${config.glue_s3_encryption_level}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `Glue security configuration "${configuration.Name}" does not have S3 encryption enabled`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/glue/glueS3EncryptionEnabled.spec.js b/plugins/aws/glue/glueS3EncryptionEnabled.spec.js new file mode 100644 index 000000000..4cc050664 --- /dev/null +++ b/plugins/aws/glue/glueS3EncryptionEnabled.spec.js @@ -0,0 +1,177 @@ +var expect = require('chai').expect; +var s3EncryptionMode = require('./glueS3EncryptionEnabled'); + +const getSecurityConfigurations = [ + { + "Name": "config-test", + "CreatedTimeStamp": "2020-12-15T03:32:22.300000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "SSE-KMS", + "KmsKeyArn": "arn:aws:kms:us-east-1:111122223333:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "DISABLED" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "DISABLED" + } + } + }, + { + "Name": "config-test2", + "CreatedTimeStamp": "2020-12-15T02:20:28.329000+05:00", + "EncryptionConfiguration": { + "S3Encryption": [ + { + "S3EncryptionMode": "DISABLED" + } + ], + "CloudWatchEncryption": { + "CloudWatchEncryptionMode": "DISABLED" + }, + "JobBookmarksEncryption": { + "JobBookmarksEncryptionMode": "DISABLED" + } + } + } +]; + +const listKeys = [ + { + "KeyId": "7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec", + "KeyArn": "arn:aws:kms:us-east-1:111122223333:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/7cb78370-cdc6-4ccb-a973-ecc8cbdc0dec", + "CreationDate": 1598523566.709, + "Enabled": true, + "Description": "Default master key that protects my S3 objects when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (configurations, listKeys, describeKey) => { + var keyId = (listKeys && listKeys.length) ? listKeys[0].KeyId : null; + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': { + data: configurations + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: listKeys + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': { + err: { + message: 'error getting AWS Glue security configurations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + glue: { + getSecurityConfigurations: { + 'us-east-1': null + } + }, + }; +}; + +describe('s3EncryptionMode', function () { + describe('run', function () { + it('should PASS if AWS Glue security configuration has s3 encryption enabled', function (done) { + const cache = createCache([getSecurityConfigurations[0]], listKeys, describeKey[0]); + s3EncryptionMode.run(cache, { glue_s3_encryption_level: 'awskms' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if AWS Glue security configuration does not hsve s3 encryption enabled at desired level', function (done) { + const cache = createCache([getSecurityConfigurations[0]], listKeys, describeKey[0]); + s3EncryptionMode.run(cache, { glue_s3_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if AWS Glue security configuration does not have s3 encryption disabled', function (done) { + const cache = createCache([getSecurityConfigurations[1]]); + s3EncryptionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if No AWS Glue security configurations found', function (done) { + const cache = createCache([]); + s3EncryptionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get AWS Glue security configurations', function (done) { + const cache = createErrorCache(); + s3EncryptionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get security configurations response not found', function (done) { + const cache = createNullCache(); + s3EncryptionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.js b/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.js new file mode 100644 index 000000000..77107eb17 --- /dev/null +++ b/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.js @@ -0,0 +1,112 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS Glue DataBrew Job Output Encrypted', + category: 'Glue DataBrew', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS Glue DataBrew jobs have encryption enabled for output files with desired encryption level.', + more_info: 'AWS Glue DataBrew jobs should have encryption enabled to encrypt S3 targets i.e. output files to meet regulatory compliance requirements within your organization.', + recommended_action: 'Modify Glue DataBrew jobs to set desired encryption configuration', + link: 'https://docs.aws.amazon.com/databrew/latest/dg/encryption-security-configuration.html', + apis: ['DataBrew:listJobs', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + databrew_job_encryption_level: { + name: 'AWS DataBrew Job Target Encryption Level', + description: 'In order (lowest to highest) sse=S3-SSE; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awscmk|externalcmk|cloudhsm)$', + default: 'awscmk', + } + }, + realtime_triggers: ['databrew:CreateRecipeJob', 'databrew:UpdateRecipeJob', 'databrew:DeleteJob'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.databrew_job_encryption_level || this.settings.databrew_job_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.databrew, function(region, rcb){ + var listJobs = helpers.addSource(cache, source, + ['databrew', 'listJobs', region]); + + if (!listJobs) return rcb(); + + if (listJobs.err || !listJobs.data) { + helpers.addResult(results, 3, + `Unable to list DataBrew jobs: ${helpers.addError(listJobs)}`, region); + return rcb(); + } + + if (!listJobs.data.length) { + helpers.addResult(results, 0, + 'No DataBrew jobs found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let job of listJobs.data) { + if (!job.ResourceArn) continue; + + var resource = job.ResourceArn; + + if (job.EncryptionMode) { + if (job.EncryptionKeyArn) { + var kmsKeyId = job.EncryptionKeyArn.split('/')[1] ? job.EncryptionKeyArn.split('/')[1] : job.EncryptionKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, job.EncryptionKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 1; //s3-sse + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `DataBrew job is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `DataBrew job is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'DataBrew job does not have encryption enabled for output file', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.spec.js b/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.spec.js new file mode 100644 index 000000000..2daeb841c --- /dev/null +++ b/plugins/aws/gluedatabrew/databrewJobOutputEncrypted.spec.js @@ -0,0 +1,237 @@ +var expect = require('chai').expect; +var databrewJobOutputEncrypted = require('./databrewJobOutputEncrypted'); + +const listJobs = [ + { + "AccountId": "000011112222", + "CreatedBy": "arn:aws:iam::000011112222:root", + "CreateDate": "2021-11-15T15:46:11.255Z", + "DatasetName": "sample-ds", + "EncryptionKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "EncryptionMode": "SSE-KMS", + "Name": "khulnasoft-job", + "Type": "RECIPE", + "LastModifiedBy": null, + "LastModifiedDate": null, + "LogSubscription": "DISABLE", + "MaxCapacity": 5, + "MaxRetries": 0, + "Outputs": [ + { + "CompressionFormat": null, + "Format": "CSV", + "Location": { + "Bucket": "khulnasoft-data-bucket", + "Key": null + }, + "Overwrite": false, + "FormatOptions": { + "Csv": { + "Delimiter": "," + } + } + } + ], + "ProjectName": null, + "RecipeReference": { + "Name": "sample-recipe", + "RecipeVersion": "1.0" + }, + "ResourceArn": "arn:aws:databrew:us-east-1:000011112222:job/khulnasoft-job", + "RoleArn": "arn:aws:iam::000011112222:role/service-role/AWSGlueDataBrewServiceRole-databrew-role", + "Timeout": 2880, + "Tags": {} + }, + { + "AccountId": "000011112222", + "CreatedBy": "arn:aws:iam::000011112222:root", + "CreateDate": "2021-11-15T16:06:58.027Z", + "DatasetName": "sample-ds", + "EncryptionKeyArn": null, + "EncryptionMode": null, + "Name": "khulnasoft-job2", + "Type": "RECIPE", + "LastModifiedBy": null, + "LastModifiedDate": null, + "LogSubscription": "DISABLE", + "MaxCapacity": 5, + "MaxRetries": 0, + "Outputs": [ + { + "CompressionFormat": null, + "Format": "CSV", + "Location": { + "Bucket": "khulnasoft-data-bucket", + "Key": null + }, + "Overwrite": false, + "FormatOptions": { + "Csv": { + "Delimiter": "," + } + } + } + ], + "ProjectName": null, + "RecipeReference": { + "Name": "sample-recipe", + "RecipeVersion": "1.0" + }, + "ResourceArn": "arn:aws:databrew:us-east-1:000011112222:job/khulnasoft-job2", + "RoleArn": "arn:aws:iam::000011112222:role/service-role/AWSGlueDataBrewServiceRole-databrew-role", + "Timeout": 2880, + "Tags": {} + }, + { + "AccountId": "000011112222", + "CreatedBy": "arn:aws:iam::000011112222:root", + "CreateDate": "2021-11-15T16:18:29.930Z", + "DatasetName": "sample-ds", + "EncryptionKeyArn": null, + "EncryptionMode": "SSE-S3", + "Name": "khulnasoft-job3", + "Type": "RECIPE", + "LastModifiedBy": null, + "LastModifiedDate": null, + "LogSubscription": "DISABLE", + "MaxCapacity": 5, + "MaxRetries": 0, + "Outputs": [ + { + "CompressionFormat": null, + "Format": "CSV", + "Location": { + "Bucket": "khulnasoft-data-bucket", + "Key": null + }, + "Overwrite": false, + "FormatOptions": { + "Csv": { + "Delimiter": "," + } + } + } + ], + "ProjectName": null, + "RecipeReference": { + "Name": "sample-recipe", + "RecipeVersion": "1.0" + }, + "ResourceArn": "arn:aws:databrew:us-east-1:000011112222:job/khulnasoft-job3", + "RoleArn": "arn:aws:iam::000011112222:role/service-role/AWSGlueDataBrewServiceRole-databrew-role", + "Timeout": 2880, + "Tags": {} + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (jobs, keys, describeKey, jobsErr, keysErr, describeKeyErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + return { + databrew: { + listJobs: { + 'us-east-1': { + err: jobsErr, + data: jobs + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('databrewJobOutputEncrypted', function () { + describe('run', function () { + it('should PASS if DataBrew job is encrypted with desired encryption level', function (done) { + const cache = createCache([listJobs[0]], listKeys, describeKey[0]); + databrewJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DataBrew job is not encrypted with desired encryption level', function (done) { + const cache = createCache([listJobs[1]], listKeys); + databrewJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DataBrew jobs found', function (done) { + const cache = createCache([]); + databrewJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Translate jobs', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Translate jobs" }); + databrewJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listJobs, null, null, null, { message: "Unable to list KMS keys" }); + databrewJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/guardduty/eksProtectionEnabled.js b/plugins/aws/guardduty/eksProtectionEnabled.js new file mode 100644 index 000000000..befb261da --- /dev/null +++ b/plugins/aws/guardduty/eksProtectionEnabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'EKS GuardDuty Enabled', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that GuardDuty protection is enabled for EKS clusters.' , + more_info: 'Enabling GuardDuty EKS protection helps detect potential security threats in your EKS clusters by monitoring audit logs, user activities, and control plane operations. It provides enhanced security by offering proactive threat detection and automated alerting for suspicious activities and security issues within your AWS environment.', + recommended_action: 'Enable GuardDuty EKS protection for all AWS accounts.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/kubernetes-protection.html', + apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:UpdateDetector', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var regions = helpers.regions(settings); + + async.each(regions.guardduty, function(region, rcb) { + var listDetectors = helpers.addSource(cache, source, ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, 'Unable to list GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return rcb(); + } + + if (!listDetectors.data || !listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return rcb(); + } + + listDetectors.data.forEach(function(detectorId) { + var resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detectorId; + var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); + + if (!getDetector) return; + + if (getDetector.err || !getDetector.data) { + helpers.addResult(results, 3, 'Unable to get GuardDuty detector: ' + helpers.addError(getDetector),region); + return; + } + + var detector = getDetector.data; + + if (detector.DataSources && + detector.DataSources.Kubernetes && + detector.DataSources.Kubernetes.AuditLogs && + detector.DataSources.Kubernetes.AuditLogs.Status && + detector.DataSources.Kubernetes.AuditLogs.Status.toLowerCase() === 'disabled'){ + helpers.addResult(results, 2, 'GuardDuty EKS protection is disabled', region, resource); + } else { + helpers.addResult(results, 0, 'GuardDuty EKS protection is enabled', region, resource); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/guardduty/eksProtectionEnabled.spec.js b/plugins/aws/guardduty/eksProtectionEnabled.spec.js new file mode 100644 index 000000000..5bf148154 --- /dev/null +++ b/plugins/aws/guardduty/eksProtectionEnabled.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var eksProtectionEnabled = require('./eksProtectionEnabled'); + +const listDetectors = [ + "6cc45a4adb18e50f5ba51f6800db03d8" +]; + +const getDetector = [ + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "DataSources": { + "CloudTrail": { + "Status": "ENABLED" + }, + "DNSLogs": { + "Status": "ENABLED" + }, + "FlowLogs": { + "Status": "ENABLED" + }, + "S3Logs": { + "Status": "ENABLED" + }, + "Kubernetes": { + "AuditLogs": { + "Status": "ENABLED" + } + } + }, + "Tags": {} + }, + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "DataSources": { + "CloudTrail": { + "Status": "ENABLED" + }, + "DNSLogs": { + "Status": "ENABLED" + }, + "FlowLogs": { + "Status": "ENABLED" + }, + "S3Logs": { + "Status": "DISABLED" + }, + "Kubernetes": { + "AuditLogs": { + "Status": "DISABLED" + } + } + }, + "Tags": {} + } +]; + +const createCache = (listDetectors, getDetector) => { + let detectorId = (listDetectors.length) ? listDetectors[0] : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + data: listDetectors + }, + }, + getDetector: { + 'us-east-1': { + [detectorId]: { + data: getDetector + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': null + } + } + }; +}; + + +describe('eksProtectionEnabled', function () { + describe('run', function () { + it('should FAIL if GuardDuty EKS protection is diabled', function (done) { + const cache = createCache(listDetectors, getDetector[1],); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty EKS protection is disabled'); + done(); + }); + }); + + it('should PASS if GuardDuty EKS protection is enabled', function (done) { + const cache = createCache(listDetectors, getDetector[0]); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty EKS protection is enabled'); + done(); + }); + }); + + it('should PASS if no detectors found', function (done) { + const cache = createCache([]); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No GuardDuty detectors found'); + done(); + }); + }); + + it('should UNKNOWN unable to list GuardDuty detector', function (done) { + const cache = createErrorCache(); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list GuardDuty detectors:'); + done(); + }); + }); + + it('should UNKNOWN unable to get GuardDuty detector', function (done) { + const cache = createCache([listDetectors]); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get GuardDuty detector: '); + done(); + }); + }); + + it('should not return any result if list dectectors response not found', function (done) { + const cache = createNullCache(); + eksProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/guardduty/exportedFindingsEncrypted.js b/plugins/aws/guardduty/exportedFindingsEncrypted.js new file mode 100644 index 000000000..370bf4ff6 --- /dev/null +++ b/plugins/aws/guardduty/exportedFindingsEncrypted.js @@ -0,0 +1,147 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Exported Findings Encrypted', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that GuardDuty findings export is encrypted using desired KMS encryption level.', + more_info: 'GuardDuty data, such as findings, is encrypted at rest using AWS owned customer master keys (CMK). ' + + 'Additionally, you can use your use key (CMKs) in order to gain more control over data encryption/decryption process.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_exportfindings.html', + recommended_action: 'Encrypt GuardDuty Export Findings with customer-manager keys (CMKs)', + apis: ['GuardDuty:listDetectors', 'GuardDuty:listPublishingDestinations', 'GuardDuty:describePublishingDestination', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + findings_desired_encryption_level: { + name: 'GuardDuty Findings Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.findings_desired_encryption_level || this.settings.findings_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(region.guardduty, function(region, rcb){ + var listDetectors = helpers.addSource(cache, source, + ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, + 'Unable to query for GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return rcb(); + } + + if (!listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let detectorId of listDetectors.data) { + if (!detectorId) continue; + + const resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detectorId; + + var listPublishingDestinations = helpers.addSource(cache, source, + ['guardduty', 'listPublishingDestinations', region, detectorId]); + + if (!listPublishingDestinations || + listPublishingDestinations.err || + !listPublishingDestinations.data || + !listPublishingDestinations.data.Destinations) { + helpers.addResult(results, 3, + 'Unable to query for GuardDuty publishing destinations: ' + helpers.addError(listPublishingDestinations), + region, resource); + continue; + } + + if (!listPublishingDestinations.data.Destinations.length) { + helpers.addResult(results, 0, + 'Guardduty findings export is not configured', + region, resource); + continue; + } + + for (let destination of listPublishingDestinations.data.Destinations) { + let resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}/publishingDestination/${destination.DestinationId}`; + + var describePublishingDestination = helpers.addSource(cache, source, + ['guardduty', 'describePublishingDestination', region, destination.DestinationId]); + + if (!describePublishingDestination || + describePublishingDestination.err || + !describePublishingDestination.data) { + helpers.addResult(results, 3, + 'Unable to query for GuardDuty publishing destination: ' + helpers.addError(describePublishingDestination), + region, resource); + continue; + } + + if (describePublishingDestination.data.DestinationProperties && + describePublishingDestination.data.DestinationProperties.KmsKeyArn) { + var kmsKey = describePublishingDestination.data.DestinationProperties.KmsKeyArn; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `GuardDuty findings export is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `GuardDuty findings export is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/guardduty/exportedFindingsEncrypted.spec.js b/plugins/aws/guardduty/exportedFindingsEncrypted.spec.js new file mode 100644 index 000000000..3f5609544 --- /dev/null +++ b/plugins/aws/guardduty/exportedFindingsEncrypted.spec.js @@ -0,0 +1,213 @@ +const expect = require('chai').expect; +var exportedFindingsEncrypted = require('./exportedFindingsEncrypted'); + + +const listDetectors = [ + "febe94ba60bad6400e7ea861564c3e23" +]; + +const listPublishingDestinations = [ + { + "DestinationId": "b0bee1c1aca099effbcf75e6bad47ca4", + "DestinationType": "S3", + "Status": "PUBLISHING" + } +]; + +const describePublishingDestination = [ + { + "DestinationId": "b0bee1c1aca099effbcf75e6bad47ca4", + "DestinationType": "S3", + "Status": "PUBLISHING", + "DestinationProperties": { + "DestinationArn": "arn:aws:s3:::viteace-data-bucket", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + }, + { + "DestinationId": "b0bee1c1aca099effbcf75e6bad47ca4", + "DestinationType": "S3", + "Status": "PUBLISHING", + "DestinationProperties": { + "DestinationArn": "arn:aws:s3:::viteace-data-bucket", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (listDetectors, listPublishingDestinations, describePublishingDestination, keys, describeKey, listDetectorsErr, keysErr, listPublishingDestinationsErr, describeKeyErr, describePublishingDestinationErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var detectorsId = (listDetectors && listDetectors.length) ? listDetectors[0] : null; + var destinationId = (listPublishingDestinations && listPublishingDestinations.length) ? + listPublishingDestinations[0].DestinationId : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + err: listDetectorsErr, + data: listDetectors + } + }, + listPublishingDestinations: { + 'us-east-1': { + [detectorsId]: { + err: listPublishingDestinationsErr, + data: { + "Destinations": listPublishingDestinations + } + } + } + }, + describePublishingDestination: { + 'us-east-1': { + [destinationId]: { + err: describePublishingDestinationErr, + data: describePublishingDestination + } + } + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': null + } + } + }; +}; + +describe('exportedFindingsEncrypted', function () { + describe('run', function () { + + it('should PASS if GuardDuty Export Findings is encrypted with desired level', function (done) { + const cache = createCache([listDetectors[0]], [listPublishingDestinations[0]], describePublishingDestination[0], listKeys, describeKey[0]); + exportedFindingsEncrypted.run(cache, { findings_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if GuardDuty Export Findings is not encrypted with desired level ', function (done) { + const cache = createCache([listDetectors[0]], [listPublishingDestinations[0]], describePublishingDestination[1], listKeys, describeKey[1]); + exportedFindingsEncrypted.run(cache, {findings_desired_encryption_level: 'cloudhsm' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if on GuardDuty detectors found', function (done) { + const cache = createCache([]); + exportedFindingsEncrypted.run(cache, { findings_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list GuardDuty detectors', function (done) { + const cache = createCache(null, null, null, null, null, { message: 'Unable to list GuardDuty detectors'}); + exportedFindingsEncrypted.run(cache, { findings_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list GuardDuty publishing destinations', function (done) { + const cache = createCache([listDetectors[0]], {}, describePublishingDestination[0], null, null, null, null, { message: 'Unable to query GuardDuty publishing destinations'}); + exportedFindingsEncrypted.run(cache, { findings_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list detectors response not found', function (done) { + const cache = createNullCache(); + exportedFindingsEncrypted.run(cache, { findings_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache([listDetectors[0]], null, null, null, null, null, { message: "Unable to list KMS keys" }); + exportedFindingsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/guardduty/guarddutyEnabled.js b/plugins/aws/guardduty/guarddutyEnabled.js index 1a2ca7135..59e221dd5 100644 --- a/plugins/aws/guardduty/guarddutyEnabled.js +++ b/plugins/aws/guardduty/guarddutyEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'GuardDuty is Enabled', category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures GuardDuty is enabled', more_info: 'GuardDuty provides threat intelligence by analyzing several AWS data sources for security risks and should be enabled in all accounts.', recommended_action: 'Enable GuardDuty for all AWS accounts.', link: 'https://docs.aws.amazon.com/guardduty/latest/ug/what-is-guardduty.html', apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:DeleteDetector'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/guardduty/guarddutyMaster.js b/plugins/aws/guardduty/guarddutyMaster.js index e80623f9e..2f5225859 100644 --- a/plugins/aws/guardduty/guarddutyMaster.js +++ b/plugins/aws/guardduty/guarddutyMaster.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'GuardDuty Master Account', category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures GuardDuty master account is correct', more_info: 'Organizations with large numbers of AWS accounts should configure GuardDuty findings from all member accounts to be sent to a consistent master account.', link: 'https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_accounts.html#guardduty_master', @@ -17,6 +19,7 @@ module.exports = { default: '', }, }, + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:CreateMembers', 'guardduty:DeleteDetector', 'guardduty:DeleteMembers'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/guardduty/lambdaProtectionEnabled.js b/plugins/aws/guardduty/lambdaProtectionEnabled.js new file mode 100644 index 000000000..9d29735e2 --- /dev/null +++ b/plugins/aws/guardduty/lambdaProtectionEnabled.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'GuardDuty Lambda Protection Enabled', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures GuardDuty protection is enabled for Lambda functions.' , + more_info: 'Enabling GuardDuty Lambda Protection helps detect potential security threats offering enhanced security by monitoring network activity logs and generating findings for suspicious activities and security issues.', + recommended_action: 'Enable GuardDuty Lambda protection for all AWS accounts.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/lambda-protection.html', + apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:UpdateDetector', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var regions = helpers.regions(settings); + + async.each(regions.guardduty, function(region, rcb) { + var listDetectors = helpers.addSource(cache, source, ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, 'Unable to list GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return rcb(); + } + + if (!listDetectors.data || !listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return rcb(); + } + + listDetectors.data.forEach(function(detectorId) { + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; + var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); + + if (!getDetector) return; + + if (getDetector.err || !getDetector.data) { + helpers.addResult(results, 3, 'Unable to get GuardDuty detector: ' + helpers.addError(getDetector),region, detectorId); + return; + } + + var detector = getDetector.data; + var lambdaLoginEventsFeature = (detector.Features && detector.Features.find(feature => feature.Name === 'LAMBDA_NETWORK_LOGS' && feature.Status === 'ENABLED')) ? true : false; + + if (lambdaLoginEventsFeature) { + helpers.addResult(results, 0, 'GuardDuty Lambda protection is enabled' , region, resource); + } else { + helpers.addResult(results, 2, 'GuardDuty Lambda protection is disabled ' , region, resource); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/guardduty/lambdaProtectionEnabled.spec.js b/plugins/aws/guardduty/lambdaProtectionEnabled.spec.js new file mode 100644 index 000000000..f35b1e651 --- /dev/null +++ b/plugins/aws/guardduty/lambdaProtectionEnabled.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var lambdaProtectionEnabled = require('./lambdaProtectionEnabled'); + +const listDetectors = [ + "6cc45a4adb18e50f5ba51f6800db03d8" +]; + +const getDetector = [ + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "Features": [ + { + "Name": "CLOUD_TRAIL", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "DNS_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "FLOW_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "S3_DATA_EVENTS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:28+05:00" + }, + { + "Name": "EKS_AUDIT_LOGS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:34+05:00" + }, + { + "Name": "EBS_MALWARE_PROTECTION", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:14:46+05:00" + }, + { + "Name": "RDS_LOGIN_EVENTS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:40:00+05:00" + }, + { + "Name": "LAMBDA_NETWORK_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:40:00+05:00" + }, + ], + + "Tags": {} + }, + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "Features": [ + { + "Name": "CLOUD_TRAIL", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "DNS_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "FLOW_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "S3_DATA_EVENTS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:28+05:00" + }, + { + "Name": "EKS_AUDIT_LOGS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:34+05:00" + }, + { + "Name": "EBS_MALWARE_PROTECTION", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:14:46+05:00" + }, + { + "Name": "LAMBDA_NETWORK_LOGS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:40:00+05:00" + }, + ], + "Tags": {} + } +]; + +const createCache = (listDetectors, getDetector) => { + let detectorId = (listDetectors.length) ? listDetectors[0] : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + data: listDetectors + }, + }, + getDetector: { + 'us-east-1': { + [detectorId]: { + data: getDetector + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': null + } + } + }; +}; + + +describe('lambdaProtectionEnabled', function () { + describe('run', function () { + it('should FAIL if GuardDuty lambda protection is diabled', function (done) { + const cache = createCache(listDetectors, getDetector[1],); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty Lambda protection is disabled'); + done(); + }); + }); + + it('should PASS if GuardDuty lambda protection is enabled', function (done) { + const cache = createCache(listDetectors, getDetector[0]); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty Lambda protection is enabled'); + done(); + }); + }); + + it('should PASS if no detectors found', function (done) { + const cache = createCache([]); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No GuardDuty detectors found'); + done(); + }); + }); + + it('should UNKNOWN unable to list GuardDuty detector', function (done) { + const cache = createErrorCache(); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list GuardDuty detectors:'); + done(); + }); + }); + + it('should UNKNOWN unable to get GuardDuty detector', function (done) { + const cache = createCache([listDetectors]); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get GuardDuty detector: '); + done(); + }); + }); + + it('should not return any result if list dectectors response not found', function (done) { + const cache = createNullCache(); + lambdaProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/guardduty/noActiveFindings.js b/plugins/aws/guardduty/noActiveFindings.js new file mode 100644 index 000000000..4e6a50bd7 --- /dev/null +++ b/plugins/aws/guardduty/noActiveFindings.js @@ -0,0 +1,94 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'GuardDuty No Active Findings', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that GurardDuty active/current findings does not exist in your AWS account.', + more_info: 'Amazon GuardDuty is a threat detection service that continuously monitors your AWS accounts and workloads for malicious activity and delivers detailed security findings for visibility and remediation. ' + + 'These findings should be acted upon and archived after they have been remediated in order to follow security best practices. ' + + 'If a finding had not been archived after set amount of time, Khulnasoft CSPM plugin will display a FAIL result.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_findings.html', + recommended_action: 'Resolve the GuardDuty findings and archive them', + apis: ['GuardDuty:listDetectors', 'GuardDuty:listFindings', 'GuardDuty:getDetector', 'GuardDuty:getFindings', + 'STS:getCallerIdentity'], + settings: { + guardduty_findings_fail: { + name: 'GuardDuty Findings Fail', + description: 'Return a failing result if a finding has not been archived after these many hours', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '48' + } + }, + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:ArchiveFindings', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + + const acctRegion = helpers.defaultRegion(settings); + const awsOrGov = helpers.defaultPartition(settings); + const accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + const regions = helpers.regions(settings); + + var config = { + guardduty_findings_fail: parseInt(settings.guardduty_findings_fail || this.settings.guardduty_findings_fail.default) + }; + + regions.guardduty.forEach((region) => { + const listDetectors = helpers.addSource(cache, source, ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return; + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, + 'Unable to list GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return; + } + + if (!listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return; + } + + for (let detectorId of listDetectors.data) { + const resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detectorId; + + const getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); + + if (!getDetector || getDetector.err || !getDetector.data) { + helpers.addResult(results, 3, `Unable to get GuardDuty detector: ${helpers.addError(listDetectors)}`, region, resource); + continue; + } + + const getFindings = helpers.addSource(cache, source, ['guardduty', 'getFindings', region, detectorId]); + + if (!getFindings) { + helpers.addResult(results, 0, 'No active findings available', region, resource); + continue; + } + + if (getFindings.err || !getFindings.data || !getFindings.data.Findings) { + helpers.addResult(results, 3, `Unable to get GuardDuty findings: ${helpers.addError(getFindings)}`, region, resource); + continue; + } + + if (!getFindings.data.Findings.length) { + helpers.addResult(results, 0, 'No active findings available', region, resource); + continue; + } + + let activeFindings = getFindings.data.Findings.filter(finding => finding.CreatedAt && + helpers.hoursBetween(new Date, finding.CreatedAt) > config.guardduty_findings_fail); + let status = (activeFindings && activeFindings.length) ? 2 : 0; + + helpers.addResult(results, status, + `GuardDuty has ${status == 0 ? 0 : activeFindings.length} active finding(s)`, region, resource); + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/guardduty/noActiveFindings.spec.js b/plugins/aws/guardduty/noActiveFindings.spec.js new file mode 100644 index 000000000..ef01aab0d --- /dev/null +++ b/plugins/aws/guardduty/noActiveFindings.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +const noActiveFindings = require('./noActiveFindings'); + +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 1); + +const listDetectors = [ + "febe94ba60bad6400e7ea861564c3e23" +]; + +const getDetector = [ + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "DISABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "DataSources": { + "CloudTrail": { + "Status": "ENABLED" + }, + "DNSLogs": { + "Status": "ENABLED" + }, + "FlowLogs": { + "Status": "ENABLED" + }, + "S3Logs": { + "Status": "ENABLED" + } + }, + "Tags": {} + } +]; + +const listFindings = { + FindingIds: [ + "60bebb31f802c270ce9157b51e3d5e5a", + "6ebebb2c1a90f5d5a307371067738422", + "78bebb2c1a926382babbd5f9726c012f", + "c2bea3df06236ff0cc563bed3bb184b0" + ] +}; + +const getFindings = [ + { + "Findings": [ + { + "AccountId": "000011112222", + "Arn": "arn:aws:guardduty:us-east-1:000011112222:detector/febe94ba60bad6400e7ea861564c3e23/finding/c2bea3df06236ff0cc563bed3bb184b0", + "CreatedAt": failDate, + "Description": "AWS CloudTrail trail codepipeline-source-trail was disabled by sadeed calling PutEventSelectors under unusual circumstances. This can be attackers attempt to cover their tracks by eliminating any trace of activity performed while they accessed your account.", + "Id": "c2bea3df06236ff0cc563bed3bb184b0", + "Partition": "aws", + "Region": "us-east-1", + "Resource": { + "AccessKeyDetails": { + "AccessKeyId": "ABCDEFGHI", + "PrincipalId": "000011112222", + "UserName": "sadeed", + "UserType": "IAMUser" + }, + "ResourceType": "AccessKey" + }, + "SchemaVersion": "2.0", + "Service": { + "Action": { + "ActionType": "AWS_API_CALL", + "AwsApiCallAction": { + "Api": "PutEventSelectors", + "CallerType": "Remote IP", + "RemoteIpDetails": { + "City": { + "CityName": "Lahore" + }, + "Country": { + "CountryName": "Pakistan" + }, + "GeoLocation": { + "Lat": 31.5822, + "Lon": 74.3292 + }, + "IpAddressV4": "72.255.36.31", + "Organization": { + "Asn": "9541", + "AsnOrg": "Cyber Internet Services Pvt Ltd.", + "Isp": "Cybernet", + "Org": "Cybernet" + } + }, + "ServiceName": "cloudtrail.amazonaws.com" + } + }, + "Archived": false, + "Count": 2, + "DetectorId": "febe94ba60bad6400e7ea861564c3e23", + "EventFirstSeen": "2021-11-22T12:45:25.000Z", + "EventLastSeen": "2021-11-22T13:09:26.000Z", + "ResourceRole": "TARGET", + "ServiceName": "guardduty" + }, + "Severity": 2, + "Title": "AWS CloudTrail trail codepipeline-source-trail was disabled.", + "Type": "Stealth:IAMUser/CloudTrailLoggingDisabled", + "UpdatedAt": "2021-11-22T13:25:38.883Z" + } + ] + }, + { + "Findings": [ + { + "AccountId": "000011112222", + "Arn": "arn:aws:guardduty:us-east-1:000011112222:detector/febe94ba60bad6400e7ea861564c3e23/finding/60bebb31f802c270ce9157b51e3d5e5a", + "CreatedAt": new Date(), + "Description": "API ListFindings was invoked using root credentials from IP address 103.127.36.50.", + "Id": "60bebb31f802c270ce9157b51e3d5e5a", + "Partition": "aws", + "Region": "us-east-1", + "Resource": { + "AccessKeyDetails": { + "AccessKeyId": "ABCDEFGHI", + "PrincipalId": "000011112222", + "UserName": "aws-viteace", + "UserType": "Root" + }, + "ResourceType": "AccessKey" + }, + "SchemaVersion": "2.0", + "Service": { + "Action": { + "ActionType": "AWS_API_CALL", + "AwsApiCallAction": { + "Api": "ListFindings", + "CallerType": "Remote IP", + "RemoteIpDetails": { + "City": { + "CityName": "Lahore" + }, + "Country": { + "CountryName": "Pakistan" + }, + "GeoLocation": { + "Lat": 31.5822, + "Lon": 74.3292 + }, + "IpAddressV4": "103.127.36.50", + "Organization": { + "Asn": "136030", + "AsnOrg": "Redtone Telecommunications Pakistan Private Limited", + "Isp": "Redtone Telecommunications Pakistan Private Limite", + "Org": "Redtone Telecommunications Pakistan Private Limite" + } + }, + "ServiceName": "guardduty.amazonaws.com" + } + }, + "Archived": false, + "Count": 16, + "DetectorId": "febe94ba60bad6400e7ea861564c3e23", + "EventFirstSeen": "2021-12-01T14:19:55.000Z", + "EventLastSeen": "2021-12-01T14:25:28.000Z", + "ResourceRole": "TARGET", + "ServiceName": "guardduty" + }, + "Severity": 2, + "Title": "API ListFindings was invoked using root credentials.", + "Type": "Policy:IAMUser/RootCredentialUsage", + "UpdatedAt": "2021-12-01T14:31:01.095Z" + }, + ] + } +]; + +const createCache = (listDetectors, getDetector, listFindings, getFindings, listDetectorsErr, getDetectorErr) => { + let detectorId = (listDetectors.length) ? listDetectors[0] : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + data: listDetectors, + err: listDetectorsErr + }, + }, + getDetector: { + 'us-east-1': { + [detectorId]: { + data: getDetector, + err: getDetectorErr + } + } + }, + listFindings: { + 'us-east-1': { + [detectorId]: { + data: listFindings + } + } + }, + getFindings: { + 'us-east-1': { + [detectorId]: { + data: getFindings + } + } + } + } + }; +}; + +describe('noActiveFindings', function () { + describe('run', function () { + it('should FAIL if GuardDuty has more than 1 active finding(s)', function (done) { + const cache = createCache(listDetectors, getDetector, listFindings, getFindings[0]); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if GuardDuty has 0 active finding(s)', function (done) { + const cache = createCache(listDetectors, getDetector, listFindings, getFindings[1]); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no detectors found', function (done) { + const cache = createCache([]); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no findings available', function (done) { + const cache = createCache(listDetectors, getDetector, listFindings, { Findings: [] }); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN unable to list GuardDuty detectors', function (done) { + const cache = createCache(listDetectors, getDetector, listFindings, { Findings: [] }, { message: 'Unable to find data' }); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN unable to get GuardDuty detector', function (done) { + const cache = createCache(listDetectors, getDetector, listFindings, { Findings: [] }, null, { message: 'Unable to find data' }); + noActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/guardduty/rdsProtectionEnabled.js b/plugins/aws/guardduty/rdsProtectionEnabled.js new file mode 100644 index 000000000..7a462e897 --- /dev/null +++ b/plugins/aws/guardduty/rdsProtectionEnabled.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'GuardDuty RDS Protection Enabled', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures GuardDuty protection is enabled for RDS instances.' , + more_info: 'Enabling GuardDuty RDS Protection helps analyze RDS login activity to identify access threats, offering enhanced security. It enables proactive threat detection, automated alerting, and flexible configuration for AWS accounts.', + recommended_action: 'Enable GuardDuty RDS protection for all AWS accounts.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/rds-protection.html', + apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:UpdateDetector', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var regions = helpers.regions(settings); + + async.each(regions.guardduty, function(region, rcb) { + var listDetectors = helpers.addSource(cache, source, ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, 'Unable to list GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return rcb(); + } + + if (!listDetectors.data || !listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return rcb(); + } + + listDetectors.data.forEach(function(detectorId) { + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; + var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); + + if (!getDetector) return; + + if (getDetector.err || !getDetector.data) { + helpers.addResult(results, 3, 'Unable to get GuardDuty detector: ' + helpers.addError(getDetector),region); + return; + } + + var detector = getDetector.data; + var rdsLoginEventsFeature = (detector.Features && detector.Features.find(feature => feature.Name === 'RDS_LOGIN_EVENTS' && feature.Status === 'ENABLED')) ? true : false; + + if (rdsLoginEventsFeature) { + helpers.addResult(results, 0, 'GuardDuty RDS protection is enabled' , region, resource); + } else { + helpers.addResult(results, 2, 'GuardDuty RDS protection is disabled ' , region, resource); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/guardduty/rdsProtectionEnabled.spec.js b/plugins/aws/guardduty/rdsProtectionEnabled.spec.js new file mode 100644 index 000000000..702dd650e --- /dev/null +++ b/plugins/aws/guardduty/rdsProtectionEnabled.spec.js @@ -0,0 +1,213 @@ +var expect = require('chai').expect; +var rdsProtectionEnabled = require('./rdsProtectionEnabled'); + +const listDetectors = [ + "6cc45a4adb18e50f5ba51f6800db03d8" +]; + +const getDetector = [ + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "Features": [ + { + "Name": "CLOUD_TRAIL", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "DNS_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "FLOW_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "S3_DATA_EVENTS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:28+05:00" + }, + { + "Name": "EKS_AUDIT_LOGS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:34+05:00" + }, + { + "Name": "EBS_MALWARE_PROTECTION", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:14:46+05:00" + }, + { + "Name": "RDS_LOGIN_EVENTS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:40:00+05:00" + }, + ], + + "Tags": {} + }, + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "Features": [ + { + "Name": "CLOUD_TRAIL", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "DNS_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "FLOW_LOGS", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:44:02+05:00" + }, + { + "Name": "S3_DATA_EVENTS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:28+05:00" + }, + { + "Name": "EKS_AUDIT_LOGS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:19:34+05:00" + }, + { + "Name": "EBS_MALWARE_PROTECTION", + "Status": "ENABLED", + "UpdatedAt": "2024-03-25T14:14:46+05:00" + }, + { + "Name": "RDS_LOGIN_EVENTS", + "Status": "DISABLED", + "UpdatedAt": "2024-03-25T14:40:00+05:00" + }, + ], + "Tags": {} + } +]; + +const createCache = (listDetectors, getDetector) => { + let detectorId = (listDetectors.length) ? listDetectors[0] : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + data: listDetectors + }, + }, + getDetector: { + 'us-east-1': { + [detectorId]: { + data: getDetector + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': null + } + } + }; +}; + + +describe('rdsProtectionEnabled', function () { + describe('run', function () { + it('should FAIL if GuardDuty RDS protection is diabled', function (done) { + const cache = createCache(listDetectors, getDetector[1],); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty RDS protection is disabled'); + done(); + }); + }); + + it('should PASS if GuardDuty RDS protection is enabled', function (done) { + const cache = createCache(listDetectors, getDetector[0]); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty RDS protection is enabled'); + done(); + }); + }); + + it('should PASS if no detectors found', function (done) { + const cache = createCache([]); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No GuardDuty detectors found'); + done(); + }); + }); + + it('should UNKNOWN unable to list GuardDuty detector', function (done) { + const cache = createErrorCache(); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list GuardDuty detectors:'); + done(); + }); + }); + + it('should UNKNOWN unable to get GuardDuty detector', function (done) { + const cache = createCache([listDetectors]); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get GuardDuty detector: '); + done(); + }); + }); + + it('should not return any result if list dectectors response not found', function (done) { + const cache = createNullCache(); + rdsProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/guardduty/s3ProtectionEnabled.js b/plugins/aws/guardduty/s3ProtectionEnabled.js new file mode 100644 index 000000000..a1002c2c7 --- /dev/null +++ b/plugins/aws/guardduty/s3ProtectionEnabled.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 GuardDuty Enabled', + category: 'GuardDuty', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures GuardDuty is enabled for S3 buckets' , + more_info: 'Enabling GuardDuty S3 protection helps to detect and prevent unauthorized access to your S3 buckets.', + recommended_action: 'Enable GuardDuty S3 protection for all AWS accounts.', + link: 'https://docs.aws.amazon.com/guardduty/latest/ug/s3-protection.html', + apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], + realtime_triggers: ['guardduty:CreateDetector', 'guardduty:UpdateDetector', 'guardduty:DeleteDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var regions = helpers.regions(settings); + + async.each(regions.guardduty, function(region, rcb) { + var listDetectors = helpers.addSource(cache, source, ['guardduty', 'listDetectors', region]); + + if (!listDetectors) return rcb(); + + if (listDetectors.err || !listDetectors.data) { + helpers.addResult(results, 3, 'Unable to list GuardDuty detectors: ' + helpers.addError(listDetectors), region); + return rcb(); + } + + if (!listDetectors.data || !listDetectors.data.length) { + helpers.addResult(results, 0, 'No GuardDuty detectors found', region); + return rcb(); + } + + listDetectors.data.forEach(function(detectorId) { + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; + var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); + + if (!getDetector) return; + + if (getDetector.err || !getDetector.data) { + helpers.addResult(results, 3, 'Unable to get GuardDuty detector: ' + helpers.addError(getDetector),region); + return; + } + + var detector = getDetector.data; + + if ( detector.DataSources && detector.DataSources.S3Logs && detector.DataSources.S3Logs.Status === 'DISABLED'){ + helpers.addResult(results, 2, 'GuardDuty S3 protection is disabled', region, resource); + } else { + helpers.addResult(results, 0, 'GuardDuty S3 protection is enabled', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/guardduty/s3ProtectionEnabled.spec.js b/plugins/aws/guardduty/s3ProtectionEnabled.spec.js new file mode 100644 index 000000000..4338ee9f6 --- /dev/null +++ b/plugins/aws/guardduty/s3ProtectionEnabled.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +var s3ProtectionEnabled = require('./s3ProtectionEnabled'); + +const listDetectors = [ + "6cc45a4adb18e50f5ba51f6800db03d8" +]; + +const getDetector = [ + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "DataSources": { + "CloudTrail": { + "Status": "ENABLED" + }, + "DNSLogs": { + "Status": "ENABLED" + }, + "FlowLogs": { + "Status": "ENABLED" + }, + "S3Logs": { + "Status": "ENABLED" + } + }, + "Tags": {} + }, + { + "CreatedAt": "2021-11-16T15:54:17.530Z", + "FindingPublishingFrequency": "SIX_HOURS", + "ServiceRole": "arn:aws:iam::000011112222:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + "Status": "ENABLED", + "UpdatedAt": "2021-12-01T14:13:59.029Z", + "DataSources": { + "CloudTrail": { + "Status": "ENABLED" + }, + "DNSLogs": { + "Status": "ENABLED" + }, + "FlowLogs": { + "Status": "ENABLED" + }, + "S3Logs": { + "Status": "DISABLED" + } + }, + "Tags": {} + } +]; + +const createCache = (listDetectors, getDetector) => { + let detectorId = (listDetectors.length) ? listDetectors[0] : null; + return { + guardduty: { + listDetectors: { + 'us-east-1': { + data: listDetectors + }, + }, + getDetector: { + 'us-east-1': { + [detectorId]: { + data: getDetector + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + guardduty: { + listDetectors: { + 'us-east-1': null + } + } + }; +}; + + +describe('s3ProtectionEnabled', function () { + describe('run', function () { + it('should FAIL if GuardDuty S3 protection is diabled', function (done) { + const cache = createCache(listDetectors, getDetector[1],); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty S3 protection is disabled'); + done(); + }); + }); + + it('should PASS if GuardDuty S3protection is enabled', function (done) { + const cache = createCache(listDetectors, getDetector[0]); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('GuardDuty S3 protection is enabled'); + done(); + }); + }); + + it('should PASS if no detectors found', function (done) { + const cache = createCache([]); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No GuardDuty detectors found'); + done(); + }); + }); + + it('should UNKNOWN unable to list GuardDuty detector', function (done) { + const cache = createErrorCache(); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list GuardDuty detectors:'); + done(); + }); + }); + + it('should UNKNOWN unable to get GuardDuty detector', function (done) { + const cache = createCache([listDetectors]); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get GuardDuty detector: '); + done(); + }); + }); + + it('should not return any result if list dectectors response not found', function (done) { + const cache = createNullCache(); + s3ProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/healthlake/dataStoreEncrypted.js b/plugins/aws/healthlake/dataStoreEncrypted.js new file mode 100644 index 000000000..c543c8785 --- /dev/null +++ b/plugins/aws/healthlake/dataStoreEncrypted.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'HealthLake Data Store Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that AWS HealthLake Data Store is using desired encryption level.', + more_info: 'Amazon HealthLake is a Fast Healthcare Interoperability Resources (FHIR)-enabled patient Data Store that uses AWS-managed KMS keys for encryption. ' + + 'Encrypt these data stores using customer-managed keys (CMKs) in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create HealthLake Data Store with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/healthlake/latest/devguide/data-protection.html', + apis: ['HealthLake:listFHIRDatastores', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + healthlake_datastore_desired_encryption_level: { + name: 'HealthLake Data Store Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['healthlake:CreateFHIRDatastore', 'healthlake:DeleteFHIRDatastore'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.healthlake_datastore_desired_encryption_level || this.settings.healthlake_datastore_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.healthlake, function(region, rcb){ + var listFHIRDatastores = helpers.addSource(cache, source, + ['healthlake', 'listFHIRDatastores', region]); + + if (!listFHIRDatastores) return rcb(); + + if (listFHIRDatastores.err || !listFHIRDatastores.data) { + helpers.addResult(results, 3, `Unable to query HealthLake Data Store: ${helpers.addError(listFHIRDatastores)}`, region); + return rcb(); + } + + if (!listFHIRDatastores.data.length) { + helpers.addResult(results, 0, 'No HealthLake data stores found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let datastore of listFHIRDatastores.data) { + if (!datastore.DatastoreArn) continue; + + let resource = datastore.DatastoreArn; + + if (datastore.SseConfiguration && + datastore.SseConfiguration.KmsEncryptionConfig && + datastore.SseConfiguration.KmsEncryptionConfig.KmsKeyId) { + + var kmskey = datastore.SseConfiguration.KmsEncryptionConfig.KmsKeyId; + var kmsKeyId = kmskey.split('/')[1] ? kmskey.split('/')[1] : kmskey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmskey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `HealthLake data store is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `HealthLake data store is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/healthlake/dataStoreEncrypted.spec.js b/plugins/aws/healthlake/dataStoreEncrypted.spec.js new file mode 100644 index 000000000..c7beb1d3b --- /dev/null +++ b/plugins/aws/healthlake/dataStoreEncrypted.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var dataStoreEncrypted = require('./dataStoreEncrypted'); + + +const listFHIRDatastores = [ + { + "DatastoreId": "7ad17b6c9d48056865a8800b86cc2797", + "DatastoreArn": "arn:aws:healthlake:us-east-1:000111222333:datastore/fhir/7ad17b6c9d48056865a8800b86cc2797", "DatastoreName": "sadeed-ds1", + "DatastoreStatus": "ACTIVE", + "CreatedAt": "2021-11-23T15:31:55.180000+05:00", + "DatastoreTypeVersion": "R4", + "DatastoreEndpoint": "https://healthlake.us-east-1.amazonaws.com/datastore/7ad17b6c9d48056865a8800b86cc2797/r4/", + "SseConfiguration": { + "KmsEncryptionConfig": { + "CmkType": "CUSTOMER_MANAGED_KMS_KEY", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + }, +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (datastore, keys, describeKey, datastoreErr, keysErr, describeKeyErr) => { + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + return { + healthlake: { + listFHIRDatastores: { + 'us-east-1': { + err: datastoreErr, + data: datastore + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + + +describe('dataStoreEncrypted', function () { + describe('run', function () { + it('should PASS if HealthLake Data Store is encrypted with desired encryption level', function (done) { + const cache = createCache(listFHIRDatastores, listKeys, describeKey[0]); + dataStoreEncrypted.run(cache, { healthlake_datastore_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('HealthLake data store is encrypted with awscmk'); + done(); + }); + }); + + + it('should FAIL if HealthLake Data Store is not encrypted with desired encyption level', function (done) { + const cache = createCache(listFHIRDatastores, listKeys, describeKey[1]); + dataStoreEncrypted.run(cache, { healthlake_datastore_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('HealthLake data store is encrypted with awskms'); + done(); + }); + }); + + + it('should PASS if no HealthLake Data Store found', function (done) { + const cache = createCache([]); + dataStoreEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No HealthLake data stores found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list HealthLake Data Store', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list HealthLake Data Store encryption" }); + dataStoreEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + dataStoreEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/healthlake/dataStoreHasTags.js b/plugins/aws/healthlake/dataStoreHasTags.js new file mode 100644 index 000000000..80c7828fe --- /dev/null +++ b/plugins/aws/healthlake/dataStoreHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'HealthLake Data Store Has Tags', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that HealthLake Data Store has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify HealthLake data store and add tags.', + link: 'https://docs.aws.amazon.com/healthlake/latest/devguide/add-a-tag.html', + apis: ['HealthLake:listFHIRDatastores', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['healthlake:CreateFHIRDatastore', 'healthlake:DeleteFHIRDatastore', 'healthlake:TagResource', 'healthlake:UntagResource'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.healthlake, function(region, rcb){ + var listFHIRDatastores = helpers.addSource(cache, source, + ['healthlake', 'listFHIRDatastores', region]); + + if (!listFHIRDatastores) return rcb(); + + if (listFHIRDatastores.err || !listFHIRDatastores.data) { + helpers.addResult(results, 3, `Unable to query HealthLake Data Store: ${helpers.addError(listFHIRDatastores)}`, region); + return rcb(); + } + + if (!listFHIRDatastores.data.length) { + helpers.addResult(results, 0, 'No HealthLake data stores found', region); + return rcb(); + } + + const arnList = []; + for (let datastore of listFHIRDatastores.data){ + if (!datastore.DatastoreArn) continue; + + arnList.push(datastore.DatastoreArn); + } + + helpers.checkTags(cache, 'Healthlake data store', arnList, region, results, settings); + return rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/healthlake/dataStoreHasTags.spec.js b/plugins/aws/healthlake/dataStoreHasTags.spec.js new file mode 100644 index 000000000..a83e8f8f1 --- /dev/null +++ b/plugins/aws/healthlake/dataStoreHasTags.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +const dataStoreHasTags = require('./dataStoreHasTags'); + +const listFHIRDatastores = [ + { + "DatastoreId": "7ad17b6c9d48056865a8800b86cc2797", + "DatastoreArn": "arn:aws:healthlake:us-east-1:000111222333:datastore/fhir/7ad17b6c9d48056865a8800b86cc2797", + "DatastoreName": "sadeed-ds1", + "DatastoreStatus": "ACTIVE", + "CreatedAt": "2021-11-23T15:31:55.180000+05:00", + "DatastoreTypeVersion": "R4", + "DatastoreEndpoint": "https://healthlake.us-east-1.amazonaws.com/datastore/7ad17b6c9d48056865a8800b86cc2797/r4/", + "SseConfiguration": { + "KmsEncryptionConfig": { + "CmkType": "CUSTOMER_MANAGED_KMS_KEY", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + }, + { + "DatastoreId": "7ad17b6c9d48056865a8800b86cc2797", + "DatastoreArn": "arn:aws:healthlake:us-east-1:000111222333:datastore/fhir/7ad17b6c9d48056865a880", + "DatastoreName": "sadeed-ds1", + "DatastoreStatus": "ACTIVE", + "CreatedAt": "2021-11-23T15:31:55.180000+05:00", + "DatastoreTypeVersion": "R4", + "DatastoreEndpoint": "https://healthlake.us-east-1.amazonaws.com/datastore/7ad17b6c9d48056865a8800b86cc2797/r4/", + "SseConfiguration": { + "KmsEncryptionConfig": { + "CmkType": "CUSTOMER_MANAGED_KMS_KEY", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } + }, +]; + +const getResources = [ + { + "ResourceARN": "arn:aws:healthlake:us-east-1:000111222333:datastore/fhir/7ad17b6c9d48056865a880", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:healthlake:us-east-1:000111222333:datastore/fhir/7ad17b6c9d48056865a8800b86cc2797", + "Tags": [{key: 'value'}], + } +] + + +const createCache = (datastore, rgData) => { + return { + healthlake: { + listFHIRDatastores: { + 'us-east-1': { + err: null, + data: datastore + }, + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + + +describe('dataStoreHasTags', function () { + describe('run', function () { + it('should PASS if Bedrock custom model has tags', function (done) { + const cache = createCache([listFHIRDatastores[0]], [getResources[1]]); + dataStoreHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Healthlake data store has tags') + done(); + }); + }); + + it('should FAIL if Bedrock custom model doesnot have tags', function (done) { + const cache = createCache([listFHIRDatastores[0]], [getResources[0]]); + dataStoreHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Healthlake data store does not have any tags') + done(); + }); + }); + + it('should PASS if no Bedrock custom model found', function (done) { + const cache = createCache([]); + dataStoreHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No HealthLake data stores found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query Bedrock custom model', function (done) { + const cache = createCache(null, null); + dataStoreHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query HealthLake Data Store: ') + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listFHIRDatastores[0]],null); + dataStoreHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/accessKeysExtra.js b/plugins/aws/iam/accessKeysExtra.js index d643166c7..93bfa7101 100644 --- a/plugins/aws/iam/accessKeysExtra.js +++ b/plugins/aws/iam/accessKeysExtra.js @@ -4,11 +4,31 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Access Keys Extra', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Detects the use of more than one access key by any single user', more_info: 'Having more than one access key for a single user increases the chance of accidental exposure. Each account should only have one key that defines the users permissions.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/ManagingCredentials.html', recommended_action: 'Remove the extra access key for the specified user.', apis: ['IAM:generateCredentialReport'], + asl: { + conditions: [ + { + service: 'iam', + api: 'generateCredentialReport', + property: 'access_key_1_active', + op: 'ISFALSE' + }, + { + service: 'iam', + api: 'generateCredentialReport', + property: 'access_key_2_active', + op: 'ISFALSE', + logical: 'OR' + } + ] + }, + realtime_triggers: ['iam:CreateAccessKey','iam:UpdateAccessKey','iam:DeleteAccessKey'], run: function(cache, settings, callback) { diff --git a/plugins/aws/iam/accessKeysExtra.spec.js b/plugins/aws/iam/accessKeysExtra.spec.js new file mode 100644 index 000000000..5b7b46d31 --- /dev/null +++ b/plugins/aws/iam/accessKeysExtra.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +const accessKeysExtra = require('./accessKeysExtra'); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-11-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": false, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-11-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + } +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('accessKeysExtra', function () { + describe('run', function () { + it('should PASS if user is not using both access keys', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[1],generateCredentialReport[1]]); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if user is using both access keys', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[1],generateCredentialReport[2]]); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no user using access keys found', function (done) { + const cache = createCache([]); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no users using both access keys found', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[0]]); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + accessKeysExtra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/accessKeysLastUsed.js b/plugins/aws/iam/accessKeysLastUsed.js index b95d0c8b5..b8a29e131 100644 --- a/plugins/aws/iam/accessKeysLastUsed.js +++ b/plugins/aws/iam/accessKeysLastUsed.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Access Keys Last Used', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Detects access keys that have not been used for a period of time and that should be decommissioned', more_info: 'Having numerous, unused access keys extends the attack surface. Access keys should be removed if they are no longer being used.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/ManagingCredentials.html', @@ -28,6 +30,19 @@ module.exports = { default: 90 } }, + asl: { + conditions: [ + { + service: 'iam', + api: 'generateCredentialReport', + property: 'access_key_1_last_used_date', + transform: 'DAYSFROM', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:CreateAccessKey','iam:DeleteAccessKey'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/iam/accessKeysLastUsed.spec.js b/plugins/aws/iam/accessKeysLastUsed.spec.js new file mode 100644 index 000000000..4950416d0 --- /dev/null +++ b/plugins/aws/iam/accessKeysLastUsed.spec.js @@ -0,0 +1,217 @@ +var expect = require('chai').expect; +const accessKeysLastUsed = require('./accessKeysLastUsed'); + +var warnDate = new Date(); +warnDate.setMonth(warnDate.getMonth() - 4); +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 1); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 7); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": passDate, + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": passDate, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_date": warnDate, + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_date": warnDate, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-02-12T16:58:34+00:00", + "access_key_1_last_used_date": failDate, + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_date": failDate, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + } +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('accessKeysLastUsed', function () { + describe('run', function () { + it('should PASS if the user access key was last used within the pass limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[1]]); + var settings = { + access_keys_last_used_fail: 180, + access_keys_last_used_warn: 90 + }; + accessKeysLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if the user access key was last used within the warn limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[2]]); + var settings = { + access_keys_last_used_fail: 180, + access_keys_last_used_warn: 90 + }; + accessKeysLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(1); + expect(results[1].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if the user access key was last used more than the fail limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[3]]); + var settings = { + access_keys_last_used_fail: 180, + access_keys_last_used_warn: 90 + }; + accessKeysLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no users using access keys found', function (done) { + const cache = createCache([]); + accessKeysLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + accessKeysLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + accessKeysLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/accessKeysRotated.js b/plugins/aws/iam/accessKeysRotated.js index 846b82e82..18713ffab 100644 --- a/plugins/aws/iam/accessKeysRotated.js +++ b/plugins/aws/iam/accessKeysRotated.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Access Keys Rotated', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures access keys are not older than 180 days in order to reduce accidental exposures', more_info: 'Access keys should be rotated frequently to avoid having them accidentally exposed.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/ManagingCredentials.html', @@ -32,6 +34,7 @@ module.exports = { default: 90 } }, + realtime_triggers: ['iam:CreateAccessKey','iam:DeleteAccessKey'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/iam/accessKeysRotated.spec.js b/plugins/aws/iam/accessKeysRotated.spec.js new file mode 100644 index 000000000..771127e9e --- /dev/null +++ b/plugins/aws/iam/accessKeysRotated.spec.js @@ -0,0 +1,217 @@ +var expect = require('chai').expect; +const accessKeysRotated = require('./accessKeysRotated'); + +var warnDate = new Date(); +warnDate.setMonth(warnDate.getMonth() - 4); +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 2); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 7); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": warnDate, + "access_key_1_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": warnDate, + "access_key_2_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": passDate, + "access_key_1_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": passDate, + "access_key_2_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": failDate, + "access_key_1_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": failDate, + "access_key_2_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + } +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('accessKeysRotated', function () { + describe('run', function () { + it('should PASS if the user access key was last rotated within the pass limit', function (done) { + const cache = createCache([generateCredentialReport[0], generateCredentialReport[2]]); + var settings = { + access_keys_rotated_fail: 180, + access_keys_rotated_warn: 90 + }; + accessKeysRotated.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should WARN if the user access key was last rotated within the warn limit', function (done) { + const cache = createCache([generateCredentialReport[0], generateCredentialReport[1]]); + var settings = { + access_keys_rotated_fail: 180, + access_keys_rotated_warn: 80 + }; + accessKeysRotated.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(1); + expect(results[1].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if the user access key was last rotated more than the fail limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[3]]); + var settings = { + access_keys_rotated_fail: 180, + access_keys_rotated_warn: 90 + }; + accessKeysRotated.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no IAM user using access keys found', function (done) { + const cache = createCache([]); + accessKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + accessKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + accessKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/canaryKeysUsed.js b/plugins/aws/iam/canaryKeysUsed.js index 6f07bec84..0870ea30d 100644 --- a/plugins/aws/iam/canaryKeysUsed.js +++ b/plugins/aws/iam/canaryKeysUsed.js @@ -4,10 +4,12 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Canary Keys Used', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Critical', description: 'Detects when a special canary-token access key has been used', more_info: 'Canary access keys can be created with limited permissions and then used to detect when a potential breach occurs.', link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/ManagingCredentials.html', - recommended_action: 'Create a canary access token and provide its user to CloudSploit. If CloudSploit detects that the account is in use, it will trigger a failure.', + recommended_action: 'Create a canary access token and provide its user to CloudExploit. If CloudExploit detects that the account is in use, it will trigger a failure.', apis: ['IAM:generateCredentialReport'], settings: { canary_user: { @@ -17,6 +19,21 @@ module.exports = { default: '' } }, + //we need to check if (canary_user && access_key_1_last_used_date) || (canary_user && access_key_2_last_used_date) [SOP] + // canary_user && (access_key_1_last_used_date || access_key_2_last_used_date) + asl: { + conditions: [ + { + service: 'iam', + api: 'generateCredentialReport', + property: 'access_key_1_last_rotated', + transform: 'DAYSFROM', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/iam/canaryKeysUsed.spec.js b/plugins/aws/iam/canaryKeysUsed.spec.js new file mode 100644 index 000000000..d42380015 --- /dev/null +++ b/plugins/aws/iam/canaryKeysUsed.spec.js @@ -0,0 +1,196 @@ +var expect = require('chai').expect; +const canaryKeysUsed = require('./canaryKeysUsed'); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": null, + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": false, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-11-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + } +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('canaryKeysUsed', function () { + describe('run', function () { + it('should PASS if the canary user has not been used', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[1],generateCredentialReport[1]]); + var settings = { + canary_user: 'cloudexploit' + }; + canaryKeysUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if the canary user has been used', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[2],generateCredentialReport[2]]); + var settings = { + canary_user: 'cloudexploit' + }; + canaryKeysUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if the canary user specified in the config could not be found', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[2],generateCredentialReport[2]]); + var settings = { + canary_user: 'test_user' + }; + canaryKeysUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no users using access keys found', function (done) { + const cache = createCache([]); + var settings = { + canary_user: 'test_user' + }; + canaryKeysUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + var settings = { + canary_user: 'test_user' + }; + canaryKeysUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if canary user is not provided in settings', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[2],generateCredentialReport[2]]); + canaryKeysUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + canaryKeysUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/certificateExpiry.js b/plugins/aws/iam/certificateExpiry.js index 46b9eb832..24538a3e7 100644 --- a/plugins/aws/iam/certificateExpiry.js +++ b/plugins/aws/iam/certificateExpiry.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Certificate Expiry', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Detect upcoming expiration of certificates used with ELBs', more_info: 'Certificates that have expired will trigger warnings in all major browsers', link: 'http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-update-ssl-cert.html', @@ -22,6 +24,19 @@ module.exports = { default: 30 } }, + asl: { + conditions: [ + { + service: 'iam', + api: 'listServerCertificates', + property: 'Expiration', + transform: 'DAYSFROM', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:UploadServerCertificate','iam:DeleteServerCertificate','elasticloadbalancing:SetLoadBalancerListenerSSLCertificate'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/iam/certificateExpiry.spec.js b/plugins/aws/iam/certificateExpiry.spec.js new file mode 100644 index 000000000..6c1e8fe22 --- /dev/null +++ b/plugins/aws/iam/certificateExpiry.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +const certificateExpiry = require('./certificateExpiry'); + +var certWarn = new Date(); +certWarn.setMonth(certWarn.getMonth() + 1); +var certPass = new Date(); +certPass.setMonth(certPass.getMonth() + 2); +var certFail = new Date(); +certFail.setMonth(certFail.getMonth() - 1); + +const listServerCertificates = [ + { + "Path": "/", + "ServerCertificateName": "ExampleCertificate", + "ServerCertificateId": "ASCAYE32SRU5Z4FTTGYXD", + "Arn": "arn:aws:iam::560213429563:server-certificate/ExampleCertificate", + "UploadDate": "2020-08-13T11:37:27Z", + "Expiration": certPass + }, + { + "Path": "/", + "ServerCertificateName": "myServerCertificate", + "ServerCertificateId": "ASCAYE32SRU5QZ74UU3W2", + "Arn": "arn:aws:iam::560213429563:server-certificate/myServerCertificate", + "UploadDate": "2020-08-15T15:16:31Z", + "Expiration": certWarn + }, + { + "Path": "/", + "ServerCertificateName": "myServerCertificate", + "ServerCertificateId": "ASCAYE32SRU5QZ74UU3W2", + "Arn": "arn:aws:iam::560213429563:server-certificate/myServerCertificate", + "UploadDate": "2020-08-15T15:16:31Z", + "Expiration": certFail + } +]; + +const createCache = (report) => { + return { + iam:{ + listServerCertificates: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + listServerCertificates: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + listServerCertificates: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('certificateExpiry', function () { + describe('run', function () { + it('should PASS if certificate expires in more than pass limit', function (done) { + const cache = createCache([listServerCertificates[0]]); + var settings = { + certificate_expiry_pass: 45, + certificate_expiry_warn: 30 + }; + certificateExpiry.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if certificate expires within the warn limit', function (done) { + const cache = createCache([listServerCertificates[1]]); + var settings = { + certificate_expiry_pass: 45, + certificate_expiry_warn: 25 + }; + certificateExpiry.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if certificate expires in less than warn limit', function (done) { + const cache = createCache([listServerCertificates[1]]); + var settings = { + certificate_expiry_pass: 45, + certificate_expiry_warn: 35 + }; + certificateExpiry.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if certificate expired already', function (done) { + const cache = createCache([listServerCertificates[2]]); + var settings = { + certificate_expiry_pass: 45, + certificate_expiry_warn: 30 + }; + certificateExpiry.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no certificates found', function (done) { + const cache = createCache([]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list server certificates', function (done) { + const cache = createErrorCache(); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list server certificates response not found', function (done) { + const cache = createNullCache(); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/crossAccountMfaExtIdAccess.js b/plugins/aws/iam/crossAccountMfaExtIdAccess.js new file mode 100644 index 000000000..30d37bad4 --- /dev/null +++ b/plugins/aws/iam/crossAccountMfaExtIdAccess.js @@ -0,0 +1,96 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Cross-Account Access External ID and MFA', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that either MFA or external IDs are used to access AWS roles.', + more_info: 'IAM roles should be configured to require either a shared external ID or use an MFA device when assuming the role.', + link: 'https://aws.amazon.com/blogs/aws/mfa-protection-for-cross-account-access/', + recommended_action: 'Update the IAM role to either require MFA or use an external ID.', + apis: ['IAM:listRoles', 'STS:getCallerIdentity'], + realtime_triggers: ['iam:CreateRole','iam:UpdateAssumeRolePolicy','iam:DeleteRole'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', region, 'data']); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + listRoles.data.forEach(role => { + if (!role.AssumeRolePolicyDocument || !role.Arn) { + return; + } + + var resource = role.Arn; + + var statements = helpers.normalizePolicyDocument(role.AssumeRolePolicyDocument); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'IAM role does not contain trust relationship statements', + 'global', resource); + return; + } + + var failingArns = []; + var crossAccountRole = false; + + for (var s in statements) { + var statement = statements[s]; + + if (statement.Principal && helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings)) { + crossAccountRole = true; + + if (!((statement.Condition && statement.Condition.Bool && + statement.Condition.Bool['aws:MultiFactorAuthPresent'] && + statement.Condition.Bool['aws:MultiFactorAuthPresent'] === 'true') || + (statement.Condition && statement.Condition.StringEquals && + statement.Condition.StringEquals['sts:ExternalId']))) { + var principals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + if (principals.length) { + principals.forEach(principal => { + if (!failingArns.includes(principal)) failingArns.push(principal); + }); + } + } + } + } + + if (crossAccountRole && failingArns.length) { + helpers.addResult(results, 2, + 'Cross-account role does not require MFA/external ID for these account ARNs: ' + failingArns.join(', '), + 'global', resource); + } else if (crossAccountRole) { + helpers.addResult(results, 0, + 'Cross-account role requires MFA/external ID for all accounts', + 'global', resource); + } else { + helpers.addResult(results, 0, + 'IAM role does not contain cross-account statements', + 'global', resource); + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/crossAccountMfaExtIdAccess.spec.js b/plugins/aws/iam/crossAccountMfaExtIdAccess.spec.js new file mode 100644 index 000000000..621f0e88d --- /dev/null +++ b/plugins/aws/iam/crossAccountMfaExtIdAccess.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +const crossAccountMfaExtIdAccess = require('./crossAccountMfaExtIdAccess'); + +const roles = [ + { + "Path": "/aws-service-role/support.amazonaws.com/", + "RoleName": "AWSServiceRoleForSupport", + "RoleId": "AROAYE32SRU57FHIKLZI5", + "Arn": "arn:aws:iam::123456654321:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport", + "CreateDate": "2020-08-09T16:55:28Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22trustedadvisor.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "Enables resource access for AWS to provide billing, administrative and support services", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-MFA-true", + "RoleId": "AROAYE32SRU5VULODBGFK", + "Arn": "arn:aws:iam::123456654321:role/test-role-MFA-true", + "CreateDate": "2020-08-30T17:48:45Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%111122223333%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%22Bool%22%3A%7B%22aws%3AMultiFactorAuthPresent%22%3A%22true%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-web-identity", + "RoleId": "AROAYE32SRU52CTP2RVNS", + "Arn": "arn:aws:iam::123456654321:role/test-role-web-identity", + "CreateDate": "2020-08-30T17:55:56Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Federated%22%3A%22graph.facebook.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRoleWithWebIdentity%22%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22graph.facebook.com%3Aapp_id%22%3A%22test-id%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-external-id", + "RoleId": "AROAYE32SRU52JAKJR5YY", + "Arn": "arn:aws:iam::123456654321:role/test-role-external-id", + "CreateDate": "2020-08-30T21:18:35Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%3A111222333444%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%22Bool%22%3A%7B%22aws%3AMultiFactorAuthPresent%22%3A%22true%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-MFA-externalid-false", + "RoleId": "AROAYE32SRU5ZCTFXUN6O", + "Arn": "arn:aws:iam::123456654321:role/test-role-MFA-externalid-false", + "CreateDate": "2020-08-30T19:09:22Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%3A111222333444%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-MFA-externalid-false", + "RoleId": "AROAYE32SRU5ZCTFXUN6O", + "Arn": "arn:aws:iam::123456654321:role/test-role-MFA-externalid-false", + "CreateDate": "2020-08-30T19:09:22Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%", + "MaxSessionDuration": 3600 + } +]; + +const createCache = (roles) => { + return { + iam: { + listRoles: { + 'us-east-1': { + data: roles, + }, + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111122223333' + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing IAM roles' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('crossAccountMfaExtIdAccess', function () { + describe('run', function () { + it('should FAIL if cross-account role does not require MFA/external ID for any account', function (done) { + const cache = createCache([roles[4]]); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if cross-account role requires MFA/external ID for all accounts', function (done) { + const cache = createCache([roles[3]]); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no IAM roles found', function (done) { + const cache = createCache([]); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if IAM role does not contain cross-account statements', function (done) { + const cache = createCache([roles[0], roles[2]]); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if IAM role does not contain trust relationship statements', function (done) { + const cache = createCache([roles[5]]); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for IAM roles', function (done) { + const cache = createErrorCache(); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if unable to query for IAM roles', function (done) { + const cache = createNullCache(); + crossAccountMfaExtIdAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/emptyGroups.js b/plugins/aws/iam/emptyGroups.js index 00844bbdf..13b9a38fb 100644 --- a/plugins/aws/iam/emptyGroups.js +++ b/plugins/aws/iam/emptyGroups.js @@ -4,11 +4,26 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Empty Groups', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures all groups have at least one member', more_info: 'While having empty groups does not present a direct security risk, it does broaden the management landscape which could potentially introduce risks in the future.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_WorkingWithGroupsAndUsers.html', recommended_action: 'Remove unused groups without users', apis: ['IAM:listGroups', 'IAM:getGroup'], + asl: { + conditions: [ + { + service: 'iam', + api: 'getGroup', + property: 'Users', + transform: 'COUNT', + op: 'GT', + value: 0 + } + ] + }, + realtime_triggers: ['iam:CreateGroup','iam:AddUserToGroup','iam:RemoveUserFromGroup','iam:DeleteGroup'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/emptyGroups.spec.js b/plugins/aws/iam/emptyGroups.spec.js new file mode 100644 index 000000000..267be4b22 --- /dev/null +++ b/plugins/aws/iam/emptyGroups.spec.js @@ -0,0 +1,144 @@ +const expect = require('chai').expect; +const emptyGroups = require('./emptyGroups'); + +const listGroups = [ + { + "Path": "/", + "GroupName": "test-cp", + "GroupId": "AGPAYE32SRU5ROTM7JUA3", + "Arn": "arn:aws:iam::123456654321:group/test-cp", + "CreateDate": "2020-09-20T22:20:25Z" + }, + { + "Path": "/", + "GroupName": "test-cs-1", + "GroupId": "AGPAYE32SRU5TOSRN5ZC5", + "Arn": "arn:aws:iam::123456654321:group/test-cs-1", + "CreateDate": "2020-09-20T22:24:49Z" + } +]; + +const getGroup = [ + { + "Users": [], + "Group": { + "Path": "/", + "GroupName": "test-cp", + "GroupId": "AGPAYE32SRU5ROTM7JUA3", + "Arn": "arn:aws:iam::123456654321:group/test-cp", + "CreateDate": "2020-09-20T22:20:25Z" + } + }, + { + "Users": [ + { + "Path": "/", + "UserName": "cloudexploit", + "UserId": "AIDAYE32SRU57PAYVNPEI", + "Arn": "arn:aws:iam::123456654321:user/cloudexploit", + "CreateDate": "2020-09-12T16:58:32Z", + "PasswordLastUsed": "2020-09-20T18:58:07Z" + } + ], + "Group": { + "Path": "/", + "GroupName": "test-cs-1", + "GroupId": "AGPAYE32SRU5TOSRN5ZC5", + "Arn": "arn:aws:iam::123456654321:group/test-cs-1", + "CreateDate": "2020-09-20T22:24:49Z" + } + } + +]; + +const createCache = (listGroups, getGroup) => { + var groupName = (listGroups && listGroups.length) ? listGroups[0].GroupName : null; + return { + iam: { + listGroups: { + 'us-east-1': { + data: listGroups, + }, + }, + getGroup: { + 'us-east-1': { + [groupName]: { + data: getGroup + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listGroups: { + 'us-east-1': { + err: { + message: 'error listing IAM groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('emptyGroups', function () { + describe('run', function () { + it('should WARN if IAM group does not contain any users', function (done) { + const cache = createCache([listGroups[0]], getGroup[0]); + emptyGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no IAM groups found', function (done) { + const cache = createCache([]); + emptyGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if IAM group contains user(s)', function (done) { + const cache = createCache([listGroups[1]], getGroup[1]); + emptyGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for IAM groups', function (done) { + const cache = createErrorCache(); + emptyGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for IAM groups', function (done) { + const cache = createNullCache(); + emptyGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/groupInlinePolicies.js b/plugins/aws/iam/groupInlinePolicies.js new file mode 100644 index 000000000..6b29bd1f3 --- /dev/null +++ b/plugins/aws/iam/groupInlinePolicies.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Group Inline Policies', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensures that groups do not have any inline policies', + more_info: 'Managed Policies are recommended over inline policies.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html', + recommended_action: 'Remove inline policies attached to groups', + apis: ['IAM:listGroups', 'IAM:listGroupPolicies'], + realtime_triggers: ['iam:CreateGroup','iam:PutGroupPolicy','iam:DeleteGroupPolicy','iam:DeleteGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listGroups = helpers.addSource(cache, source, + ['iam', 'listGroups', region]); + + if (!listGroups) return callback(null, results, source); + + if (listGroups.err || !listGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for groups: ' + helpers.addError(listGroups)); + return callback(null, results, source); + } + + if (!listGroups.data.length) { + helpers.addResult(results, 0, 'No groups found'); + return callback(null, results, source); + } + + async.each(listGroups.data, function(group, cb){ + if (!group.GroupName) return cb(); + + var listGroupPolicies = helpers.addSource(cache, source, + ['iam', 'listGroupPolicies', region, group.GroupName]); + + if (!listGroupPolicies || listGroupPolicies.err || !listGroupPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query inline policies for group: ' + group.GroupName + ': ' + helpers.addError(listGroupPolicies), + 'global', group.Arn); + return cb(); + } + + if (!listGroupPolicies.data.PolicyNames || !listGroupPolicies.data.PolicyNames.length) { + helpers.addResult(results, 0, + 'Group: ' + group.GroupName + ' does not contain any inline policy', + 'global', group.Arn); + } else { + helpers.addResult(results, 2, + 'Group: ' + group.GroupName + ' contains ' + listGroupPolicies.data.PolicyNames.length + ' inline policy(s)', + 'global', group.Arn); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/groupInlinePolicies.spec.js b/plugins/aws/iam/groupInlinePolicies.spec.js new file mode 100644 index 000000000..5d1aaa38a --- /dev/null +++ b/plugins/aws/iam/groupInlinePolicies.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var groupInlinePolicies = require('./groupInlinePolicies'); + +const groups = [ + { + "Path": "/", + "GroupName": "akhtar-gr-15", + "GroupId": "AGPAYE32SRU5WTVWZJGNX", + "Arn": "arn:aws:iam::123456654321:group/akhtar-gr-15", + "CreateDate": "2020-08-30T14:24:48.000Z" + }, + { + "Path": "/", + "GroupName": "akhtar-gr3-15", + "GroupId": "AGPAYE32SRU56LRFN4U55", + "Arn": "arn:aws:iam::123456654321:group/akhtar-gr3-15", + "CreateDate": "2020-08-30T15:06:01.000Z" + } +]; + +const groupPolicies = [ + { + ResponseMetadata: { RequestId: 'ac47dbfc-6333-4840-8500-2ffb616f03d4' }, + PolicyNames: [], + IsTruncated: false + }, + { + ResponseMetadata: { RequestId: '485a4202-06ef-4e8b-9661-ed4d1dd286d3' }, + PolicyNames: [ + 'policygen-akhtar-gr-15-202008301932', + 'policygen-akhtar-gr-15-202008302019' + ], + IsTruncated: false + } +]; + +const createCache = (groups, groupPolicies) => { + if (groups && groups.length) var groupName = groups[0].GroupName; + return { + iam: { + listGroups: { + 'us-east-1': { + data: groups, + }, + }, + listGroupPolicies: { + 'us-east-1': { + [groupName]: { + data: groupPolicies + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listGroups: { + 'us-east-1': { + err: { + message: 'error listing IAM groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('groupInlinePolicies', function () { + describe('run', function () { + it('should FAIL if IAM group contains inline policies', function (done) { + const cache = createCache([groups[0]], groupPolicies[1]); + groupInlinePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if IAM group does not contain inline policies', function (done) { + const cache = createCache([groups[1]], groupPolicies[0]); + groupInlinePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no IAM groups found', function (done) { + const cache = createCache([]); + groupInlinePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for IAM groups', function (done) { + const cache = createErrorCache(); + groupInlinePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for IAM groups', function (done) { + const cache = createNullCache(); + groupInlinePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/iamMasterManagerRoles.js b/plugins/aws/iam/iamMasterManagerRoles.js new file mode 100644 index 000000000..1bf9f4f53 --- /dev/null +++ b/plugins/aws/iam/iamMasterManagerRoles.js @@ -0,0 +1,295 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +const managerRoleActions = { + allow: [ + 'iam:GetRole', + 'iam:GetUser', + 'iam:GetPolicy', + 'iam:ListRoles', + 'iam:ListUsers', + 'iam:ListGroups', + 'iam:UpdateUser', + 'iam:UpdateGroup', + 'iam:ListPolicies', + 'iam:GetRolePolicy', + 'iam:GetUserPolicy', + 'iam:PutUserPolicy', + 'iam:AddUserToGroup', + 'iam:PutGroupPolicy', + 'iam:DeleteUserPolicy', + 'iam:DetachRolePolicy', + 'iam:DetachUserPolicy', + 'iam:GetPolicyVersion', + 'iam:ListRolePolicies', + 'iam:AttachGroupPolicy', + 'iam:DeleteGroupPolicy', + 'iam:DetachGroupPolicy', + 'iam:ListGroupPolicies', + 'iam:ListGroupsForUser', + 'iam:ListPolicyVersions', + 'iam:RemoveUserFromGroup', + 'iam:ListEntitiesForPolicy', + 'iam:UpdateAssumeRolePolicy', + 'iam:ListAttachedRolePolicies', + 'iam:ListAttachedUserPolicies', + 'iam:ListAttachedGroupPolicies', + 'iam:ListPoliciesGrantingServiceAccess' + ], + deny: [ + 'iam:CreateRole', + 'iam:CreateUser', + 'iam:DeleteRole', + 'iam:DeleteUser', + 'iam:CreateGroup', + 'iam:DeleteGroup', + 'iam:CreatePolicy', + 'iam:DeletePolicy', + 'iam:PutRolePolicy', + 'iam:AddUserToGroup', + 'iam:AttachRolePolicy', + 'iam:DeleteRolePolicy', + 'iam:CreatePolicyVersion', + 'iam:DeletePolicyVersion' + ] +}; + +const masterRoleActions = { + allow: [ + 'iam:GetRole', + 'iam:GetUser', + 'iam:GetPolicy', + 'iam:ListRoles', + 'iam:ListUsers', + 'iam:CreateRole', + 'iam:CreateUser', + 'iam:DeleteRole', + 'iam:DeleteUser', + 'iam:ListGroups', + 'iam:CreateGroup', + 'iam:DeleteGroup', + 'iam:CreatePolicy', + 'iam:DeletePolicy', + 'iam:ListPolicies', + 'iam:GetRolePolicy', + 'iam:PutRolePolicy', + 'iam:GetUserPolicy', + 'iam:AttachRolePolicy', + 'iam:DeleteRolePolicy', + 'iam:GetPolicyVersion', + 'iam:ListRolePolicies', + 'iam:ListGroupsForUser', + 'iam:ListGroupPolicies', + 'iam:ListPolicyVersions', + 'iam:CreatePolicyVersion', + 'iam:DeletePolicyVersion', + 'iam:ListEntitiesForPolicy', + 'iam:ListAttachedRolePolicies', + 'iam:ListAttachedUserPolicies', + 'iam:ListAttachedGroupPolicies', + 'iam:ListPoliciesGrantingServiceAccess' + ], + deny: [ + 'iam:UpdateUser', + 'iam:UpdateGroup', + 'iam:PutUserPolicy', + 'iam:AddUserToGroup', + 'iam:PutGroupPolicy', + 'iam:DeleteUserPolicy', + 'iam:DetachRolePolicy', + 'iam:DetachUserPolicy', + 'iam:AttachGroupPolicy', + 'iam:DeleteGroupPolicy', + 'iam:DetachGroupPolicy', + 'iam:RemoveUserFromGroup', + 'iam:UpdateAssumeRolePolicy' + ] +}; + +module.exports = { + title: 'IAM Master and IAM Manager Roles', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure IAM Master and IAM Manager roles are active within your AWS account.', + more_info: 'IAM roles should be split into IAM Master and IAM Manager roles to work in two-person rule manner for best prectices.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html', + recommended_action: 'Create the IAM Master and IAM Manager roles for an efficient IAM administration and permission management within your AWS account', + apis: ['IAM:listRoles', 'IAM:listRolePolicies', 'IAM:getRolePolicy', 'IAM:getRole'], + settings: { + iam_role_policies_ignore_path: { + name: 'IAM Role Policies Ignore Path', + description: 'Ignores roles that contain the provided exact-match path', + regex: '^[0-9A-Za-z/._-]{3,512}$', + default: false + }, + iam_role_policies_ignore_tag: { + name: 'IAM Role Policies Ignore Tag', + description: 'Ignores roles that contain the provided tag. Give key-value pair i.e. env:Finance', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole'], + + run: function(cache, settings, callback) { + var config = { + iam_role_policies_ignore_path: settings.iam_role_policies_ignore_path || this.settings.iam_role_policies_ignore_path.default, + iam_role_policies_ignore_tag: settings.iam_role_policies_ignore_tag || this.settings.iam_role_policies_ignore_tag.default + }; + + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + var masterRoleFound = false; + var managerRoleFound = false; + + async.each(listRoles.data, function(role, cb){ + if (!role.RoleName || !role.AssumeRolePolicyDocument) return cb(); + + // Skip roles with user-defined paths + if (config.iam_role_policies_ignore_path && + config.iam_role_policies_ignore_path.length && + role.Path && + role.Path.indexOf(config.iam_role_policies_ignore_path) > -1) { + return cb(); + } + + // Get role details + var getRole = helpers.addSource(cache, source, + ['iam', 'getRole', region, role.RoleName]); + + if (!getRole || getRole.err || !getRole.data || !getRole.data.Role) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles details: ' + role.RoleName + ': ' + helpers.addError(getRole), 'global', role.Arn); + return cb(); + } + + //Skip roles with user defined tags + if (config.iam_role_policies_ignore_tag && config.iam_role_policies_ignore_tag.length) { + if (config.iam_role_policies_ignore_tag.split(':').length == 2){ + var key = config.iam_role_policies_ignore_tag.split(':')[0].trim(); + var value= new RegExp(config.iam_role_policies_ignore_tag.split(':')[1].trim()); + if (getRole.data.Role.Tags && getRole.data.Role.Tags.length){ + if (getRole.data.Role.Tags.find(tag => + tag.Key == key && value.test(tag.Value))) return cb(); + } + } + } + + // Get inline policies attached to role + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', region, role.RoleName]); + + var getRolePolicy = helpers.addSource(cache, source, + ['iam', 'getRolePolicy', region, role.RoleName]); + + if (!listRolePolicies || listRolePolicies.err || !listRolePolicies.data || !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + 'Unable to query for IAM role policy for role: ' + role.RoleName + ': ' + helpers.addError(listRolePolicies), 'global', role.Arn); + return cb(); + } + + var assumeRolePolicy = helpers.normalizePolicyDocument(role.AssumeRolePolicyDocument); + + if (!assumeRolePolicy || !assumeRolePolicy.length) return cb(); + + var roleAssumable = false; + for (var a in assumeRolePolicy) { + var policyStatement = assumeRolePolicy[a]; + + if (policyStatement.Effect && + policyStatement.Effect.toUpperCase() === 'ALLOW' && + policyStatement.Action && + policyStatement.Action.indexOf('sts:AssumeRole') > -1 && + policyStatement.Principal && + policyStatement.Principal.Service && + policyStatement.Principal.Service.indexOf('iam.amazonaws.com') > -1) { + roleAssumable = true; + break; + } + } + + if (!roleAssumable) return cb(); + + var rolePermissions = { allow: [], deny: [] }; + + for (var p in listRolePolicies.data.PolicyNames) { + var policyName = listRolePolicies.data.PolicyNames[p]; + + if (getRolePolicy && + getRolePolicy[policyName] && + getRolePolicy[policyName].data && + getRolePolicy[policyName].data.PolicyDocument) { + + var statements = getRolePolicy[policyName].data.PolicyDocument; + if (!statements) break; + + for (var s in statements) { + var statement = statements[s]; + if (statement.Action && statement.Action.length && !statement.Condition) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW') { + statement.Action.forEach(perm => { + if (!rolePermissions.allow.includes(perm)) rolePermissions.allow.push(perm); + }); + continue; + } + + if (statement.Effect && statement.Effect.toUpperCase() === 'DENY') { + statement.Action.forEach(perm => { + if (!rolePermissions.deny.includes(perm)) rolePermissions.deny.push(perm); + }); + } + } + } + } + } + + if (masterRoleActions.allow.every(permission => rolePermissions.allow.includes(permission)) && + masterRoleActions.deny.every(permission => rolePermissions.deny.includes(permission))) { + masterRoleFound = true; + } + + if (managerRoleActions.allow.every(permission => rolePermissions.allow.includes(permission)) && + managerRoleActions.deny.every(permission => rolePermissions.deny.includes(permission))) { + managerRoleFound = true; + } + + cb(); + }, function(){ + if (managerRoleFound && masterRoleFound) { + helpers.addResult(results, 0, + 'IAM Master and Manager Roles found', 'global'); + } else if (!managerRoleFound && !masterRoleFound) { + helpers.addResult(results, 2, + 'IAM Master and Manager Roles not found', 'global'); + } else if (!managerRoleFound) { + helpers.addResult(results, 2, + 'IAM Manager Role not found', 'global'); + } else { + helpers.addResult(results, 2, + 'IAM Master Role not found', 'global'); + } + + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/iamMasterManagerRoles.spec.js b/plugins/aws/iam/iamMasterManagerRoles.spec.js new file mode 100644 index 000000000..3a7546e8a --- /dev/null +++ b/plugins/aws/iam/iamMasterManagerRoles.spec.js @@ -0,0 +1,411 @@ +var expect = require('chai').expect; +const iamMasterManagerRoles = require('./iamMasterManagerRoles'); + +const roles = [ + { + "Path": "/", + "RoleName": "IAM-Manager-Role", + "RoleId": "AROAYE32SRU5TQY4O5JBW", + "Arn": "arn:aws:iam::111122223333:role/IAM-Manager-Role", + "CreateDate": "2020-12-02T06:34:08.000Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22iam.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "Iam manager role.", + "MaxSessionDuration": 3600, + "Tags": [] + }, + { + "Path": "/", + "RoleName": "IAM-Master-Role", + "RoleId": "AROAYE32SRU5R232MB5LZ", + "Arn": "arn:aws:iam::111122223333:role/IAM-Master-Role", + "CreateDate": "2020-11-30T07:58:42.000Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22%22%2C%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22iam.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "Iam Master role", + "MaxSessionDuration": 3600, + "Tags": [] + }, + { + "Path": "/", + "RoleName": "khulnasoft-cspm-security-remediator-rotator", + "RoleId": "AROARPGOCGXSYQSXS37BT", + "Arn": "arn:aws:iam::000000001111111:role/khulnasoft-cspm-security-remediator-rotator", + "CreateDate": "2022-09-21T09:56:11+00:00", + } +]; + +const listRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": '32c83dc4-bbdb-4b20-b9b7-b461a6942e04' + }, + "PolicyNames": [ 'manager-role-policy' ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'ecfb7061-b67c-47de-afe4-e5505bb17a97' + }, + "PolicyNames": [ 'master-role-policy' ], + "IsTruncated": false + }, + { + "PolicyNames": [ "khulnasoft-cspm-iam-remediator-access" ] + } +]; + +const getRolePolicy = [ + { + "manager-role-policy": { + "data": { + "ResponseMetadata": [{}], + "RoleName": 'IAM-Manager-Role', + "PolicyName": 'manager-role-policy', + "PolicyDocument": [ { Sid: 'VisualEditor0', + Effect: 'Allow', + Action: + [ 'iam:GetRole', + 'iam:GetUser', + 'iam:GetPolicy', + 'iam:ListRoles', + 'iam:ListUsers', + 'iam:ListGroups', + 'iam:UpdateUser', + 'iam:UpdateGroup', + 'iam:ListPolicies', + 'iam:GetRolePolicy', + 'iam:GetUserPolicy', + 'iam:PutUserPolicy', + 'iam:AddUserToGroup', + 'iam:PutGroupPolicy', + 'iam:DeleteUserPolicy', + 'iam:DetachRolePolicy', + 'iam:DetachUserPolicy', + 'iam:GetPolicyVersion', + 'iam:ListRolePolicies', + 'iam:AttachGroupPolicy', + 'iam:DeleteGroupPolicy', + 'iam:DetachGroupPolicy', + 'iam:ListGroupPolicies', + 'iam:ListGroupsForUser', + 'iam:ListPolicyVersions', + 'iam:RemoveUserFromGroup', + 'iam:ListEntitiesForPolicy', + 'iam:UpdateAssumeRolePolicy', + 'iam:ListAttachedRolePolicies', + 'iam:ListAttachedUserPolicies', + 'iam:ListAttachedGroupPolicies', + 'iam:ListPoliciesGrantingServiceAccess' ], + Resource: [ '*' ] }, + { Sid: 'VisualEditor1', + Effect: 'Deny', + Action: + [ 'iam:CreateRole', + 'iam:CreateUser', + 'iam:DeleteRole', + 'iam:DeleteUser', + 'iam:CreateGroup', + 'iam:DeleteGroup', + 'iam:CreatePolicy', + 'iam:DeletePolicy', + 'iam:PutRolePolicy', + 'iam:AddUserToGroup', + 'iam:AttachRolePolicy', + 'iam:DeleteRolePolicy', + 'iam:CreatePolicyVersion', + 'iam:DeletePolicyVersion' ], + Resource: [ '*' ] } ] + } + } + }, + { + "master-role-policy": { + "data": { + "ResponseMetadata": [{}], + "RoleName": 'IAM-Master-Role', + "PolicyName": 'master-role-policy', + "PolicyDocument": [ { Sid: 'VisualEditor0', + Effect: 'Allow', + Action: + [ 'iam:GetRole', + 'iam:GetUser', + 'iam:GetPolicy', + 'iam:ListRoles', + 'iam:ListUsers', + 'iam:CreateRole', + 'iam:CreateUser', + 'iam:DeleteRole', + 'iam:DeleteUser', + 'iam:ListGroups', + 'iam:CreateGroup', + 'iam:DeleteGroup', + 'iam:CreatePolicy', + 'iam:DeletePolicy', + 'iam:ListPolicies', + 'iam:GetRolePolicy', + 'iam:PutRolePolicy', + 'iam:GetUserPolicy', + 'iam:AttachRolePolicy', + 'iam:DeleteRolePolicy', + 'iam:GetPolicyVersion', + 'iam:ListRolePolicies', + 'iam:ListGroupsForUser', + 'iam:ListGroupPolicies', + 'iam:ListPolicyVersions', + 'iam:CreatePolicyVersion', + 'iam:DeletePolicyVersion', + 'iam:ListEntitiesForPolicy', + 'iam:ListAttachedRolePolicies', + 'iam:ListAttachedUserPolicies', + 'iam:ListAttachedGroupPolicies', + 'iam:ListPoliciesGrantingServiceAccess' ], + Resource: [ '*' ] }, + { Sid: 'VisualEditor1', + Effect: 'Deny', + Action: + [ 'iam:UpdateUser', + 'iam:UpdateGroup', + 'iam:PutUserPolicy', + 'iam:AddUserToGroup', + 'iam:PutGroupPolicy', + 'iam:DeleteUserPolicy', + 'iam:DetachRolePolicy', + 'iam:DetachUserPolicy', + 'iam:AttachGroupPolicy', + 'iam:DeleteGroupPolicy', + 'iam:DetachGroupPolicy', + 'iam:RemoveUserFromGroup', + 'iam:UpdateAssumeRolePolicy' ], + Resource: [ '*' ] } ] + } + } + }, + { + "khulnasoft-cspm-iam-remediator-access": { + "data": { + "ResponseMetadata": [{}], + "RoleName": 'khulnasoft-cspm-security-remediator', + "PolicyName": 'khulnasoft-cspm-iam-remediator-access', + } + } + } +]; + +const getRole = [ + { + 'Role':{ + "Path": "/", + "RoleName": "IAM-Manager-Role", + "RoleId": "AROAYE32SRU5R232MB5LZ", + "Arn": "arn:aws:iam::111122223333:role/IAM-Manager-Role", + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ] + } + }, + { + 'Role':{ + "Path": "/", + "RoleName": "IAM-Master-Role", + "RoleId": "AROAYE32SRU5R232MB5LZ", + "Arn": "arn:aws:iam::111122223333:role/IAM-Master-Role", + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ] + } + }, + { + 'Role':{ + "Path": "/", + "RoleName": "khulnasoft-cspm-security-remediator", + "RoleId": "AROAYE32SRU5R232MB5LZ", + "Arn": "arn:aws:iam::111122223333:role/khulnasoft-cspm-security-remediator", + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ] + } + } +] + +const createPassCache = (roles, listRolePolicies, getRolePolicy, getRole) => { + return { + iam: { + listRoles: { + 'us-east-1': { + data: roles, + }, + }, + listRolePolicies: { + 'us-east-1': { + [roles[0].RoleName]: { + data: listRolePolicies[0] + }, + [roles[1].RoleName]: { + data: listRolePolicies[1] + } + } + }, + getRolePolicy: { + 'us-east-1': { + [roles[0].RoleName]: getRolePolicy[0], + [roles[1].RoleName]: getRolePolicy[1] + } + }, + getRole: { + 'us-east-1': { + [roles[0].RoleName]: { + data: getRole[0] + }, + [roles[1].RoleName]: { + data: getRole[1] + } + } + } + } + }; +}; + +const createCache = (roles, listRolePolicies, getRolePolicy, getRole) => { + var roleName = (roles && roles.length) ? roles[0].RoleName : null; + return { + iam: { + listRoles: { + 'us-east-1': { + data: roles, + }, + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + }, + getRolePolicy: { + 'us-east-1': { + [roleName]: getRolePolicy + } + }, + getRole: { + 'us-east-1': { + [roleName]:{ + data: getRole, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing IAM roles' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('iamMasterManagerRoles', function () { + describe('run', function () { + it('should PASS if IAM Master and Manager Roles found', function (done) { + const cache = createPassCache(roles, listRolePolicies, getRolePolicy, getRole); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('IAM Master and Manager Roles found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if IAM Master Role not found', function (done) { + const cache = createCache([roles[0]], listRolePolicies[0], getRolePolicy[0], getRole[0]); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('IAM Master Role not found'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if IAM Manager Role not found', function (done) { + const cache = createCache([roles[1]], listRolePolicies[1], getRolePolicy[1], getRole[1]); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('IAM Manager Role not found'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if IAM Master and Manager Roles not found', function (done) { + const cache = createCache([roles[2]], listRolePolicies[2], getRolePolicy[2], getRole[2]); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('IAM Master and Manager Roles not found'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no IAM roles found', function (done) { + const cache = createCache([]); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results[0].message).to.include('No IAM roles found'); + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for IAM roles', function (done) { + const cache = createErrorCache(); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results[0].message).to.include('Unable to query for IAM roles'); + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list IAM roles response not found', function (done) { + const cache = createNullCache(); + iamMasterManagerRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if role with specific regex is ignored', function (done) { + const cache = createPassCache(roles, listRolePolicies, getRolePolicy, getRole) + iamMasterManagerRoles.run(cache, {iam_role_policies_ignore_tag:'app_name:Khulnasoft CSPM'}, (err, results) => { + expect(results.length).to.equal(1); + done(); + }); + }) + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamPoliciesPresent.js b/plugins/aws/iam/iamPoliciesPresent.js new file mode 100644 index 000000000..5d35354e3 --- /dev/null +++ b/plugins/aws/iam/iamPoliciesPresent.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM Policies Present', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that required policies are present in all IAM roles.', + more_info: 'Validate the presence of required policies in IAM roles in order to follow your organizations\'s security and compliance requirements.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html', + recommended_action: 'Modify IAM roles to attach required policies', + apis: ['IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies'], + settings: { + iam_required_policy_names: { + name: 'IAM Required Policy Names', + description: 'A comma separated list of IAM policy names that all IAM roles should have', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole','iam:AttachRolePolicy','iam:DetachRolePolicy','iam:PutRolePolicy','iam:DeleteRolePolicy'], + + run: function(cache, settings, callback) { + var config = { + iam_required_policy_names: settings.iam_required_policy_names || this.settings.iam_required_policy_names.default + }; + + if (!config.iam_required_policy_names.length) return callback(null, results, source); + + config.iam_required_policy_names = config.iam_required_policy_names.split(','); + + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + async.each(listRoles.data, function(role, cb){ + if (!role.RoleName) return cb(); + + // Get managed policies attached to role + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', region, role.RoleName]); + + // Get inline policies attached to role + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', region, role.RoleName]); + + if (!listAttachedRolePolicies || listAttachedRolePolicies.err) { + helpers.addResult(results, 3, + 'Unable to query for IAM attached policy for role: ' + role.RoleName + ': ' + helpers.addError(listAttachedRolePolicies), region, role.Arn); + return cb(); + } + + if (!listRolePolicies || listRolePolicies.err) { + helpers.addResult(results, 3, + 'Unable to query for IAM role policy for role: ' + role.RoleName + ': ' + helpers.addError(listRolePolicies), region, role.Arn); + return cb(); + } + + var attachedPolicies = []; + var difference = []; + + // See if role has admin managed policy + if (listAttachedRolePolicies.data && + listAttachedRolePolicies.data.AttachedPolicies) { + + for (let policy of listAttachedRolePolicies.data.AttachedPolicies) { + attachedPolicies.push(policy.PolicyName); + } + } + + if (listRolePolicies.data && listRolePolicies.data.PolicyNames) attachedPolicies = attachedPolicies.concat(listRolePolicies.data.PolicyNames); + + for (let policy of config.iam_required_policy_names) { + if (!attachedPolicies.includes(policy)) difference.push(policy); + } + + if (difference.length) { + helpers.addResult(results, 2, + `IAM role does not have these required policies attached: ${difference.join(', ')}`, region, role.Arn); + } else { + helpers.addResult(results, 0, + 'IAM role has all required policies attached', region, role.Arn); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/iam/iamPoliciesPresent.spec.js b/plugins/aws/iam/iamPoliciesPresent.spec.js new file mode 100644 index 000000000..afbe9b462 --- /dev/null +++ b/plugins/aws/iam/iamPoliciesPresent.spec.js @@ -0,0 +1,159 @@ +const expect = require('chai').expect; +var iamPoliciesPresent = require('./iamPoliciesPresent'); + + +const listRoles = [ + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "sts:AssumeRoleWithSAML", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "S3-Full" + ] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f7d427cc-970b-47af-9b7d-3e06121f83da' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + } +]; + +const createCache = (listRoles, listAttachedRolePolicies, listRolePolicies, listRolesErr, listAttachedRolePoliciesErr, listRolePoliciesErr) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + return { + iam: { + listRoles: { + 'us-east-1': { + err: listRolesErr, + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listAttachedRolePoliciesErr, + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listRolePoliciesErr, + data: listRolePolicies + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listRoles: { + 'us-east-1': null + } + } + }; +}; + +describe('iamPoliciesPresent', function () { + describe('run', function () { + + it('should PASS if IAM role has all required policies attached', function (done) { + const cache = createCache([listRoles[0]], listAttachedRolePolicies[0], listRolePolicies[0]); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full,AdministratorAccess' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if IAM role does not have required policies attached', function (done) { + const cache = createCache([listRoles[0]], {}, listRolePolicies[0]); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'AdministratorAccess' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if on IAM roles found', function (done) { + const cache = createCache([]); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list IAM roles', function (done) { + const cache = createCache(null, null, null, { message: 'Unable to list IAM roles'}); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache([listRoles[0]], {}, null, { message: 'Unable to list attached role policies'}); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache([listRoles[0]], listAttachedRolePolicies[0], {}, null, null, { message: 'Unable to query role policies'}); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list roles response not found', function (done) { + const cache = createNullCache(); + iamPoliciesPresent.run(cache, { iam_required_policy_names: 'S3-Full' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamRoleHasTags.js b/plugins/aws/iam/iamRoleHasTags.js new file mode 100644 index 000000000..f93fa3e51 --- /dev/null +++ b/plugins/aws/iam/iamRoleHasTags.js @@ -0,0 +1,58 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM Role Has Tags', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that AWS IAM Roles have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_tags.html', + recommended_action: 'Modify Roles to add tags.', + apis: ['IAM:listRoles', 'IAM:getRole'], + realtime_triggers: ['iam:CreateRole','iam:TagRole','iam:UntagRole','iam:DeleteRole'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + for (var role of listRoles.data) { + if (!role.RoleName) continue; + + var getRole = helpers.addSource(cache, source, + ['iam', 'getRole', region, role.RoleName]); + + if (!getRole || getRole.err || !getRole.data || !getRole.data.Role) { + helpers.addResult(results, 3, + 'Unable to query for IAM role details: ' + role.RoleName + ': ' + helpers.addError(getRole), 'global', role.Arn); + continue; + } + + if (!getRole.data.Role.Tags || !getRole.data.Role.Tags.length) { + helpers.addResult(results, 2, 'IAM Role does not have tags', 'global', role.Arn); + } else { + helpers.addResult(results, 0, 'IAM Role has tags', 'global', role.Arn); + } + + } + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/iamRoleHasTags.spec.js b/plugins/aws/iam/iamRoleHasTags.spec.js new file mode 100644 index 000000000..34f8dba4c --- /dev/null +++ b/plugins/aws/iam/iamRoleHasTags.spec.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +const iamRolesHasTags = require('./iamRoleHasTags.js'); + +const listRoles = [ + { + "Path": "/", + "RoleName": "khulnasoft-cspm-security-remediator-rotator", + "RoleId": "AROARPGOCGXSYQSXS37BT", + "Arn": "arn:aws:iam::000000001111111:role/khulnasoft-cspm-security-remediator-rotator", + "CreateDate": "2022-09-21T09:56:11+00:00", + }, + { + "Path": "/", + "RoleName": "s3AdminAccess", + "RoleId": "AROARPGOCGXST7CLXIBTZ", + "Arn": "arn:aws:iam::0000000001111111:role/s3AdminAccess", + "CreateDate": "2022-09-07T13:41:36+00:00", + } +]; + +const getRole= [ + { + 'Role':{ + "Path": "/", + "RoleName": "khulnasoft-cspm-security-remediator-rotator", + "RoleId": "AROARPGOCGXSYQSXS37BT", + "Arn": "arn:aws:iam::000000001111111:role/khulnasoft-cspm-security-remediator-rotator", + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ], + } + }, + { + "Role": { + "Path": "/", + "RoleName": "s3AdminAccess", + "RoleId": "AROARPGOCGXST7CLXIBTZ", + "Arn": "arn:aws:iam::0000000001111111:role/s3AdminAccess", + + } + } + +] +const createCache = (listRoles,getRole) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + return { + iam: { + listRoles: { + 'us-east-1': { + data: listRoles, + err: null + }, + }, + getRole: { + 'us-east-1': { + [roleName]:{ + data: getRole, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing IAM Roles' + }, + }, + }, + }, + }; +}; + +describe('iamRolesHasTags', function () { + describe('run', function () { + it('Should PASS if IAM role has tags', function (done) { + const cache = createCache([listRoles[0]],getRole[0]); + iamRolesHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('IAM Role has tags'); + done(); + }); + }); + + it('Should FAIL if IAM role does not have tags', function (done) { + const cache = createCache([listRoles[1]],getRole[1]); + iamRolesHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('IAM Role does not have tags'); + done(); + }); + }); + + it('Should UNKNOWN if error in listing IAM user', function (done) { + const cache = createErrorCache(); + iamRolesHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('Should PASS if no IAM role found', function (done) { + const cache = createCache([]); + iamRolesHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamRoleLastUsed.js b/plugins/aws/iam/iamRoleLastUsed.js new file mode 100644 index 000000000..587ed32ae --- /dev/null +++ b/plugins/aws/iam/iamRoleLastUsed.js @@ -0,0 +1,150 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM Role Last Used', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensures IAM roles that have not been used within the given time frame are deleted.', + more_info: 'IAM roles that have not been used for a long period may contain old access policies that could allow unintended access to resources if accidentally attached to new services. These roles should be deleted.', + link: 'https://aws.amazon.com/about-aws/whats-new/2019/11/identify-unused-iam-roles-easily-and-remove-them-confidently-by-using-the-last-used-timestamp/', + recommended_action: 'Delete IAM roles that have not been used within the expected time frame.', + apis: ['IAM:listRoles', 'IAM:getRole'], + settings: { + iam_role_last_used_fail: { + name: 'IAM Role Last Used Fail', + description: 'Return a failing result when IAM roles exceed this number of days without being used', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 180 + }, + iam_role_last_used_warn: { + name: 'IAM Role Last Used Warn', + description: 'Return a warning result when IAM roles exceed this number of days without being used', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 90 + }, + iam_role_ignore_path: { + name: 'IAM Role Policies Ignore Path', + description: 'Ignores roles that contain the provided exact-match path', + regex: '^[0-9A-Za-z/._-]{3,512}$', + default: false + }, + skip_aws_service_roles: { + name: 'Skip AWS Service Roles', + description: 'If set to true, plugin will Ignore AWS service roles', + regex: '^(true|false)$', + default: 'true' + }, + iam_role_policies_ignore_tag: { + name: 'IAM Role Policies Ignore Tag', + description: 'Ignores roles that contain the provided tag. Give key-value pair i.e. env:Finance ', + regex: '^.*$', + default: '' + } + }, + asl: { + conditions: [ + { + service: 'iam', + api: 'getRole', + property: 'Role.RoleLastUsed.LastUsedDate', + transform: 'DAYSFROM', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole'], + + run: function(cache, settings, callback) { + var config = { + iam_role_last_used_fail: settings.iam_role_last_used_fail || this.settings.iam_role_last_used_fail.default, + iam_role_last_used_warn: settings.iam_role_last_used_warn || this.settings.iam_role_last_used_warn.default, + iam_role_ignore_path: settings.iam_role_ignore_path || this.settings.iam_role_ignore_path.default, + skip_aws_service_roles: settings.skip_aws_service_roles || this.settings.skip_aws_service_roles.default, + iam_role_policies_ignore_tag: settings.iam_role_policies_ignore_tag || this.settings.iam_role_policies_ignore_tag.default + }; + + config.skip_aws_service_roles = (config.skip_aws_service_roles == 'true'); + var custom = helpers.isCustom(settings, this.settings); + + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + async.each(listRoles.data, function(role, cb){ + if (!role.RoleName || (config.skip_aws_service_roles && role.Path && role.Path.startsWith('/aws-service-role/'))) return cb(); + + // Skip roles with user-defined paths + if (config.iam_role_ignore_path && + config.iam_role_ignore_path.length && + role.Path && + role.Path.indexOf(config.iam_role_ignore_path) > -1) { + return cb(); + } + + // Get role details + var getRole = helpers.addSource(cache, source, + ['iam', 'getRole', region, role.RoleName]); + + if (!getRole || getRole.err || !getRole.data || !getRole.data.Role) { + helpers.addResult(results, 3, + 'Unable to query for IAM role details: ' + role.RoleName + ': ' + helpers.addError(getRole), 'global', role.Arn); + return cb(); + } + + //Skip roles with user defined tags + if (config.iam_role_policies_ignore_tag && config.iam_role_policies_ignore_tag.length) { + if (config.iam_role_policies_ignore_tag.split(':').length == 2){ + var key = config.iam_role_policies_ignore_tag.split(':')[0].trim(); + var value= new RegExp(config.iam_role_policies_ignore_tag.split(':')[1].trim()); + if (getRole.data.Role.Tags && getRole.data.Role.Tags.length){ + if (getRole.data.Role.Tags.find(tag => + tag.Key == key && value.test(tag.Value))) return cb(); + } + } + } + + if (!getRole.data.Role.RoleLastUsed || + !getRole.data.Role.RoleLastUsed.LastUsedDate) { + helpers.addResult(results, 2, + 'IAM role: ' + role.RoleName + ' has not been used', 'global', role.Arn); + return cb(); + } + + var daysAgo = helpers.daysAgo(getRole.data.Role.RoleLastUsed.LastUsedDate); + + var returnCode = 0; + var returnMsg = `IAM role was last used ${daysAgo} days ago in the ${getRole.data.Role.RoleLastUsed.Region || 'unknown'} region`; + if (daysAgo > config.iam_role_last_used_fail) { + returnCode = 2; + } else if (daysAgo > config.iam_role_last_used_warn) { + returnCode = 1; + } + + helpers.addResult(results, returnCode, returnMsg, 'global', role.Arn, custom); + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/iamRoleLastUsed.spec.js b/plugins/aws/iam/iamRoleLastUsed.spec.js new file mode 100644 index 000000000..d84b71a30 --- /dev/null +++ b/plugins/aws/iam/iamRoleLastUsed.spec.js @@ -0,0 +1,183 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var iamRoleLastUsed = require('./iamRoleLastUsed'); + +const listRoles = [ + { + "Path": "/", + "RoleName": "SampleRole1", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole1", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "" + }, + { + "Path": "/", + "RoleName": "SampleRole2", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole2", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "" + }, + { + "Path": "/", + "RoleName": "SampleRole3", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole3", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "" + }, + { + "Path": "/", + "RoleName": "SampleRole4", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole4", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "" + } +]; + +const getRole= [ + { + "Role": { + "Path": "/", + "RoleName": "SampleRole1", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole1", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "", + "RoleLastUsed": {}, + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ] + } + }, + { + "Role": { + "Path": "/", + "RoleName": "SampleRole2", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole2", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "", + "RoleLastUsed": { + "LastUsedDate": new Date(), + "Region": "us-east-1" + } + } + }, + { + "Role": { + "Path": "/", + "RoleName": "SampleRole3", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole3", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "", + "RoleLastUsed": { + "LastUsedDate": "2019-05-18T14:42:29.000Z", + "Region": "us-east-1" + } + } + }, + { + "Role": { + "Path": "/", + "RoleName": "SampleRole4", + "RoleId": "ABCDEFG", + "Arn": "arn:aws:iam::01234567819101:role/SampleRole3", + "CreateDate": "2019-11-19T14:52:01.000Z", + "AssumeRolePolicyDocument": "", + "RoleLastUsed": { + "LastUsedDate": "2019-05-18T14:42:29.000Z", + "Region": "us-east-1" + }, + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ] + } + } +]; + +const createCache = (listRoles,getRole) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + return { + iam: { + listRoles: { + 'us-east-1': { + data: listRoles, + err: null + }, + }, + getRole: { + 'us-east-1': { + [roleName]:{ + data: getRole, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing IAM Roles' + }, + }, + }, + }, + }; +}; + +describe('iamRoleLastUsed', function() { + describe('run', function() { + it('should FAIL when no last used date present', function(done) { + const cache = createCache([listRoles[0]],getRole[0]); + iamRoleLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has not been used'); + done(); + }); + }); + + it('should PASS when last used date is recent', function(done) { + const cache = createCache([listRoles[1]],getRole[1]); + iamRoleLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('IAM role was last used'); + done(); + }); + }); + + it('should FAIL when last used date is old', function(done) { + const cache = createCache([listRoles[2]],getRole[2]); + iamRoleLastUsed.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role with specific regex is ignored',function(done) { + const cache = createCache([listRoles[3]],getRole[3]); + iamRoleLastUsed.run(cache, {iam_role_policies_ignore_tag:'app_name:Khulnasoft CSPM'}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/iamRolePolicies.js b/plugins/aws/iam/iamRolePolicies.js index 78ba4d659..c249bba63 100644 --- a/plugins/aws/iam/iamRolePolicies.js +++ b/plugins/aws/iam/iamRolePolicies.js @@ -1,37 +1,126 @@ var async = require('async'); var helpers = require('../../../helpers/aws'); -var managedAdminPolicy = 'arn:aws:iam::aws:policy/AdministratorAccess'; module.exports = { title: 'IAM Role Policies', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures IAM role policies are properly scoped with specific permissions', more_info: 'Policies attached to IAM roles should be scoped to least-privileged access and avoid the use of wildcards.', link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html', recommended_action: 'Ensure that all IAM roles are scoped to specific services and API calls.', - apis: ['IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies', - 'IAM:getPolicy', 'IAM:getRolePolicy'], + apis: ['IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies', 'IAM:listPolicies', + 'IAM:getPolicy', 'IAM:getPolicyVersion', 'IAM:getRolePolicy', 'IAM:getRole'], settings: { iam_role_policies_ignore_path: { name: 'IAM Role Policies Ignore Path', - description: 'Ignores roles that contain the provided exact-match path', + description: 'A comma-separated list indicating role paths which should PASS without checking', regex: '^[0-9A-Za-z/._-]{3,512}$', - default: false + default: '' + }, + ignore_service_specific_wildcards: { + name: 'Ignore Service Specific Wildcards', + description: 'This allows enables you to allow attached policies (inline and managed) to use service specific wildcards in Action. ' + + 'Example: Consider a role has following inline policy' + + `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cognito-sync:*", + "cognito-identity:*" + ], + "Resource": [ + "*" + ] + } + ] + }` + + 'If ignore_service_specific_wildcards is true, a PASS result will be generated. ' + + 'If ignore_service_specific_wildcards is false, a FAIL result will be generated.', + regex: '^(true|false)$', + default: 'false' + }, + ignore_identity_federation_roles: { + name: 'Ignore Identity Federation Roles', + description: 'This setting allows you to skip IdP/SAML based roles ' + + 'i.e. if for a role, all trust relationship statements have "Action" either "sts:AssumeRoleWithWebIdentity" or "sts:AssumeRoleWithSAML" '+ + 'and value for this setting is set to true, a PASS results will be generated.', + regex: '^(true|false)$', + default: 'false' + }, + ignore_aws_managed_iam_policies: { + name: 'Ignore AWS-Managed IAM Policies', + description: 'If set to true, skip AWS-managed policies attached to the role with the exception of AWS-managed AdministratorAccess policy', + regex: '^(true|false)$', + default: 'false' + }, + ignore_customer_managed_iam_policies: { + name: 'Ignore Customer-Managed IAM Policies', + description: 'If set to true, skip customer-managed policies attached to the role.', + regex: '^(true|false)$', + default: 'false' + }, + iam_role_policies_ignore_tag: { + name: 'IAM Role Policies Ignore Tag', + description: 'A comma-separated list of tags to ignore roles that contain the provided tag. Give key-value pair i.e. env:Finance, env:Accounts ', + regex: '^.*$', + default: '' + }, + iam_policy_resource_specific_wildcards: { + name: 'IAM Policy Resource Specific Wildcards', + description: 'Allows policy resources to flag based on regular expression. All the resources in IAM policy, inline or managed, will be tested against this regex and if they don\'t pass the regex, they will be flagged by the plugin.', + regex: '^.*$', + default: '^.*$', + }, + ignore_iam_policy_resource_wildcards: { + name: 'IAM Role Policies Ignore Resource Specific Wildcards', + description: 'Enable this setting to ignore resource wildcards i.e. \'"Resource": "*"\' in the IAM policy, which by default, are being flagged.', + regex: '^(true|false)$', + default: 'false' + }, + iam_policy_message_format: { + name: 'IAM Policy Message Format', + description: 'Enable this setting to include policy names in the failure messages', + regex: '^(true|false)$', + default: 'false' } }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole','iam:AttachRolePolicy','iam:DetachRolePolicy','iam:PutRolePolicy','iam:DeleteRolePolicy'], run: function(cache, settings, callback) { var config = { - iam_role_policies_ignore_path: settings.iam_role_policies_ignore_path || this.settings.iam_role_policies_ignore_path.default + iam_role_policies_ignore_path: settings.iam_role_policies_ignore_path || this.settings.iam_role_policies_ignore_path.default, + ignore_service_specific_wildcards: settings.ignore_service_specific_wildcards || this.settings.ignore_service_specific_wildcards.default, + ignore_identity_federation_roles: settings.ignore_identity_federation_roles || this.settings.ignore_identity_federation_roles.default, + ignore_aws_managed_iam_policies: settings.ignore_aws_managed_iam_policies || this.settings.ignore_aws_managed_iam_policies.default, + ignore_customer_managed_iam_policies: settings.ignore_customer_managed_iam_policies || this.settings.ignore_customer_managed_iam_policies.default, + iam_role_policies_ignore_tag: settings.iam_role_policies_ignore_tag || this.settings.iam_role_policies_ignore_tag.default, + iam_policy_resource_specific_wildcards: settings.iam_policy_resource_specific_wildcards || this.settings.iam_policy_resource_specific_wildcards.default, + ignore_iam_policy_resource_wildcards: settings.ignore_iam_policy_resource_wildcards || this.settings.ignore_iam_policy_resource_wildcards.default, + iam_policy_message_format: settings.iam_policy_message_format || this.settings.iam_policy_message_format.default }; + config.ignore_service_specific_wildcards = (config.ignore_service_specific_wildcards === 'true'); + config.ignore_identity_federation_roles = (config.ignore_identity_federation_roles === 'true'); + config.ignore_aws_managed_iam_policies = (config.ignore_aws_managed_iam_policies === 'true'); + config.ignore_customer_managed_iam_policies = (config.ignore_customer_managed_iam_policies === 'true'); + config.ignore_iam_policy_resource_wildcards = (config.ignore_iam_policy_resource_wildcards === 'true'); + config.iam_policy_message_format = (config.iam_policy_message_format === 'true'); + + + var allowedRegex = RegExp(config.iam_policy_resource_specific_wildcards); var custom = helpers.isCustom(settings, this.settings); var results = []; var source = {}; - + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; var listRoles = helpers.addSource(cache, source, ['iam', 'listRoles', region]); @@ -59,6 +148,37 @@ module.exports = { role.Path.indexOf(config.iam_role_policies_ignore_path) > -1) { return cb(); } + // Get role details + var getRole = helpers.addSource(cache, source, + ['iam', 'getRole', region, role.RoleName]); + + if (!getRole || getRole.err || !getRole.data || !getRole.data.Role) { + helpers.addResult(results, 3, + 'Unable to query for IAM role details: ' + role.RoleName + ': ' + helpers.addError(getRole), 'global', role.Arn); + return cb(); + } + + //Skip roles with user defined tags + if (config.iam_role_policies_ignore_tag && config.iam_role_policies_ignore_tag.length) { + var tagList = config.iam_role_policies_ignore_tag.split(','); + var ignoreRole = tagList.some(tag => { + var key = tag.split(/:(?!.*:)/)[0].trim(); + var value = new RegExp(tag.split(/:(?!.*:)/)[1].trim()); + if (getRole.data.Role.Tags && getRole.data.Role.Tags.length){ + return getRole.data.Role.Tags.find(tag => + tag.Key == key && value.test(tag.Value)); + } + }); + if (ignoreRole) return cb(); + } + + if (config.ignore_identity_federation_roles && + helpers.hasFederatedUserRole(helpers.normalizePolicyDocument(role.AssumeRolePolicyDocument))) { + helpers.addResult(results, 0, + 'Role is federated user role', + 'global', role.Arn, custom); + return cb(); + } // Get managed policies attached to role var listAttachedRolePolicies = helpers.addSource(cache, source, @@ -71,96 +191,307 @@ module.exports = { var getRolePolicy = helpers.addSource(cache, source, ['iam', 'getRolePolicy', region, role.RoleName]); - if (listAttachedRolePolicies.err) { + if (!listAttachedRolePolicies || listAttachedRolePolicies.err) { helpers.addResult(results, 3, 'Unable to query for IAM attached policy for role: ' + role.RoleName + ': ' + helpers.addError(listAttachedRolePolicies), 'global', role.Arn); return cb(); } - if (listRolePolicies.err) { + if (!listRolePolicies || listRolePolicies.err) { helpers.addResult(results, 3, 'Unable to query for IAM role policy for role: ' + role.RoleName + ': ' + helpers.addError(listRolePolicies), 'global', role.Arn); return cb(); } - var roleFailures = []; + var roleFailures = config.iam_policy_message_format ? {} : []; + // See if role has admin managed policy - if (listAttachedRolePolicies && - listAttachedRolePolicies.data && + if (listAttachedRolePolicies.data && listAttachedRolePolicies.data.AttachedPolicies) { - for (var a in listAttachedRolePolicies.data.AttachedPolicies) { - var policy = listAttachedRolePolicies.data.AttachedPolicies[a]; - + for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { if (policy.PolicyArn === managedAdminPolicy) { - roleFailures.push('Role has managed AdministratorAccess policy'); + if (config.iam_policy_message_format) { + roleFailures.admin = 'managedAdminPolicy'; + } else { + roleFailures.push('Role has managed AdministratorAccess policy'); + } break; } + + if (config.ignore_aws_managed_iam_policies && new RegExp(`^arn:${awsOrGov}:iam::aws:.*`).test(policy.PolicyArn)) continue; + + if (config.ignore_customer_managed_iam_policies && new RegExp(`^arn:${awsOrGov}:iam::[0-9]{12}:.*`).test(policy.PolicyArn)) continue; + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', region, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', region, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + + if (config.iam_policy_message_format) { + addRoleFailuresPolicyName(roleFailures, statements, 'managed', policy.PolicyName, config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } else { + addRoleFailures(roleFailures, statements, 'managed', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } + } + } } } - // See if role has admin inline policy - if (listRolePolicies && - listRolePolicies.data && + if (listRolePolicies.data && listRolePolicies.data.PolicyNames) { for (var p in listRolePolicies.data.PolicyNames) { var policyName = listRolePolicies.data.PolicyNames[p]; if (getRolePolicy && - getRolePolicy[policyName] && + getRolePolicy[policyName] && getRolePolicy[policyName].data && getRolePolicy[policyName].data.PolicyDocument) { - var statements = helpers.normalizePolicyDocument( - getRolePolicy[policyName].data.PolicyDocument); + var statements = getRolePolicy[policyName].data.PolicyDocument; if (!statements) break; - - // Loop through statements to see if admin privileges - for (var s in statements) { - var statement = statements[s]; - - if (statement.Effect === 'Allow' && - !statement.Condition) { - var failMsg; - if (statement.Action.indexOf('*') > -1 && - statement.Resource && - statement.Resource.indexOf('*') > -1) { - failMsg = 'Role inline policy allows all actions on all resources'; - } else if (statement.Action.indexOf('*') > -1) { - failMsg = 'Role inline policy allows all actions on selected resources'; - } else if (statement.Action && statement.Action.length) { - // Check each action for wildcards - var wildcards = []; - for (a in statement.Action) { - if (statement.Action[a].endsWith(':*')) { - wildcards.push(statement.Action[a]); - } - } - if (wildcards.length) failMsg = 'Role inline policy allows wildcard actions: ' + wildcards.join(', '); - } - - if (failMsg && roleFailures.indexOf(failMsg) === -1) roleFailures.push(failMsg); - } + if (config.iam_policy_message_format) { + addRoleFailuresPolicyName(roleFailures, statements, 'inline', policyName, config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } else { + addRoleFailures(roleFailures, statements, 'inline', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); } } } } - if (roleFailures.length) { - helpers.addResult(results, 2, - roleFailures.join(', '), - 'global', role.Arn, custom); + if (config.iam_policy_message_format) { + compileFormattedResults(roleFailures, role, results, custom); } else { - helpers.addResult(results, 0, - 'Role does not have overly-permissive policy', - 'global', role.Arn, custom); + compileSimpleResults(roleFailures, role, results, custom); } + cb(); }, function(){ callback(null, results, source); }); } -}; \ No newline at end of file +}; + +function addRoleFailures(roleFailures, statements, policyType, ignoreServiceSpecific, regResource, ignoreResourceSpecific) { + for (var statement of statements) { + if (statement.Effect === 'Allow' && + !statement.Condition) { + let failMsg; + if (statement.Action && + statement.Action.indexOf('*') > -1 && + statement.Resource && + statement.Resource.indexOf('*') > -1) { + failMsg = `Role ${policyType} policy allows all actions on all resources`; + } else if (statement.Action.indexOf('*') > -1) { + failMsg = `Role ${policyType} policy allows all actions on selected resources`; + } else if (!ignoreResourceSpecific && statement.Resource && statement.Resource == '*' ){ + failMsg = `Role ${policyType} policy allows actions on all resources`; + } else if (!ignoreServiceSpecific && statement.Action && statement.Action.length) { + // Check each action for wildcards + let wildcards = []; + for (var a in statement.Action) { + if (/^.+:[a-zA-Z]?\*.?$/.test(statement.Action[a])) { + wildcards.push(statement.Action[a]); + } + } + if (wildcards.length) failMsg = `Role ${policyType} policy allows wildcard actions: ${wildcards.join(', ')}`; + } else if (statement.Resource && statement.Resource.length) { + // Check each resource for wildcard + let wildcards = []; + for (var resource of statement.Resource) { + if (!regResource.test(resource)) { + wildcards.push(resource); + } + } + if (wildcards.length) failMsg = `Role ${policyType} policy does not match provided regex: ${wildcards.join(', ')}`; + } + + if (failMsg && roleFailures.indexOf(failMsg) === -1) roleFailures.push(failMsg); + } + } +} + +function addRoleFailuresPolicyName(roleFailures, statements, policyType, policyName, ignoreServiceSpecific, regResource, ignoreResourceSpecific) { + // Initialize roleFailures as an object for the first time + if (!roleFailures.managed) { + roleFailures.managed = { + allActionsAllResources: [], + allActionsSelectedResources: [], + actionsAllResources: [], + wildcardActions: {}, + regexMismatch: {} + }; + } + if (!roleFailures.inline) { + roleFailures.inline = { + allActionsAllResources: [], + allActionsSelectedResources: [], + actionsAllResources: [], + wildcardActions: {}, + regexMismatch: {} + }; + } + if (!roleFailures.admin) roleFailures.admin = false; + + for (var statement of statements) { + if (statement.Effect === 'Allow' && !statement.Condition) { + let targetObj = roleFailures[policyType]; + + if (statement.Action && + statement.Action.indexOf('*') > -1 && + statement.Resource && + statement.Resource.indexOf('*') > -1) { + targetObj.allActionsAllResources.push(policyName); + } else if (statement.Action.indexOf('*') > -1) { + targetObj.allActionsSelectedResources.push(policyName); + } else if (!ignoreResourceSpecific && statement.Resource && statement.Resource == '*') { + targetObj.actionsAllResources.push(policyName); + } else if (!ignoreServiceSpecific && statement.Action && statement.Action.length) { + // Check each action for wildcards + let wildcards = []; + for (var a in statement.Action) { + if (/^.+:[a-zA-Z]?\*.?$/.test(statement.Action[a])) { + wildcards.push(statement.Action[a]); + } + } + if (wildcards.length) { + if (!targetObj.wildcardActions[wildcards.join(', ')]) { + targetObj.wildcardActions[wildcards.join(', ')] = []; + } + if (!targetObj.wildcardActions[wildcards.join(', ')].includes(policyName)) { + targetObj.wildcardActions[wildcards.join(', ')].push(policyName); + } + } + } else if (statement.Resource && statement.Resource.length) { + // Check each resource for wildcard + let wildcards = []; + for (var resource of statement.Resource) { + if (!regResource.test(resource)) { + wildcards.push(resource); + } + } + if (wildcards.length) { + if (!targetObj.regexMismatch[wildcards.join(', ')]) { + targetObj.regexMismatch[wildcards.join(', ')] = []; + } + if (!targetObj.regexMismatch[wildcards.join(', ')].includes(policyName)) { + targetObj.regexMismatch[wildcards.join(', ')].push(policyName); + } + } + } + } + } +} + +function hasFailures(roleFailures) { + if (roleFailures.admin) return true; + + if (roleFailures.managed) { + if (roleFailures.managed.allActionsAllResources.length) return true; + if (roleFailures.managed.allActionsSelectedResources.length) return true; + if (roleFailures.managed.actionsAllResources.length) return true; + if (Object.keys(roleFailures.managed.wildcardActions).length) return true; + if (Object.keys(roleFailures.managed.regexMismatch).length) return true; + } + + if (roleFailures.inline) { + if (roleFailures.inline.allActionsAllResources.length) return true; + if (roleFailures.inline.allActionsSelectedResources.length) return true; + if (roleFailures.inline.actionsAllResources.length) return true; + if (Object.keys(roleFailures.inline.wildcardActions).length) return true; + if (Object.keys(roleFailures.inline.regexMismatch).length) return true; + } + + return false; +} + +function formatPolicyNames(policyArray) { + if (policyArray.length <= 5) { + return [...new Set(policyArray)].join('", "'); + } + return [...new Set(policyArray)].slice(0, 5).join('", "') + '" and so on...'; +} + +function compileSimpleResults(roleFailures, role, results, custom) { + if (roleFailures.length) { + helpers.addResult(results, 2, + roleFailures.join(', '), + 'global', role.Arn, custom); + } else { + helpers.addResult(results, 0, + 'Role does not have overly-permissive policy', + 'global', role.Arn, custom); + } +} + +function compileFormattedResults(roleFailures, role, results, custom) { + if (hasFailures(roleFailures)) { + let messages = []; + + if (roleFailures.admin == 'managedAdminPolicy') { + messages.push('Role has managed AdministratorAccess policy'); + } + + // Format managed policies + if (roleFailures.managed) { + if (roleFailures.managed.allActionsAllResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.allActionsAllResources)}" allows all actions on all resources`); + } + if (roleFailures.managed.allActionsSelectedResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.allActionsSelectedResources)}" allows all actions on selected resources`); + } + if (roleFailures.managed.actionsAllResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.actionsAllResources)}" allows actions on all resources`); + } + for (let action in roleFailures.managed.wildcardActions) { + messages.push(`Role managed policy "${roleFailures.managed.wildcardActions[action].join('", "')}" allows wildcard actions: ${action}`); + } + for (let resource in roleFailures.managed.regexMismatch) { + messages.push(`Role managed policy "${roleFailures.managed.regexMismatch[resource].join('", "')}" does not match provided regex: ${resource}`); + } + } + + // Format inline policies + if (roleFailures.inline) { + if (roleFailures.inline.allActionsAllResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.allActionsAllResources)}" allows all actions on all resources`); + } + if (roleFailures.inline.allActionsSelectedResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.allActionsSelectedResources)}" allows all actions on selected resources`); + } + if (roleFailures.inline.actionsAllResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.actionsAllResources)}" allows actions on all resources`); + } + for (let action in roleFailures.inline.wildcardActions) { + messages.push(`Role inline policy "${roleFailures.inline.wildcardActions[action].join('", "')}" allows wildcard actions: ${action}`); + } + for (let resource in roleFailures.inline.regexMismatch) { + messages.push(`Role inline policy "${roleFailures.inline.regexMismatch[resource].join('", "')}" does not match provided regex: ${resource}`); + } + } + + helpers.addResult(results, 2, + messages.join('\n'), + 'global', role.Arn, custom); + } else { + helpers.addResult(results, 0, + 'Role does not have overly-permissive policy', + 'global', role.Arn, custom); + } +} \ No newline at end of file diff --git a/plugins/aws/iam/iamRolePolicies.spec.js b/plugins/aws/iam/iamRolePolicies.spec.js new file mode 100644 index 000000000..927aff99a --- /dev/null +++ b/plugins/aws/iam/iamRolePolicies.spec.js @@ -0,0 +1,488 @@ +const expect = require('chai').expect; +var iamRolePolicies = require('./iamRolePolicies'); + + +const listRoles = [ + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "sts:AssumeRoleWithSAML", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-2", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-2", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "sts:AssumeRoleWithSAML", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/service-role/", + "RoleName": "test_lambda_core-role-04pqjctk", + "RoleId": "AROASZ433I6EHK3RAK3E4", + "Arn": "arn:aws:iam::123456789:role/service-role/test_lambda_core-role-04pqjctk", + "CreateDate": "2022-11-08T10:04:57+00:00", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "S3-Full" + ] + }, + { + "PolicyNames": [ + "All-Action-Resources" + ] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f7d427cc-970b-47af-9b7d-3e06121f83da' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'EC2-Full', + "PolicyArn": 'arn:aws:iam::000011112222:policy/EC2-Full' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'EC2-Wildcard', + "PolicyArn": 'arn:aws:iam::000011112222:policy/EC2-Wildcard' + } + ], + "IsTruncated": false + }, + { + "AttachedPolicies": [ + { + "PolicyName": "testPolicy", + "PolicyArn": "arn:aws:iam::193063503752:policy/testPolicy" + } + ] + } +]; + +const getRolePolicy = [ + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-Full', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ 's3:*' ], + Resource: [ '*' ] + } + ] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-WildCard', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ 's3:g*' ], + Resource: [ '*' ] + } + ] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-Limited', + "PolicyDocument": [ + { + Sid: 'AWSCloudTrailCreateLogStream2014110', + Effect: 'Allow', + Action: [ 'logs:CreateLogStream' ], + Resource: [ 'arn:aws:logs:us-east-1:193063503752:log-group:aws-cloudtrail-logs-193063503752-432bdd08:log-stream:193063503752_CloudTrail_us-east-1*' ] + }, + { + Sid: 'AWSCloudTrailPutLogEvents20141101', + Effect: 'Allow', + Action: [ 'logs:PutLogEvents' ], + Resource: [ 'arn:aws:logs:us-east-1:193063503752:log-group:aws-cloudtrail-logs-193063503752-432bdd08:log-stream:193063503752_CloudTrail_us-east-1*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'All-Action-Resources', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ '*' ], + Resource: [ '*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'All-Actions', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ '*' ], + Resource: [ 'arn:aws:s3:::*' ] + }] + } +]; + +const getPolicy = [ + { + "Policy": { + "PolicyName": 'EC2-Wildcard', + "PolicyId": 'ANPAYE32SRU57UHNCIGCT', + "Arn": 'arn:aws:iam::000011112222:policy/EC2-Wildcard', + "Path": '/', + "DefaultVersionId": 'v5', + "AttachmentCount": 2, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true + } + }, + { + "Policy": { + "PolicyName": "testPolicy", + "PolicyId": "ANPASZ433I6ELDTSCKKP3", + "Arn": "arn:aws:iam::123456789:policy/testPolicy", + "Path": "/service-role/", + "DefaultVersionId": "v1", + "AttachmentCount": 1, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true, + "CreateDate": "2022-11-08T10:04:57+00:00", + "UpdateDate": "2022-11-08T10:04:57+00:00", + "Tags": [] + } + + } +]; + +const getPolicyVersion = [ + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22s3%3A%2A%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22ec2%3A%2A%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22arn%3Aaws%3Aec2%3Aus-east-1%3A193063503752%3Ainstance%2Fi-0ed34b9c39ebd03ba%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v5', + } + }, + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22s3%3AputObject%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22ec2%3ACreateFleet%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22arn%3Aaws%3As3%3A%3A%3A%2A%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor1%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22ec2%3ACreateFleet%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22arn%3Aaws%3Aec2%3Aus-east-1%3A193063503752%3Ainstance%2F%2A%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": "v1", + "IsDefaultVersion": true, + "CreateDate": "2022-11-08T10:04:57+00:00" + } + } +]; + +const getRole = [ + { + 'Role':{ + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "Tags": [ + { + "Key": "app_name", + "Value": "KhulnasoftCSPM" + } + ], + } + }, + { + "Role": { + "Path": "/service-role/", + "RoleName": "test_lambda_core-role-04pqjctk", + "RoleId": "AROASZ433I6EHK3RAK3E4", + "Arn": "arn:aws:iam::123456789:role/service-role/test_lambda_core-role-04pqjctk", + "CreateDate": "2022-11-08T10:04:57+00:00", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + + } + +]; + +const createCache = (listRoles,getRole, listAttachedRolePolicies, listRolePolicies, getRolePolicy, getPolicy, getPolicyVersion, listRolesErr, listRolePoliciesErr, listAttachedRolePoliciesErr) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + var policyArn = (listAttachedRolePolicies && listAttachedRolePolicies.AttachedPolicies) ? listAttachedRolePolicies.AttachedPolicies[0].PolicyArn : null; + var policyName = (listRolePolicies && listRolePolicies.PolicyNames) ? listRolePolicies.PolicyNames[0] : null; + return { + iam: { + listRoles: { + 'us-east-1': { + err: listRolesErr, + data: listRoles + } + }, + getRole: { + 'us-east-1': { + [roleName]:{ + data: getRole + } + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listAttachedRolePoliciesErr, + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listRolePoliciesErr, + data: listRolePolicies + } + } + }, + getPolicy: { + 'us-east-1': { + [policyArn]: { + data: getPolicy + } + } + }, + getRolePolicy: { + 'us-east-1': { + [roleName]: { + [policyName]: { + data: getRolePolicy + } + } + } + }, + getPolicyVersion: { + 'us-east-1': { + [policyArn]: { + data: getPolicyVersion + } + } + }, + + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listRoles: { + 'us-east-1': null + } + } + }; +}; + +describe('iamRolePolicies', function () { + describe('run', function () { + + it('should PASS if role does not have overly-permissive policy', function (done) { + const cache = createCache([listRoles[0]], getRole[0], listAttachedRolePolicies[2], listRolePolicies[0], getRolePolicy[2]); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Role does not have overly-permissive policy'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows wildcard actions', function (done) { + const cache = createCache([listRoles[0]],getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy allows wildcard actions'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role policy allows wildcard actions but ignore managed iam policies is set to true', function (done) { + const cache = createCache([listRoles[0]],getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + iamRolePolicies.run(cache, { ignore_customer_managed_iam_policies : 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows all actions on selected resources', function (done) { + const cache = createCache([listRoles[0]],getRole[0], {}, listRolePolicies[1], getRolePolicy[4]); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('allows all actions on selected resources'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if role policy allows all actions on all resources', function (done) { + const cache = createCache([listRoles[1]],getRole[0], {}, listRolePolicies[1], getRolePolicy[3]); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('allows all actions on all resources'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role policy allows wildcard actions but ignore service specific roles setting is enabled', function (done) { + const cache = createCache([listRoles[0]],getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + iamRolePolicies.run(cache, { ignore_service_specific_wildcards: 'true'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if on IAM roles found', function (done) { + const cache = createCache([]); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No IAM roles found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list IAM roles', function (done) { + const cache = createCache(null, null, null, null, null, null, { message: 'Unable to list IAM roles'}); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM roles'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache([listRoles[1]],getRole[0], {}, null, null, null, null, null, null, { message: 'Unable to list attached role policies'}); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM attached policy for role'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache([listRoles[1]],getRole[0], listAttachedRolePolicies[0], {}, null, null, null, null, { message: 'Unable to query role policies'}); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM role policy for role'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list roles response not found', function (done) { + const cache = createNullCache(); + iamRolePolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if role with specific tag is ignored', function (done) { + const cache = createCache([listRoles[0]],getRole[0], listAttachedRolePolicies[2], listRolePolicies[0], getRolePolicy[2]); + iamRolePolicies.run(cache, {iam_role_policies_ignore_tag:'app_name:KhulnasoftCSPM'}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows resources which does not match regex in iam_policy_resource_specific_wildcards', function (done) { + const cache = createCache([listRoles[2]],getRole[1], listAttachedRolePolicies[3], null, null, getPolicy[1], getPolicyVersion[1]); + iamRolePolicies.run(cache, {ignore_service_specific_wildcards: 'true',iam_policy_resource_specific_wildcards: '^[a-z]+:[a-z]+:[a-z0-9]+:::[a-z]+$'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy does not match provided regex'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamSupportPolicy.js b/plugins/aws/iam/iamSupportPolicy.js new file mode 100644 index 000000000..dab41998c --- /dev/null +++ b/plugins/aws/iam/iamSupportPolicy.js @@ -0,0 +1,49 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM Support Policy', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that an IAM role, group or user exists with specific permissions to access support center.', + more_info: 'AWS provides a support center that can be used for incident notification and response, as well as technical support and customer services. An IAM Role should be present to allow authorized users to manage incidents with AWS Support.', + link: 'https://docs.aws.amazon.com/awssupport/latest/user/accessing-support.html', + recommended_action: 'Ensure that an IAM role has permission to access support center.', + apis: ['IAM:listPolicies'], + realtime_triggers: ['iam:CreateRole','iam:DeleteRole','iam:AttachRolePolicy', 'iam:DetachRolePolicy','iam:CreateUser','iam:DeleteUser','iam:AttachUserPolicy','iam:DetachUserPolicy','iam:CreateGroup','iam:DeleteGroup','iam:AttachGroupPolicy','iam:DetachGroupPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + const listPolicies = helpers.addSource(cache, source, + ['iam', 'listPolicies', region]); + + if (!listPolicies) return callback(null, results, source); + + if (listPolicies.err || !listPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM policies: ' + helpers.addError(listPolicies)); + return callback(null, results, source); + } + + if (!listPolicies.data.length) { + helpers.addResult(results, 0, + 'No IAM policies found'); + return callback(null, results, source); + } + + var found = listPolicies.data.find(policy => policy.PolicyName == 'AWSSupportAccess'); + + if (found) { + helpers.addResult(results, 0, + 'AWSSupportAccess policy is attached to a user, role or group', 'global', found.Arn); + } else { + helpers.addResult(results, 2, + 'No role, user or group attached to the AWSSupportAccess policy', 'global'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/aws/iam/iamSupportPolicy.spec.js b/plugins/aws/iam/iamSupportPolicy.spec.js new file mode 100644 index 000000000..d5a294ad6 --- /dev/null +++ b/plugins/aws/iam/iamSupportPolicy.spec.js @@ -0,0 +1,119 @@ +const expect = require('chai').expect; +var iamSupportPolicy = require('./iamSupportPolicy'); + +const listPolicies = [ + { + PolicyName: "CloudTrailCloudwatchRole", + PolicyId: "ANPAYE32SRU52MRBE7GDH", + Arn: "arn:aws:iam::111111111111:policy/CloudTrailCloudwatchRole", + Path: "/", + DefaultVersionId: "v2", + AttachmentCount: 0, + PermissionsBoundaryUsageCount: 0, + IsAttachable: true, + CreateDate: "", + UpdateDate: "", + Tags: [], + }, + { + PolicyName: "AWSSupportAccess", + PolicyId: "ANPAYE32SRU52MRBE7GDH", + Arn: "arn:aws:iam::111111111111:policy/AWSSupportAccess", + Path: "/", + DefaultVersionId: "v2", + AttachmentCount: 0, + PermissionsBoundaryUsageCount: 0, + IsAttachable: true, + CreateDate: "", + UpdateDate: "", + Tags: [], + }, + { + PolicyName: "AWSSupportAccess", + PolicyId: "ANPAYE32SRU52MRBE7GDH", + Arn: "arn:aws:iam::111111111111:policy/AWSSupportAccess", + Path: "/", + DefaultVersionId: "v2", + AttachmentCount: 1, + PermissionsBoundaryUsageCount: 0, + IsAttachable: true, + CreateDate: "", + UpdateDate: "", + Tags: [], + } +]; + +const createCache = (policies, entities) => { + return { + iam: { + listPolicies: { + "us-east-1": { + data: policies + } + + } + } + } + +} + +const createNullCachePolicies = () => { + return { + iam: { + listPolicies: { + "us-east-1": { + data: null + } + }, + } + } +} + +const createNullCacheEntities = (policies) => { + return { + iam: { + listPolicies: { + "us-east-1": { + data: policies + } + + } + } + } +} + +describe('iamSupportPolicy',() =>{ + describe('run', () => { + it('should PASS if no policy attachment to access support center',() => { + const cache = createCache([listPolicies[2]]); + iamSupportPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + + it('should PASS if no policies',() => { + const cache = createCache([]); + iamSupportPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }) + }); + + it('should FAIL if no policy attachment to access support center',() => { + const cache = createCache([listPolicies[0]]); + iamSupportPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }) + }); + + it('should UNKNOWN if no policy returned',() => { + const cache = createNullCachePolicies(); + iamSupportPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/iam/iamUserAdmins.js b/plugins/aws/iam/iamUserAdmins.js index 0e230803e..27b154a0d 100644 --- a/plugins/aws/iam/iamUserAdmins.js +++ b/plugins/aws/iam/iamUserAdmins.js @@ -1,17 +1,18 @@ var async = require('async'); var helpers = require('../../../helpers/aws'); -var managedAdminPolicy = 'arn:aws:iam::aws:policy/AdministratorAccess'; module.exports = { title: 'IAM User Admins', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures the number of IAM admins in the account are minimized', more_info: 'While at least two IAM admin users should be configured, the total number of admins should be kept to a minimum.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started_create-admin-group.html', recommended_action: 'Keep two users with admin permissions but ensure other IAM users have more limited permissions.', apis: ['IAM:listUsers', 'IAM:listUserPolicies', 'IAM:listAttachedUserPolicies', - 'IAM:listGroupsForUser', + 'IAM:listPolicies' ,'IAM:getPolicy', 'IAM:getPolicyVersion' ,'IAM:listGroupsForUser', 'IAM:listGroups', 'IAM:listGroupPolicies', 'IAM:listAttachedGroupPolicies', 'IAM:getUserPolicy', 'IAM:getGroupPolicy'], compliance: { @@ -33,6 +34,7 @@ module.exports = { default: 2 } }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser','iam:AttachUserPolicy','iam:DetachUserPolicy','iam:PutUserPolicy','iam:DeleteUserPolicy','iam:PutGroupPolicy','iam:DeleteGroupPolicy','iam:CreateGroup','iam:DeleteGroup','iam:AddUserToGroup','iam:RemoveUserFromGroup','iam:AttachGroupPolicy','iam:DetachGroupPolicy'], run: function(cache, settings, callback) { var config = { @@ -44,12 +46,21 @@ module.exports = { ? parseInt(settings.iam_admin_count_maximum) : this.settings.iam_admin_count_maximum.default, }; + var custom = helpers.isCustom(settings, this.settings); var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + if (config.iam_admin_count_maximum < config.iam_admin_count_minimum) { + helpers.addResult(results, 3, + 'IAM Admin Count Maximum cannot be less than IAM Admin Count Minimum'); + return callback(null, results, source); + } var listUsers = helpers.addSource(cache, source, ['iam', 'listUsers', region]); @@ -86,42 +97,71 @@ module.exports = { var getUserPolicy = helpers.addSource(cache, source, ['iam', 'getUserPolicy', region, user.UserName]); - if (listAttachedUserPolicies.err) { + if (!listAttachedUserPolicies || listAttachedUserPolicies.err) { helpers.addResult(results, 3, 'Unable to query for IAM attached policy for user: ' + user.UserName + ': ' + helpers.addError(listAttachedUserPolicies), 'global', user.Arn); return cb(); } - if (listUserPolicies.err) { + if (!listUserPolicies || listUserPolicies.err) { helpers.addResult(results, 3, 'Unable to query for IAM user policy for user: ' + user.UserName + ': ' + helpers.addError(listUserPolicies), 'global', user.Arn); return cb(); } - if (listGroupsForUser.err) { + if (!listGroupsForUser || listGroupsForUser.err) { helpers.addResult(results, 3, 'Unable to query for IAM user groups for user: ' + user.UserName + ': ' + helpers.addError(listGroupsForUser), 'global', user.Arn); return cb(); } // See if user has admin managed policy - if (listAttachedUserPolicies && - listAttachedUserPolicies.data && + if (listAttachedUserPolicies.data && listAttachedUserPolicies.data.AttachedPolicies) { - for (var a in listAttachedUserPolicies.data.AttachedPolicies) { var policy = listAttachedUserPolicies.data.AttachedPolicies[a]; + + if (!policy.PolicyArn) continue; if (policy.PolicyArn === managedAdminPolicy) { userAdmins.push({name: user.UserName, arn: user.Arn}); return cb(); } + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', region, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', region, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + userAdmins.push({name: user.UserName, arn: user.Arn}); + return cb(); + } + } + } + } } } // See if user has admin inline policy - if (listUserPolicies && - listUserPolicies.data && + if (listUserPolicies.data && listUserPolicies.data.PolicyNames) { for (var p in listUserPolicies.data.PolicyNames) { @@ -153,8 +193,7 @@ module.exports = { } // See if user is in a group allowing admin access - if (listGroupsForUser && - listGroupsForUser.data && + if (listGroupsForUser.data && listGroupsForUser.data.Groups) { for (var g in listGroupsForUser.data.Groups) { diff --git a/plugins/aws/iam/iamUserHasTags.js b/plugins/aws/iam/iamUserHasTags.js new file mode 100644 index 000000000..3002ded3d --- /dev/null +++ b/plugins/aws/iam/iamUserHasTags.js @@ -0,0 +1,57 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM User Has Tags', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that AWS IAM Users have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_tags_users.html', + recommended_action: 'Modify IAM User and add tags', + apis: ['IAM:listUsers', 'IAM:getUser'], + realtime_triggers: ['iam:CreateUser','iam:DeleteUser','iam:TagUser','iam:UntagUser'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listUsers = helpers.addSource(cache, source, + ['iam', 'listUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query for iam users: ' + helpers.addError(listUsers)); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No iam users found', 'global'); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + var getUser = helpers.addSource(cache, source, + ['iam', 'getUser', region, user.UserName]); + + if (!getUser || getUser.err || !getUser.data || !getUser.data.User) { + helpers.addResult(results, 3, + 'Unable to query for IAM user details: ' + user.UserName + ': ' + helpers.addError(getUser), 'global', user.Arn); + continue; + } + + if (!getUser.data.User.Tags || !getUser.data.User.Tags.length) { + helpers.addResult(results, 2, 'IAM User does not have tags', 'global', user.Arn); + } else { + helpers.addResult(results, 0, 'IAM User has tags', 'global', user.Arn); + } + + } + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/iamUserHasTags.spec.js b/plugins/aws/iam/iamUserHasTags.spec.js new file mode 100644 index 000000000..02ec7b9c9 --- /dev/null +++ b/plugins/aws/iam/iamUserHasTags.spec.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +const iamUserHasTags = require('./iamUserHasTags.js'); + +const listUsers = [ + { + "Path": "/", + "UserName": "cloudexploit", + "UserId": "AIDARPGOCGXSSUH7TNLM4", + "Arn": "arn:aws:iam::000011111:user/cloudexploit", + "CreateDate": "2021-12-12T13:15:54+00:00" + }, + { + "Path": "/", + "UserName": "testUser", + "UserId": "AIDARPGOCGXSUSX63OQEM", + "Arn": "arn:aws:iam::0000111111112:user/testUser", + "CreateDate": "2022-10-10T11:41:15+00:00" + } +]; + +const getUser = [ + { + "User": { + "Path": "/", + "UserName": "cloudexploit", + "UserId": "AIDARPGOCGXSSUH7TNLM4", + "Arn": "arn:aws:iam::000011111:user/cloudexploit", + "CreateDate": "2021-12-12T13:15:54+00:00", + "Tags": [ + { + "Key": "tag", + "Value": "tag" + } + ] + } + }, + { + "User": { + "Path": "/", + "UserName": "testUser", + "UserId": "AIDARPGOCGXSUSX63OQEM", + "Arn": "arn:aws:iam::0000111111112:user/testUser", + "CreateDate": "2022-10-10T11:41:15+00:00", + } + } +] +const createCache = (listUsers,getUser) => { + var userName = (listUsers && listUsers.length) ? listUsers[0].UserName : null; + return { + iam: { + listUsers: { + 'us-east-1': { + data: listUsers, + err: null + }, + }, + getUser: { + 'us-east-1': { + [userName]:{ + data: getUser, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': { + err: { + message: 'error listing IAM users' + }, + }, + }, + }, + }; +}; + +describe('iamUserHasTags', function () { + describe('run', function () { + it('Should PASS if IAM user has tags', function (done) { + const cache = createCache([listUsers[0]],getUser[0]); + iamUserHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('IAM User has tags'); + done(); + }); + }); + + it('Should FAIL if IAM user has tags', function (done) { + const cache = createCache([listUsers[1]],getUser[1]); + iamUserHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('IAM User does not have tags'); + done(); + }); + }); + + it('Should UNKNOWN if error in listing IAM user', function (done) { + const cache = createErrorCache(); + iamUserHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('Should PASS if no IAM user found', function (done) { + const cache = createCache([]); + iamUserHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamUserInUse.js b/plugins/aws/iam/iamUserInUse.js new file mode 100644 index 000000000..e65fd5e3b --- /dev/null +++ b/plugins/aws/iam/iamUserInUse.js @@ -0,0 +1,80 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM User Account In Use', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that IAM user accounts are not being actively used.', + more_info: 'IAM users, roles, and groups should not be used for day-to-day account management.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html', + recommended_action: 'Delete IAM user accounts which are being actively used.', + apis: ['IAM:generateCredentialReport'], + settings: { + iam_user_account_in_use_days: { + name: 'IAM User Account In Use Days', + description: 'Return a failing result when an IAM user account has been used within this many days', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '15' + } + }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], + + run: function(cache, settings, callback) { + const config = { + iam_user_account_in_use_days: parseInt(settings.iam_user_account_in_use_days || this.settings.iam_user_account_in_use_days.default) + }; + const custom = helpers.isCustom(settings, this.settings); + + const results = []; + const source = {}; + + const region = helpers.defaultRegion(settings); + + const generateCredentialReport = helpers.addSource(cache, source, + ['iam', 'generateCredentialReport', region]); + + if (!generateCredentialReport) return callback(null, results, source); + + if (generateCredentialReport.err || !generateCredentialReport.data) { + helpers.addResult(results, 3, + 'Unable to query IAM users: ' + helpers.addError(generateCredentialReport)); + return callback(null, results, source); + } + + let found = false; + + generateCredentialReport.data.forEach(user => { + if (user && user.user !== '') { + found = true; + + var accessDates = []; + + if (user.password_last_used && user.password_last_used !== 'no_information') accessDates.push(user.password_last_used); + + if (user.access_key_1_last_used_date && user.access_key_1_last_used_date != 'N/A') accessDates.push(user.access_key_1_last_used_date); + + if (user.access_key_2_last_used_date && user.access_key_2_last_used_date != 'N/A') accessDates.push(user.access_key_2_last_used_date); + + if (!accessDates.length) { + helpers.addResult(results, 0, 'IAM user has not been used', 'global', user.arn); + } else { + const currentDate = new Date(); + const loginDate = new Date(helpers.mostRecentDate(accessDates)); + const difference = helpers.daysBetween(loginDate, currentDate); + const resultCode = ( difference < config.iam_user_account_in_use_days) ? 2: 0; + + helpers.addResult(results, resultCode, + 'IAM user was last used ' + difference + ' days ago', + 'global', user.arn, custom); + } + } + }); + + if (!found) { + helpers.addResult(results, 0, 'No IAM users found', 'global'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/aws/iam/iamUserInUse.spec.js b/plugins/aws/iam/iamUserInUse.spec.js new file mode 100644 index 000000000..451ade7e2 --- /dev/null +++ b/plugins/aws/iam/iamUserInUse.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var iamUserInUse = require('./iamUserInUse') + + +const currentDate = new Date(); +const dayOldDate = new Date(); +dayOldDate.setDate(dayOldDate.getDate()-1); +const oldDate = new Date(); +oldDate.setFullYear(oldDate.getFullYear()-1); + +const users = [ + { + "user": "", + "arn": "arn:aws:iam::11111111111:root", + "password_last_used": oldDate, + "access_key_1_last_used_date": null, + "access_key_2_last_used_date": null, + }, + { + "user": "kms_key_recovery", + "arn": "arn:aws:iam::11111111111:user/kms_key_recovery", + "password_last_used": oldDate, + "access_key_1_last_used_date": null, + "access_key_2_last_used_date": null, + }, + { + "user": "kms_key_recovery", + "arn": "arn:aws:iam::11111111111:user/kms_key_recovery", + "password_last_used": oldDate, + "access_key_1_last_used_date": dayOldDate, + "access_key_2_last_used_date": null, + } +] + +const createCache = (users) => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + data: users + } + } + } + } +} + +describe('iamUserInUse', function () { + describe('run', function () { + it('should FAIL when IAM user account recently used', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache([users[2]]); + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.equal('IAM user was last used 1 days ago'); + done(); + }; + + iamUserInUse.run(cache, settings, callback); + }) + + it('should PASS when IAM user account not recently used', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache([users[1]]); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }; + + iamUserInUse.run(cache, settings, callback); + }) + + it('should ignore recently used root user', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + } + + const cache = createCache([users[0], users[1]]); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }; + + iamUserInUse.run(cache, settings, callback); + }) + }) +}) diff --git a/plugins/aws/iam/iamUserNameRegex.js b/plugins/aws/iam/iamUserNameRegex.js index 4545732c8..d1390b956 100644 --- a/plugins/aws/iam/iamUserNameRegex.js +++ b/plugins/aws/iam/iamUserNameRegex.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'IAM Username Matches Regex', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures all IAM user names match the given regex', more_info: 'Many organizational policies require IAM user names to follow a common naming convention. This check ensures these conventions are followed.', recommended_action: 'Rename the IAM user name to match the provided regex.', @@ -17,6 +19,19 @@ module.exports = { default: '^.*$', }, }, + asl: { + conditions: [ + { + service: 'iam', + api: 'generateCredentialReport', + property: 'user', + transform: 'STRING', + op: 'MATCHES', + value: '^.*$' + } + ] + }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/iamUserNameRegex.spec.js b/plugins/aws/iam/iamUserNameRegex.spec.js new file mode 100644 index 000000000..982ccacc4 --- /dev/null +++ b/plugins/aws/iam/iamUserNameRegex.spec.js @@ -0,0 +1,121 @@ +const expect = require('chai').expect; +const iamUserNameRegex = require('./iamUserNameRegex'); + +const users = [ + { + "user": "", + "arn": "arn:aws:iam::112233445566:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-09-17T21:45:22+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false + }, + { + "user": "test1", + "arn": "arn:aws:iam::112233445566:user/test1", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-09-24T10:15:34+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false + }, + { + "user": "tes11", + "arn": "arn:aws:iam::112233445566:user/tes11", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-09-24T10:15:34+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false + } +]; + +const createCache = (users) => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + data: users, + }, + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('iamUserNameRegex', function () { + describe('run', function () { + it('should PASS if IAM username matches regex', function (done) { + const cache = createCache([users[1]]); + const settings = { iam_username_regex: '^test(.+)' } + iamUserNameRegex.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if IAM username improperly named', function (done) { + const cache = createCache([users[2]]); + const settings = { iam_username_regex: '^test(.+)' } + iamUserNameRegex.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if IAM username is of Root Account', function (done) { + const cache = createCache([users[0]]); + iamUserNameRegex.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for IAM credetial report', function (done) { + const cache = createErrorCache(); + iamUserNameRegex.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for IAM credential report', function (done) { + const cache = createNullCache(); + iamUserNameRegex.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/iamUserNotInUse.js b/plugins/aws/iam/iamUserNotInUse.js new file mode 100644 index 000000000..5cd2d8e2e --- /dev/null +++ b/plugins/aws/iam/iamUserNotInUse.js @@ -0,0 +1,80 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM User Account Not In Use', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that IAM user accounts are being actively used.', + more_info: 'To increase the security of your AWS account, remove IAM user accounts that have not been used over a certain period of time.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html', + recommended_action: 'Delete IAM user accounts which are not being actively used or change the password or deactivate the access keys so they no longer have access.', + apis: ['IAM:generateCredentialReport'], + settings: { + iam_user_account_not_in_use_days: { + name: 'IAM User Account Not In Use Days', + description: 'Return a failing result when an IAM user account has not been used within this many days', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], + + run: function(cache, settings, callback) { + const config = { + iam_user_account_not_in_use_days: parseInt(settings.iam_user_account_not_in_use_days || this.settings.iam_user_account_not_in_use_days.default) + }; + const custom = helpers.isCustom(settings, this.settings); + + const results = []; + const source = {}; + + const region = helpers.defaultRegion(settings); + + const generateCredentialReport = helpers.addSource(cache, source, + ['iam', 'generateCredentialReport', region]); + + if (!generateCredentialReport) return callback(null, results, source); + + if (generateCredentialReport.err || !generateCredentialReport.data) { + helpers.addResult(results, 3, + 'Unable to query IAM users: ' + helpers.addError(generateCredentialReport)); + return callback(null, results, source); + } + + let found = false; + + generateCredentialReport.data.forEach(user => { + if (user && user.user !== '') { + found = true; + + var accessDates = []; + + if (user.password_last_used && user.password_last_used !== 'no_information') accessDates.push(user.password_last_used); + + if (user.access_key_1_last_used_date && user.access_key_1_last_used_date != 'N/A') accessDates.push(user.access_key_1_last_used_date); + + if (user.access_key_2_last_used_date && user.access_key_2_last_used_date != 'N/A') accessDates.push(user.access_key_2_last_used_date); + + if (!accessDates.length) { + helpers.addResult(results, 2, 'IAM user has not been used', 'global', user.arn); + } else { + const currentDate = new Date(); + const loginDate = new Date(helpers.mostRecentDate(accessDates)); + const difference = helpers.daysBetween(loginDate, currentDate); + const resultCode = ( difference > config.iam_user_account_not_in_use_days) ? 2 : 0; + + helpers.addResult(results, resultCode, + 'IAM user was last used ' + difference + ' days ago', + 'global', user.arn, custom); + } + } + }); + + if (!found) { + helpers.addResult(results, 0, 'No IAM users found', 'global'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/aws/iam/iamUserNotInUse.spec.js b/plugins/aws/iam/iamUserNotInUse.spec.js new file mode 100644 index 000000000..ed3a54249 --- /dev/null +++ b/plugins/aws/iam/iamUserNotInUse.spec.js @@ -0,0 +1,130 @@ +var expect = require('chai').expect; +var iamUserNotInUse = require('./iamUserNotInUse') + + +const dayOldDate = new Date(); +dayOldDate.setDate(dayOldDate.getDate()-1); +const oldDate = new Date(); +oldDate.setFullYear(oldDate.getFullYear()-1); + +const users = [ + { + "user": "", + "arn": "arn:aws:iam::11111111111:root", + "password_last_used": oldDate, + "access_key_1_last_used_date": null, + "access_key_2_last_used_date": null, + }, + { + "user": "kms_key_recovery", + "arn": "arn:aws:iam::11111111111:user/kms_key_recovery", + "password_last_used": oldDate, + "access_key_1_last_used_date": null, + "access_key_2_last_used_date": null, + }, + { + "user": "kms_key_recovery", + "arn": "arn:aws:iam::11111111111:user/kms_key_recovery", + "password_last_used": oldDate, + "access_key_1_last_used_date": dayOldDate, + "access_key_2_last_used_date": null, + } +] + +const createCache = (users, err = null) => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + data: users, + err + } + } + } + } +} + +describe('iamUserNotInUse', function () { + describe('run', function () { + + it('should give unknown when unable to query for IAM Users', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache(null, ['error']); + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query IAM users'); + done(); + }; + + iamUserNotInUse.run(cache, settings, callback); + }) + + it('should pass when no IAM Users found', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache([]); + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('No IAM users found'); + done(); + }; + + iamUserNotInUse.run(cache, settings, callback); + }) + + it('should pass if IAM user account was recently used', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache([users[2]]); + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('IAM user was last used 1 days ago'); + done(); + }; + + iamUserNotInUse.run(cache, settings, callback); + }) + + it('should PASS if IAM user account was not recently used', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + }; + + const cache = createCache([users[1]]); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }; + + iamUserNotInUse.run(cache, settings, callback); + }) + + it('should ignore root user', function (done) { + const settings = { + iam_user_account_in_use_days: '15' + } + + const cache = createCache([users[0], users[1]]); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }; + + iamUserNotInUse.run(cache, settings, callback); + }) + }) +}) diff --git a/plugins/aws/iam/iamUserPresent.js b/plugins/aws/iam/iamUserPresent.js new file mode 100644 index 000000000..cbb687476 --- /dev/null +++ b/plugins/aws/iam/iamUserPresent.js @@ -0,0 +1,44 @@ + +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM User Present', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that at least one IAM user exists so that access to your AWS services and resources is made only through IAM users instead of the root account.', + more_info: 'To protect your AWS root account and adhere to IAM security best practices, create individual IAM users to access your AWS environment.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html', + recommended_action: 'Create IAM user(s) and use them to access AWS services and resources.', + apis: ['IAM:listUsers'], + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listUsers = helpers.addSource(cache, source, + ['iam', 'listUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(listUsers)); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 2, 'No users found', 'global'); + } else { + helpers.addResult(results, 0, `Found ${listUsers.data.length} users`, 'global'); + } + + return callback(null, results, source); + } +}; + + + \ No newline at end of file diff --git a/plugins/aws/iam/iamUserPresent.spec.js b/plugins/aws/iam/iamUserPresent.spec.js new file mode 100644 index 000000000..58491aa5e --- /dev/null +++ b/plugins/aws/iam/iamUserPresent.spec.js @@ -0,0 +1,74 @@ +const expect = require('chai').expect; +const iamUserPresent = require('./iamUserPresent.js'); + +const listUsers = [ + { + UserName: 'test1', + UserId: 'AIDAYE32SRU545SJ5O6AI', + Arn: 'arn:aws:iam::000111222333:user/test1', + CreateDate: '2021-09-23T10:58:24.000Z', + PasswordLastUsed: '2021-10-04T13:02:00.000Z', + Tags: [] + } +]; + +const createCache = (listUsers) => { + var usersName = (listUsers && listUsers.length) ? listUsers[0].usersName : null; + return { + iam: { + listUsers: { + 'us-east-1': { + data: listUsers, + }, + }, + + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': { + err: { + message: 'error listing IAM users' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('iamUserPresent', function () { + describe('run', function () { + it('should pass if users are present', function (done) { + const cache = createCache([listUsers[0]]); + iamUserPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no IAM user(s) are present and root account is being used', function (done) { + const cache = createCache([]); + iamUserPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/iamUserUnauthorizedToEdit.js b/plugins/aws/iam/iamUserUnauthorizedToEdit.js new file mode 100644 index 000000000..27cb90f76 --- /dev/null +++ b/plugins/aws/iam/iamUserUnauthorizedToEdit.js @@ -0,0 +1,241 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + + +var iamEditAccessPermissions = [ + '*', + 'iam:*', + 'iam:CreatePolicy', + 'iam:CreatePolicyVersion', + 'iam:DeleteGroupPolicy', + 'iam:DeletePolicy', + 'iam:DeletePolicyVersion', + 'iam:DeleteRolePolicy', + 'iam:DeleteUserPolicy', + 'iam:DetachGroupPolicy', + 'iam:DetachRolePolicy', + 'iam:DetachUserPolicy', + 'iam:PutGroupPolicy', + 'iam:PutRolePolicy', + 'iam:PutUserPolicy', + 'iam:UpdateAssumeRolePolicy' +]; + +module.exports = { + title: 'IAM User Unauthorized to Edit', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensures AWS IAM users that are not authorized to edit IAM access policies are decommissioned.', + more_info: 'Only authorized IAM users should have permission to edit IAM access policies to prevent any unauthorized requests.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/access_controlling.html', + recommended_action: 'Update unauthorized IAM users to remove permissions to edit IAM access policies.', + apis: ['IAM:listUsers', 'IAM:listUserPolicies', 'IAM:listAttachedUserPolicies', + 'IAM:listGroupsForUser', 'IAM:listGroups', 'IAM:listGroupPolicies', + 'IAM:listAttachedGroupPolicies', 'IAM:getUserPolicy', 'IAM:getGroupPolicy'], + compliance: { + pci: 'PCI requires that cardholder data can only be accessed by those with ' + + 'a legitimate business need. Limiting the number of IAM administrators ' + + 'reduces the scope of users with potential access to this data.' + }, + settings: { + iam_authorized_user_arns: { + name: 'IAM Authorized User ARNs', + description: 'A comma delimited list of user ARNs authorized to contain edit IAM access policies permission', + regex: '^arn:(aws[a-zA-Z-]*)?:iam::[0-9]{12}:.+$', + default: '' + } + }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser','iam:AttachUserPolicy','iam:DetachUserPolicy','iam:PutUserPolicy','iam:DeleteUserPolicy','iam:PutGroupPolicy','iam:DeleteGroupPolicy','iam:CreateGroup','iam:DeleteGroup','iam:AddUserToGroup','iam:RemoveUserFromGroup','iam:AttachGroupPolicy','iam:DetachGroupPolicy'], + + run: function(cache, settings, callback) { + var whitelisted_users = settings.iam_authorized_user_arns || this.settings.iam_authorized_user_arns.default; + + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var adminAccessArn = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + var iamFullAccessArn = `arn:${awsOrGov}:iam::aws:policy/IAMFullAccess`; + + var listUsers = helpers.addSource(cache, source, + ['iam', 'listUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + `Unable to query for user IAM policy status: ${helpers.addError(listUsers)}`); + return callback(null, results, source); + } + + if (!helpers.isValidArray(listUsers.data)) { + helpers.addResult(results, 0, 'No user accounts found'); + return callback(null, results, source); + } + + var restrictedUser = {}; + + async.each(listUsers.data, function(user, cb){ + if (!user.UserName) return cb(); + + // Get managed policies attached to user + var listAttachedUserPolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedUserPolicies', region, user.UserName]); + + // Get inline policies attached to user + var listUserPolicies = helpers.addSource(cache, source, + ['iam', 'listUserPolicies', region, user.UserName]); + + var listGroupsForUser = helpers.addSource(cache, source, + ['iam', 'listGroupsForUser', region, user.UserName]); + + var getUserPolicy = helpers.addSource(cache, source, + ['iam', 'getUserPolicy', region, user.UserName]); + + if (!listAttachedUserPolicies || listAttachedUserPolicies.err) { + helpers.addResult(results, 3, + `Unable to query for IAM attached policy for user: ${user.UserName}: ${helpers.addError(listAttachedUserPolicies)}`, 'global', user.Arn); + return cb(); + } + + if (!listUserPolicies || listUserPolicies.err) { + helpers.addResult(results, 3, + `Unable to query for IAM user policy for user: ${user.UserName}: ${helpers.addError(listUserPolicies)}`, 'global', user.Arn); + return cb(); + } + + if (!listGroupsForUser || listGroupsForUser.err) { + helpers.addResult(results, 3, + `Unable to query for IAM user groups for user: ${user.UserName}: ${helpers.addError(listGroupsForUser)}`, 'global', user.Arn); + return cb(); + } + + // See if user has administrator access or IAM full access + if (listAttachedUserPolicies.data && listAttachedUserPolicies.data.AttachedPolicies) { + for (var p in listAttachedUserPolicies.data.AttachedPolicies) { + let policy = listAttachedUserPolicies.data.AttachedPolicies[p]; + + if (policy.PolicyArn === adminAccessArn || + policy.PolicyArn === iamFullAccessArn) { + addPolicyToUserObj(restrictedUser, user, policy.PolicyName); + } + } + } + + // See if user has IAM full access inline policy + if (listUserPolicies.data && listUserPolicies.data.PolicyNames) { + for (var up in listUserPolicies.data.PolicyNames) { + let policyName = listUserPolicies.data.PolicyNames[up]; + + if (getUserPolicy && + getUserPolicy[policyName] && + getUserPolicy[policyName].data && + getUserPolicy[policyName].data.PolicyDocument) { + + let statements = helpers.normalizePolicyDocument( + getUserPolicy[policyName].data.PolicyDocument); + if (!statements) break; + + // Loop through statements to see if admin privileges + for (var s in statements) { + let statement = statements[s]; + + if (helpers.userGlobalAccess(statement, iamEditAccessPermissions)) { + addPolicyToUserObj(restrictedUser, user, policyName); + } + } + } + } + } + + // See if user is in a group allowing admin access + if (listGroupsForUser.data && + listGroupsForUser.data.Groups) { + + for (var g in listGroupsForUser.data.Groups) { + var group = listGroupsForUser.data.Groups[g]; + + // Get managed policies attached to group + var listAttachedGroupPolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedGroupPolicies', region, group.GroupName]); + + // Get inline policies attached to group + var listGroupPolicies = helpers.addSource(cache, source, + ['iam', 'listGroupPolicies', region, group.GroupName]); + + var getGroupPolicy = helpers.addSource(cache, source, + ['iam', 'getGroupPolicy', region, group.GroupName]); + + // See if group has admin managed policy + if (listAttachedGroupPolicies && + listAttachedGroupPolicies.data && + listAttachedGroupPolicies.data.AttachedPolicies) { + + for (var a in listAttachedGroupPolicies.data.AttachedPolicies) { + let policyAttached = listAttachedGroupPolicies.data.AttachedPolicies[a]; + if (policyAttached.PolicyArn === adminAccessArn || + policyAttached.PolicyArn === iamFullAccessArn) { + addPolicyToUserObj(restrictedUser, user, policyAttached.PolicyName); + } + } + } + + // See if group has admin inline policy + if (listGroupPolicies && + listGroupPolicies.data && + listGroupPolicies.data.PolicyNames) { + + for (var q in listGroupPolicies.data.PolicyNames) { + let policyGroupName = listGroupPolicies.data.PolicyNames[q]; + + if (getGroupPolicy && + getGroupPolicy[policyGroupName] && + getGroupPolicy[policyGroupName].data && + getGroupPolicy[policyGroupName].data.PolicyDocument) { + var statementsGroup = helpers.normalizePolicyDocument( + getGroupPolicy[policyGroupName].data.PolicyDocument); + if (!statementsGroup) break; + + // Loop through statements to see if admin privileges + for (s in statementsGroup) { + let statementGroup = statementsGroup[s]; + + if (helpers.userGlobalAccess(statementGroup, iamEditAccessPermissions)) { + addPolicyToUserObj(restrictedUser, user, policyGroupName); + } + } + } + } + } + } + } + + if (!restrictedUser[user.Arn]) { + helpers.addResult(results, 0, + `IAM user "${user.UserName}" does not have edit access policies permission`, + 'global', user.Arn); + } else if (whitelisted_users.includes(user.Arn)) { + helpers.addResult(results, 0, + `IAM user "${user.UserName}" is authorized to have edit access policies permission`, + 'global', user.Arn); + } else { + helpers.addResult(results, 2, + `IAM user "${user.UserName}" is not authorized to have these policies attached: ${restrictedUser[user.Arn].policyNames.join(', ')}`, + 'global', user.Arn); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; + +function addPolicyToUserObj(userObj, user, policy) { + if (userObj[user.Arn]) { + if (!userObj[user.Arn].policyNames.includes(policy)) userObj[user.Arn].policyNames.push(policy); + } else userObj[user.Arn] = {name: user.UserName, policyNames: [policy]}; +} \ No newline at end of file diff --git a/plugins/aws/iam/iamUserUnauthorizedToEdit.spec.js b/plugins/aws/iam/iamUserUnauthorizedToEdit.spec.js new file mode 100644 index 000000000..1e7ef90af --- /dev/null +++ b/plugins/aws/iam/iamUserUnauthorizedToEdit.spec.js @@ -0,0 +1,275 @@ +const expect = require('chai').expect; +const iamUserUnauthorizedToEdit = require('./iamUserUnauthorizedToEdit'); +const helpers = require('../../../helpers/aws'); + +const listUsers = [ + { + "Path": "/", + "UserName": "test-cp1", + "UserId": "IHCTGF32SRU57PAYVNPEI", + "Arn": "arn:aws:iam::123456654321:user/test-cp1", + "CreateDate": "2020-09-12T16:58:32.000Z", + "PasswordLastUsed": "2020-09-20T04:19:18.000Z", + "Tags": [] + }, + { + "Path": "/", + "UserName": "test-cp", + "UserId": "AIDAYE32SRU57PAYVNPEI", + "Arn": "arn:aws:iam::123456654321:user/test-cp", + "CreateDate": "2020-09-12T16:58:32.000Z", + "PasswordLastUsed": "2020-09-20T04:19:18.000Z", + "Tags": [] + } +]; + +const listUserPolicies = [ + "CustomIAMFull", + "CustomIAMLimited" +]; + +const listAttachedUserPolicies = [ + { + "PolicyName": "IAMFullAccess", + "PolicyArn": "arn:aws:iam::aws:policy/IAMFullAccess" + }, + { + "PolicyName": "AdministratorAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AdministratorAccess" + }, + { + "PolicyName": "IAMUserChangePassword", + "PolicyArn": "arn:aws:iam::aws:policy/IAMUserChangePassword" + } +]; + +const getUserPolicy = [ + { + "UserName": "test-cp", + "PolicyName": "customIAMFull", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": "iam:*", + "Resource": "*" + } + ] + } + }, + { + "UserName": "test-cp1", + "PolicyName": "CustomIAMLimited", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "iam:CreateGroup", + "iam:CreateRole" + ], + "Resource": "*" + } + ] + } + } +]; + +const listGroups = [ + { + "Path": "/", + "GroupName": "group1", + "GroupId": "AGPAYE32SRU5VIDOFGQPO", + "Arn": "arn:aws:iam::123456654321:group/group1", + "CreateDate": "2020-10-24T14:50:04.000Z" + } +]; + +const createCache = (listUsers, listPolicies, attachedPolicies, getPolicy, listGroups) => { + var userName = (listUsers && listUsers.length) ? listUsers[0].UserName : null; + var groupName = (listGroups && listGroups.length) ? listGroups[0].GroupName : null; + return { + iam: { + listUsers: { + 'us-east-1': { + data: listUsers + } + }, + listUserPolicies: { + 'us-east-1': { + [userName]: { + data: { + PolicyNames: listPolicies + } + } + } + }, + listAttachedUserPolicies: { + 'us-east-1': { + [userName]: { + data: { + AttachedPolicies: attachedPolicies + } + } + } + }, + getUserPolicy: { + 'us-east-1': { + [userName]: { + data: getPolicy + } + } + }, + listGroups: { + 'us-east-1': { + data: listGroups + } + }, + listGroupsForUser: { + 'us-east-1': { + [userName]: { + data: { + Groups: listGroups + } + } + } + }, + listAttachedGroupPolicies: { + 'us-east-1': { + [groupName]: { + data: { + AttachedPolicies: attachedPolicies + } + } + } + }, + getGroupPolicy: { + 'us-east-1': { + [groupName]: { + data: getPolicy + } + } + } + } + } +}; + +const createNullCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': null, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': { + err: { + message: 'error listing IAM users' + }, + }, + }, + }, + }; +}; + +const createListPoliciesErrorCache = (listUsers) => { + var userName = (listUsers && listUsers.length) ? listUsers[0].UserName : null; + return { + iam: { + listUsers: { + 'us-east-1': { + data: listUsers + } + }, + listUserPolicies: { + 'us-east-1': { + [userName]: { + err: { + message: 'error listing user policies' + } + } + }, + }, + }, + }; +}; + +describe('iamUserUnauthorizedToEdit', function () { + describe('run', function () { + + it('should PASS if the IAM user does not have edit IAM access policies', function (done) { + const cache = createCache([listUsers[0]], [listUserPolicies[1]], [listAttachedUserPolicies[2]], getUserPolicy[0], [listGroups[0]]); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if the IAM user is authorized to edit IAM access policies', function (done) { + const cache = createCache([listUsers[0]], [listUserPolicies[0]], [listAttachedUserPolicies[1]], getUserPolicy[1]); + const settings = { + iam_authorized_user_arns: 'arn:aws:iam::123456654321:user/test-cp1' + } + iamUserUnauthorizedToEdit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if the IAM user is not authorized to have edit access policies permission', function (done) { + const cache = createCache([listUsers[1]], [listUserPolicies[0]], [listAttachedUserPolicies[1]], getUserPolicy[0]); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no IAM users found', function (done) { + const cache = createCache([]); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if uanble to list IAM users', function (done) { + const cache = createErrorCache(); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if uanble to list IAM user policies', function (done) { + const cache = createListPoliciesErrorCache(listUsers); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list IAM users response not found', function (done) { + const cache = createNullCache(); + iamUserUnauthorizedToEdit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/iamUserWithoutPermissions.js b/plugins/aws/iam/iamUserWithoutPermissions.js new file mode 100644 index 000000000..37a940c54 --- /dev/null +++ b/plugins/aws/iam/iamUserWithoutPermissions.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IAM User Without Permissions', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that no IAM user exists without any permissions.', + more_info: 'IAM users are created to perform any Console, CLI or API based operations on AWS cloud accounts. They are associated with policies that grant them permissions to perform required operations. An IAM user without any permission is a security risk, it is recommended to either add required permissions or delete them to adhere to compliance standards.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html', + recommended_action: 'Modify IAM user and attach new permissions or delete the user.', + apis: ['IAM:listUsers', 'IAM:listUserPolicies', 'IAM:listAttachedUserPolicies', 'IAM:getPolicyVersion' ,'IAM:listGroupsForUser', + 'IAM:listGroups', 'IAM:listGroupPolicies', 'IAM:listAttachedGroupPolicies'], + realtime_triggers: ['iam:CreateUser','iam:DeleteUser','iam:AttachUserPolicy','iam:DetachUserPolicy','iam:PutUserPolicy','iam:DeleteUserPolicy','iam:PutGroupPolicy','iam:DeleteGroupPolicy','iam:CreateGroup','iam:DeleteGroup','iam:AddUserToGroup','iam:RemoveUserFromGroup','iam:AttachGroupPolicy','iam:DetachGroupPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var listUsers = helpers.addSource(cache, source, + ['iam', 'listUsers', region]); + + if (!listUsers) return callback(null, results, source); + + if (listUsers.err || !listUsers.data) { + helpers.addResult(results, 3, + 'Unable to query list IAM users: ' + helpers.addError(listUsers)); + return callback(null, results, source); + } + + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No user accounts found'); + return callback(null, results, source); + } + + async.each(listUsers.data, function(user, cb){ + if (!user.UserName) return cb(); + + var listAttachedUserPolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedUserPolicies', region, user.UserName]); + + var listUserPolicies = helpers.addSource(cache, source, + ['iam', 'listUserPolicies', region, user.UserName]); + + var listGroupsForUser = helpers.addSource(cache, source, + ['iam', 'listGroupsForUser', region, user.UserName]); + + if (!listAttachedUserPolicies) return cb(); + if (!listUserPolicies) return cb(); + if (!listGroupsForUser) return cb(); + + + if (listAttachedUserPolicies.err || !listAttachedUserPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM attached policy for user: ' + user.UserName + ': ' + helpers.addError(listAttachedUserPolicies), 'global', user.Arn); + return cb(); + } + + if (listUserPolicies.err || !listUserPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM policy for user: ' + user.UserName + ': ' + helpers.addError(listUserPolicies), 'global', user.Arn); + return cb(); + } + + if (listGroupsForUser.err || !listGroupsForUser.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM groups attached to user: ' + user.UserName + ': ' + helpers.addError(listGroupsForUser), 'global', user.Arn); + return cb(); + } + + var listGroupPolicies, listAttachedGroupPolicies; + if (listGroupsForUser.data && listGroupsForUser.data.Groups){ + for (let group of listGroupsForUser.data.Groups){ + listGroupPolicies = helpers.addSource(cache, source, + ['iam', 'listGroupPolicies', region, group.GroupName]); + + listAttachedGroupPolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedGroupPolicies', region, group.GroupName]); + + if (!listGroupPolicies || listGroupPolicies.err || !listGroupPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM group policies: ' + helpers.addError(listGroupPolicies)); + return cb(); + } + + if (!listAttachedGroupPolicies || listAttachedGroupPolicies.err || !listAttachedGroupPolicies.data ) { + helpers.addResult(results, 3, + 'Unable to query for IAM attached group policies: ' + helpers.addError(listAttachedGroupPolicies)); + return cb(); + } + + if ((listGroupPolicies.data.PolicyNames && listGroupPolicies.data.PolicyNames.length) || + (listAttachedGroupPolicies.data.AttachedPolicies && listAttachedGroupPolicies.data.AttachedPolicies.length)){ + break; + } + } + } + + if ((listAttachedUserPolicies.data.AttachedPolicies && + listAttachedUserPolicies.data.AttachedPolicies.length) || + (listUserPolicies.data.PolicyNames && + listUserPolicies.data.PolicyNames.length) || (listAttachedGroupPolicies && listAttachedGroupPolicies.data.AttachedPolicies && + listAttachedGroupPolicies.data.AttachedPolicies.length) || + (listGroupPolicies && listGroupPolicies.data.PolicyNames && listGroupPolicies.data.PolicyNames.length)) { + helpers.addResult(results, 0, 'IAM user has permissions', 'global', user.Arn); + } else { + helpers.addResult(results, 2, 'IAM user does not have any permissions', 'global', user.Arn); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/iamUserWithoutPermissions.spec.js b/plugins/aws/iam/iamUserWithoutPermissions.spec.js new file mode 100644 index 000000000..769dd3b2e --- /dev/null +++ b/plugins/aws/iam/iamUserWithoutPermissions.spec.js @@ -0,0 +1,242 @@ +var expect = require('chai').expect; +const iamUserWithoutPermissions = require('./iamUserWithoutPermissions'); + +const listUsers = [ + { + "Path": "/", + "UserName": "cloudexploit", + "UserId": "AIDAYE32SRU57PAYVNPEI", + "Arn": "arn:aws:iam::111122223333:user/cloudexploit", + "CreateDate": "2020-09-12T16:58:32Z", + "PasswordLastUsed": "2020-11-14T18:51:16Z" + } +]; + +const listUserPolicies = [ + { + "PolicyNames": [] + }, + { + "PolicyNames": [ + "CustomIAMFull", + "CutomIAMLimited" + ] + }, +]; + +const groups = [ + { + "Path": "/", + "GroupName": "akhtar-gr-15", + "GroupId": "AGPAYE32SRU5WTVWZJGNX", + "Arn": "arn:aws:iam::123456654321:group/akhtar-gr-15", + "CreateDate": "2020-08-30T14:24:48.000Z" + }, + { + "Path": "/", + "GroupName": "akhtar-gr3-15", + "GroupId": "AGPAYE32SRU56LRFN4U55", + "Arn": "arn:aws:iam::123456654321:group/akhtar-gr3-15", + "CreateDate": "2020-08-30T15:06:01.000Z" + } +]; +const groupsForUsers = { + Groups: [ {"GroupName": "akhtar-gr-15"}] +} +const groupPolicies = [ + { + ResponseMetadata: { RequestId: 'ac47dbfc-6333-4840-8500-2ffb616f03d4' }, + PolicyNames: [ + 'policygen-akhtar-gr-15-202008301932', + 'policygen-akhtar-gr-15-202008302019' + ], + IsTruncated: false + }, + { + ResponseMetadata: { RequestId: '485a4202-06ef-4e8b-9661-ed4d1dd286d3' }, + PolicyNames: [ + + ], + IsTruncated: false + } +]; + +const listAttachedUserPolicies = [ + + { + "AttachedPolicies": [] + }, + { + "AttachedPolicies": [ + { + "PolicyName": "IAMFullAccess", + "PolicyArn": "arn:aws:iam::aws:policy/IAMFullAccess" + }, + { + "PolicyName": "AmazonS3FullAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AmazonS3FullAccess" + }, + { + "PolicyName": "AdministratorAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AdministratorAccess" + }, + { + "PolicyName": "IAMUserChangePassword", + "PolicyArn": "arn:aws:iam::aws:policy/IAMUserChangePassword" + }, + { + "PolicyName": "AWSBillingReadOnlyAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess" + } + ] + } +]; + +const createCache = (users, listUserPolicies, attachedUserPolicies, groups, groupsForUsers, getPolicy, groupAttachedPolicies ) => { + if (users && users.length && users[0].UserName) var username = users[0].UserName; + if (groups && groups.length) var groupName = groups[0].GroupName; + + return { + iam:{ + listUsers: { + 'us-east-1': { + data: users + }, + }, + listUserPolicies: { + 'us-east-1': { + [username]: { + data: listUserPolicies + }, + }, + }, + listAttachedUserPolicies: { + 'us-east-1': { + [username]: { + data: attachedUserPolicies + }, + }, + }, + listGroups: { + 'us-east-1': { + data: groups, + }, + }, + listGroupsForUser: { + 'us-east-1': { + [username]: { + data: groupsForUsers + } + } + }, + listGroupPolicies: { + 'us-east-1': { + [groupName]: { + data: getPolicy + } + } + }, + + listAttachedGroupPolicies: { + 'us-east-1': { + [groupName]: { + data: groupAttachedPolicies + } + } + }, + + }, + }; +}; + +const createErrorCache = () => { + + return { + iam:{ + listUsers: { + 'us-east-1': { + err: { + message: 'error list users' + }, + }, + }, + }, + }; +}; + + +describe('iamUserWithoutPermissions', function () { + describe('run', function () { + it('should PASS if no IAM users found', function (done) { + const cache = createCache([]); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if uanble to list IAM users', function (done) { + const cache = createErrorCache(); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if uanble to list IAM user policies', function (done) { + const cache = createCache(listUsers, null, listAttachedUserPolicies, groups); + + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + it('should UNKNOWN if uanble to list IAM attached user policies', function (done) { + const cache = createCache(listUsers, listUserPolicies, null); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + it('should UNKNOWN if uanble to list group policies', function (done) { + const cache = createCache(listUsers, listUserPolicies, listAttachedUserPolicies, groups, groupsForUsers, null, groupPolicies); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if uanble to list group attached policies', function (done) { + const cache = createCache(listUsers, listUserPolicies, listAttachedUserPolicies, groups, groupsForUsers, groupPolicies, null); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if user has permissions', function (done) { + const cache = createCache(listUsers, listUserPolicies[1], listAttachedUserPolicies[1], groups, {Groups: []}, groupPolicies[0]); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('IAM user has permissions'); + done(); + }); + }); + it('should FAIL if user does not have permissions', function (done) { + const cache = createCache(listUsers, listUserPolicies[0], listAttachedUserPolicies[0], groups, {Groups: []}, groupPolicies[0]); + iamUserWithoutPermissions.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('IAM user does not have any permissions'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/maxPasswordAge.js b/plugins/aws/iam/maxPasswordAge.js index 379357b65..6a536a9e2 100644 --- a/plugins/aws/iam/maxPasswordAge.js +++ b/plugins/aws/iam/maxPasswordAge.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Maximum Password Age', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires passwords to be reset every 180 days', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,20 @@ module.exports = { remediation_description: 'The password policy for maximum password age will be set to the value set by the user. Otherwise, it will default to 179.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + maxPasswordAge: { + name: '(Optional) Maximum Password Age', + description: 'The maximum age of passwords to allow in the account policy 1-1095', + regex: '^([1-9][0-9]{0,2}|10[0-9][0-5])$', + required: false + }, + maxPasswordAgeCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -31,6 +47,19 @@ module.exports = { default: 180 } }, + asl: { + conditions: [ + { + service: 'iam', + api: 'getAccountPasswordPolicy', + property: 'MaxPasswordAge', + transform: 'INTEGER', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var config = { @@ -50,11 +79,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } @@ -69,11 +98,11 @@ module.exports = { if (!passwordPolicy.MaxPasswordAge) { helpers.addResult(results, 2, 'Password policy does not specify a maximum password age'); } else if (passwordPolicy.MaxPasswordAge > config.max_password_age_fail) { - helpers.addResult(results, 2, 'Maximum password age of: ' + passwordPolicy.MaxPasswordAge + ' days is more than one year', 'global', null, custom); + helpers.addResult(results, 2, `Maximum password age of: ${passwordPolicy.MaxPasswordAge} days is more than ${config.max_password_age_fail}`, 'global', null, custom); } else if (passwordPolicy.MaxPasswordAge > config.max_password_age_warn) { - helpers.addResult(results, 1, 'Maximum password age of: ' + passwordPolicy.MaxPasswordAge + ' days is more than six months', 'global', null, custom); + helpers.addResult(results, 1, `Maximum password age of: ${passwordPolicy.MaxPasswordAge} days is more than ${config.max_password_age_warn}`, 'global', null, custom); } else { - helpers.addResult(results, 0, 'Maximum password age of: ' + passwordPolicy.MaxPasswordAge + ' days is suitable', 'global', null, custom); + helpers.addResult(results, 0, `Maximum password age of: ${passwordPolicy.MaxPasswordAge} days is suitable`, 'global', null, custom); } callback(null, results, source); diff --git a/plugins/aws/iam/maxPasswordAge.spec.js b/plugins/aws/iam/maxPasswordAge.spec.js new file mode 100644 index 000000000..b9be8b782 --- /dev/null +++ b/plugins/aws/iam/maxPasswordAge.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +const maxPasswordAge = require('./maxPasswordAge'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 90 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 210 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 400 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('maxPasswordAge', function () { + describe('run', function () { + it('should PASS if maximum age of password is suitable', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + var settings = { + max_password_age_fail: 365, + max_password_age_warn: 180 + }; + maxPasswordAge.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if maximum age of password is more than six months', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + var settings = { + max_password_age_fail: 365, + max_password_age_warn: 180 + }; + maxPasswordAge.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if maximum age of password is more than one year', function (done) { + const cache = createCache(getAccountPasswordPolicy[2]); + var settings = { + max_password_age_fail: 365, + max_password_age_warn: 180 + }; + maxPasswordAge.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if password policy does not specify a maximum password age', function (done) { + const cache = createCache([]); + maxPasswordAge.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + maxPasswordAge.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + maxPasswordAge.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + maxPasswordAge.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/minPasswordLength.js b/plugins/aws/iam/minPasswordLength.js index 96d8bdbb3..316c5e921 100644 --- a/plugins/aws/iam/minPasswordLength.js +++ b/plugins/aws/iam/minPasswordLength.js @@ -3,7 +3,9 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Minimum Password Length', category: 'IAM', - description: 'Ensures password policy requires a password of at least 14 characters', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures password policy requires a password of at least a minimum number of characters', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', recommended_action: 'Increase the minimum length requirement for the password policy', @@ -11,6 +13,20 @@ module.exports = { remediation_description: 'The password policy for minimum password length will be set to the value set by the user. Otherwise, it will default to 14.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + minPasswordLength: { + name: '(Optional) Minimum Password Length', + description: 'The minimum password length to use in the account policy 6-128', + regex: '^([6-9]|[0-9]{2}|11[0-9]|12[0-8])$', + required: false + }, + minPasswordLengthCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -32,6 +48,19 @@ module.exports = { default: 14 } }, + asl: { + conditions: [ + { + service: 'iam', + api: 'getAccountPasswordPolicy', + property: 'MinimumPasswordLength', + transform: 'INTEGER', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var config = { @@ -43,7 +72,7 @@ module.exports = { var results = []; var source = {}; - + var defaultPasswordPolicyMinLength = 8; var region = helpers.defaultRegion(settings); var getAccountPasswordPolicy = helpers.addSource(cache, source, @@ -51,11 +80,17 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default policy case if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + if (config.min_password_length_fail < 8) { + helpers.addResult(results, 2, `Minimum password length of: ${config.min_password_length_fail} is less than ${defaultPasswordPolicyMinLength} characters`, 'global', null, custom); + } else if (config.min_password_length_warn < 8) { + helpers.addResult(results, 1, `Minimum password length of: ${config.min_password_length_warn} is less than ${defaultPasswordPolicyMinLength} characters`, 'global', null, custom); + } else { + helpers.addResult(results, 0, `Account has Default password policy which enforces minimum length of ${config.min_password_length_warn} characters`, 'global', null, custom); + } return callback(null, results, source); } @@ -70,11 +105,11 @@ module.exports = { if (!passwordPolicy.MinimumPasswordLength) { helpers.addResult(results, 2, 'Password policy does not specify a minimum password length'); } else if (passwordPolicy.MinimumPasswordLength < config.min_password_length_fail) { - helpers.addResult(results, 2, 'Minimum password length of: ' + passwordPolicy.MinimumPasswordLength + ' is less than 10 characters', 'global', null, custom); + helpers.addResult(results, 2, `Minimum password length of: ${passwordPolicy.MinimumPasswordLength} is less than ${config.min_password_length_fail} characters`, 'global', null, custom); } else if (passwordPolicy.MinimumPasswordLength < config.min_password_length_warn) { - helpers.addResult(results, 1, 'Minimum password length of: ' + passwordPolicy.MinimumPasswordLength + ' is less than 14 characters', 'global', null, custom); + helpers.addResult(results, 1, `Minimum password length of: ${passwordPolicy.MinimumPasswordLength} is less than ${config.min_password_length_warn} characters`, 'global', null, custom); } else { - helpers.addResult(results, 0, 'Minimum password length of: ' + passwordPolicy.MinimumPasswordLength + ' is suitable', 'global', null, custom); + helpers.addResult(results, 0, `Minimum password length of: ${passwordPolicy.MinimumPasswordLength} is suitable`, 'global', null, custom); } callback(null, results, source); diff --git a/plugins/aws/iam/minPasswordLength.spec.js b/plugins/aws/iam/minPasswordLength.spec.js new file mode 100644 index 000000000..210286538 --- /dev/null +++ b/plugins/aws/iam/minPasswordLength.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +const minPasswordLength = require('./minPasswordLength'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 16, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 90 + }, + { + "MinimumPasswordLength": 12, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 210 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 400 + } +]; + +const createCache = (policy) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: policy + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error getting account password policies' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error getting account password policies' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('minPasswordLength', function () { + describe('run', function () { + it('should PASS if minimum password length is suitable', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + var settings = { + min_password_length_fail: 10, + min_password_length_warn: 14 + }; + minPasswordLength.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if minimum password length is less than 14 characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + var settings = { + min_password_length_fail: 10, + min_password_length_warn: 14 + }; + minPasswordLength.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if minimum password length is less than 10 characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[2]); + var settings = { + min_password_length_fail: 10, + min_password_length_warn: 14 + }; + minPasswordLength.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if password policy does not specify a minimum password length', function (done) { + const cache = createCache([]); + minPasswordLength.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + minPasswordLength.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + minPasswordLength.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + minPasswordLength.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/noUserIamPolicies.js b/plugins/aws/iam/noUserIamPolicies.js index eba2abb47..f188c994e 100644 --- a/plugins/aws/iam/noUserIamPolicies.js +++ b/plugins/aws/iam/noUserIamPolicies.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'No User IAM Policies', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures IAM policies are not connected directly to IAM users', more_info: 'To reduce management complexity, IAM permissions should only be assigned to roles and groups. Users can then be added to those groups. Policies should not be applied directly to a user.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#use-groups-for-permissions', @@ -12,6 +14,7 @@ module.exports = { compliance: { cis1: '1.16 Ensure IAM policies are attached only to groups or roles' }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser','iam:AttachUserPolicy','iam:DetachUserPolicy','iam:PutUserPolicy','iam:DeleteUserPolicy'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/noUserIamPolicies.spec.js b/plugins/aws/iam/noUserIamPolicies.spec.js new file mode 100644 index 000000000..18445c105 --- /dev/null +++ b/plugins/aws/iam/noUserIamPolicies.spec.js @@ -0,0 +1,236 @@ +var expect = require('chai').expect; +const noUserIamPolicies = require('./noUserIamPolicies'); + +const listUsers = [ + { + "Path": "/", + "UserName": "cloudexploit", + "UserId": "AIDAYE32SRU57PAYVNPEI", + "Arn": "arn:aws:iam::111122223333:user/cloudexploit", + "CreateDate": "2020-09-12T16:58:32Z", + "PasswordLastUsed": "2020-11-14T18:51:16Z" + } +]; + +const listUserPolicies = [ + { + "PolicyNames": [] + }, + { + "PolicyNames": [ + "CustomIAMFull", + "CutomIAMLimited" + ] + }, +]; + +const listAttachedUserPolicies = [ + + { + "AttachedPolicies": [] + }, + { + "AttachedPolicies": [ + { + "PolicyName": "IAMFullAccess", + "PolicyArn": "arn:aws:iam::aws:policy/IAMFullAccess" + }, + { + "PolicyName": "AmazonS3FullAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AmazonS3FullAccess" + }, + { + "PolicyName": "AdministratorAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AdministratorAccess" + }, + { + "PolicyName": "IAMUserChangePassword", + "PolicyArn": "arn:aws:iam::aws:policy/IAMUserChangePassword" + }, + { + "PolicyName": "AWSBillingReadOnlyAccess", + "PolicyArn": "arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess" + } + ] + } +]; + +const createCache = (users, attachedPolicies, userPolicies) => { + if(users && users.length && users[0].UserName) var username = users[0].UserName; + + return { + iam:{ + listUsers: { + 'us-east-1': { + data: users + }, + }, + listAttachedUserPolicies: { + 'us-east-1': { + [username]: { + data: attachedPolicies + }, + }, + }, + listUserPolicies: { + 'us-east-1': { + [username]: { + data: userPolicies + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + + return { + iam:{ + listUsers: { + 'us-east-1': { + err: { + message: 'error list users' + }, + }, + }, + }, + }; +}; + +const createAttachedPoliciesErrorCache = (users) => { + if(users && users.length && users[0].UserName) var username = users[0].UserName; + + return { + iam:{ + listUsers: { + 'us-east-1': { + data: users + }, + }, + listAttachedUserPolicies: { + 'us-east-1': { + [username]: { + err: { + message: 'error listing attached user policies' + }, + }, + }, + }, + listUserPolicies: { + 'us-east-1': { + [username]: { + err: { + message: 'error listing user policies' + }, + }, + }, + }, + }, + }; +}; + +const createUserPoliciesErrorCache = (users, attachedPolicies) => { + if(users && users.length && users[0].UserName) var username = users[0].UserName; + + return { + iam:{ + listUsers: { + 'us-east-1': { + data: users + }, + }, + listAttachedUserPolicies: { + 'us-east-1': { + [username]: { + data: attachedPolicies + } + }, + }, + listUserPolicies: { + 'us-east-1': { + [username]: { + err: { + message: 'error listing user policies' + }, + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + listUsers: { + 'us-east-1': null + }, + listAttachedUserPolicies: { + 'us-east-1': null + }, + listUserPolicies: { + 'us-east-1': null + }, + }, + }; +}; + +describe('noUserIamPolicies', function () { + describe('run', function () { + it('should PASS if user is using attached or inline policies', function (done) { + const cache = createCache([listUsers[0]], listAttachedUserPolicies[0], listUserPolicies[0]); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if user is using attached or inline policies', function (done) { + const cache = createCache([listUsers[0]], listAttachedUserPolicies[1], listUserPolicies[1]); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no user accounts found', function (done) { + const cache = createCache([]); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list users', function (done) { + const cache = createErrorCache(); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached policies for user', function (done) { + const cache = createAttachedPoliciesErrorCache([listUsers[0]]); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list user policies', function (done) { + const cache = createUserPoliciesErrorCache([listUsers[0]], listAttachedUserPolicies[0]); + noUserIamPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordExpiration.js b/plugins/aws/iam/passwordExpiration.js index 39e620b67..528985e3e 100644 --- a/plugins/aws/iam/passwordExpiration.js +++ b/plugins/aws/iam/passwordExpiration.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Expiration', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy enforces a password expiration', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,14 @@ module.exports = { remediation_description: 'The password policy for password expiration will be set to true.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + passwordExpirationCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -18,6 +28,19 @@ module.exports = { 'password expirations enforces this policy.', cis1: '1.11 Ensure IAM password policy expires passwords within 90 days or less' }, + asl: { + conditions: [ + { + service: 'iam', + api: 'getAccountPasswordPolicy', + property: 'MaxPasswordAge', + transform: 'INTEGER', + op: 'GT', + value: 90 + } + ] + }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var results = []; @@ -30,11 +53,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordExpiration.spec.js b/plugins/aws/iam/passwordExpiration.spec.js new file mode 100644 index 000000000..3944e92f9 --- /dev/null +++ b/plugins/aws/iam/passwordExpiration.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +const passwordExpiration = require('./passwordExpiration'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 400 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordExpiration', function () { + describe('run', function () { + it('should PASS if password expiration is suitable', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if password expiration is greater than 90 days', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if password expiration is greater than 180 days', function (done) { + const cache = createCache(getAccountPasswordPolicy[2]); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if password expiration policy is not set to expire passwords', function (done) { + const cache = createCache([]); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordExpiration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordPolicyExists.js b/plugins/aws/iam/passwordPolicyExists.js new file mode 100644 index 000000000..ca00994cc --- /dev/null +++ b/plugins/aws/iam/passwordPolicyExists.js @@ -0,0 +1,42 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Password Policy Exists', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that password policy is set for IAM users.', + more_info: 'You can set a custom password policy on your AWS account to specify login password complexity requirements and mandatory rotation periods for your IAM users\' passwords.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html', + recommended_action: 'Create a password policy under account settings in IAM', + apis: ['IAM:getAccountPasswordPolicy'], + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var getAccountPasswordPolicy = helpers.addSource(cache, source, + ['iam', 'getAccountPasswordPolicy', region]); + + if (!getAccountPasswordPolicy) return callback(null, results, source); + + // Handle special case errors + if (getAccountPasswordPolicy.err && + getAccountPasswordPolicy.err.code && + getAccountPasswordPolicy.err.code === 'NoSuchEntity') { + helpers.addResult(results, 2, 'Account does not have a password policy'); + return callback(null, results, source); + } + + if (getAccountPasswordPolicy.err || !getAccountPasswordPolicy.data) { + helpers.addResult(results, 3, + 'Unable to query for password policy status: ' + helpers.addError(getAccountPasswordPolicy)); + return callback(null, results, source); + } + + helpers.addResult(results, 0, 'Account has a password policy'); + callback(null, results, source); + }, +}; \ No newline at end of file diff --git a/plugins/aws/iam/passwordPolicyExists.spec.js b/plugins/aws/iam/passwordPolicyExists.spec.js new file mode 100644 index 000000000..db307a640 --- /dev/null +++ b/plugins/aws/iam/passwordPolicyExists.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const passwordPolicyExists = require('./passwordPolicyExists'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordPolicyExists', function () { + describe('run', function () { + it('should PASS if password policy exists', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordPolicyExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if account does not have a password policy', function (done) { + const cache = createErrorCodeCache(); + passwordPolicyExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordPolicyExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordPolicyExists.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordRequiresLowercase.js b/plugins/aws/iam/passwordRequiresLowercase.js index eadea430f..dac1880d7 100644 --- a/plugins/aws/iam/passwordRequiresLowercase.js +++ b/plugins/aws/iam/passwordRequiresLowercase.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Requires Lowercase', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one lowercase letter', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,14 @@ module.exports = { remediation_description: 'The password policy for password requires lowercase will be set to true.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + passwordRequiresLowercaseCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -18,6 +28,7 @@ module.exports = { 'requirements enforces this policy.', cis1: '1.6 Ensure IAM password policy require at least one lowercase letter' }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var results = []; @@ -30,11 +41,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordRequiresLowercase.spec.js b/plugins/aws/iam/passwordRequiresLowercase.spec.js new file mode 100644 index 000000000..f7189b47d --- /dev/null +++ b/plugins/aws/iam/passwordRequiresLowercase.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +const passwordRequiresLowercase = require('./passwordRequiresLowercase'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordRequiresLowercase', function () { + describe('run', function () { + it('should PASS if password policy requires lowercase characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordRequiresLowercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if password policy does not require lowercase characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + passwordRequiresLowercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordRequiresLowercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordRequiresLowercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordRequiresLowercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordRequiresNumbers.js b/plugins/aws/iam/passwordRequiresNumbers.js index d0384f3cf..e03791934 100644 --- a/plugins/aws/iam/passwordRequiresNumbers.js +++ b/plugins/aws/iam/passwordRequiresNumbers.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Requires Numbers', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires the use of numbers', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,14 @@ module.exports = { remediation_description: 'The password policy for password requires numbers will be set to true.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + passwordRequiresNumbersCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -18,6 +28,7 @@ module.exports = { 'requirements enforces this policy.', cis1: '1.8 Ensure IAM password policy require at least one number' }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var results = []; @@ -30,11 +41,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordRequiresNumbers.spec.js b/plugins/aws/iam/passwordRequiresNumbers.spec.js new file mode 100644 index 000000000..d041f5bc7 --- /dev/null +++ b/plugins/aws/iam/passwordRequiresNumbers.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +const passwordRequiresNumbers = require('./passwordRequiresNumbers'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordRequiresNumbers', function () { + describe('run', function () { + it('should PASS if password policy requires numbers', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordRequiresNumbers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if password policy does not require numbers', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + passwordRequiresNumbers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordRequiresNumbers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordRequiresNumbers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordRequiresNumbers.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordRequiresSymbols.js b/plugins/aws/iam/passwordRequiresSymbols.js index 6969c6c14..e12746b09 100644 --- a/plugins/aws/iam/passwordRequiresSymbols.js +++ b/plugins/aws/iam/passwordRequiresSymbols.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Requires Symbols', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires the use of symbols', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,14 @@ module.exports = { remediation_description: 'The password policy for password requires symbols will be set to true.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + passwordRequiresSymbolsCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -18,6 +28,8 @@ module.exports = { 'requirements enforces this policy.', cis1: '1.7 Ensure IAM password policy require at least one symbol' }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], + run: function(cache, settings, callback) { var results = []; @@ -30,11 +42,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordRequiresSymbols.spec.js b/plugins/aws/iam/passwordRequiresSymbols.spec.js new file mode 100644 index 000000000..cf67e7636 --- /dev/null +++ b/plugins/aws/iam/passwordRequiresSymbols.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +const passwordRequiresSymbols = require('./passwordRequiresSymbols'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordRequiresSymbols', function () { + describe('run', function () { + it('should PASS if password policy requires symbols', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordRequiresSymbols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if password policy does not require symbols', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + passwordRequiresSymbols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordRequiresSymbols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordRequiresSymbols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordRequiresSymbols.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordRequiresUppercase.js b/plugins/aws/iam/passwordRequiresUppercase.js index 32c126d2a..46007c9aa 100644 --- a/plugins/aws/iam/passwordRequiresUppercase.js +++ b/plugins/aws/iam/passwordRequiresUppercase.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Requires Uppercase', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one uppercase letter', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,14 @@ module.exports = { remediation_description: 'The password policy for password requires uppercase will be set to true.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + passwordRequiresUppercaseCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -18,6 +28,7 @@ module.exports = { 'requirements enforces this policy.', cis1: '1.5 Ensure IAM password policy requires at least one uppercase letter' }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var results = []; @@ -30,11 +41,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordRequiresUppercase.spec.js b/plugins/aws/iam/passwordRequiresUppercase.spec.js new file mode 100644 index 000000000..d2661343e --- /dev/null +++ b/plugins/aws/iam/passwordRequiresUppercase.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +const passwordRequiresUppercase = require('./passwordRequiresUppercase'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordRequiresUppercase', function () { + describe('run', function () { + it('should PASS if password policy requires uppercase characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + passwordRequiresUppercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if password policy does not require uppercase characters', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + passwordRequiresUppercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordRequiresUppercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordRequiresUppercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordRequiresUppercase.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/passwordReusePrevention.js b/plugins/aws/iam/passwordReusePrevention.js index 0a086b067..ab6d75b25 100644 --- a/plugins/aws/iam/passwordReusePrevention.js +++ b/plugins/aws/iam/passwordReusePrevention.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Password Reuse Prevention', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy prevents previous password reuse', more_info: 'A strong password policy enforces minimum length, expirations, reuse, and symbol usage', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -11,6 +13,20 @@ module.exports = { remediation_description: 'The password policy for password reuse prevention will be set to the value set by the user. Otherwise, it will default to 24.', remediation_min_version: '202006221808', apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + maxPreviousPasswords: { + name: '(Optional) Minimum Previous Passwords', + description: 'The minimum number of previous passwords to allow in the account policy 1-24', + regex: '^([1-9]|[0-1][0-9]|2[0-4])$', + required: false + }, + passwordReusePreventionCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, compliance: { @@ -32,6 +48,7 @@ module.exports = { default: 24 } }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], run: function(cache, settings, callback) { var config = { @@ -51,11 +68,11 @@ module.exports = { if (!getAccountPasswordPolicy) return callback(null, results, source); - // Handle special case errors + // Handle default password policy if (getAccountPasswordPolicy.err && getAccountPasswordPolicy.err.code && getAccountPasswordPolicy.err.code === 'NoSuchEntity') { - helpers.addResult(results, 2, 'Account does not have a password policy'); + helpers.addResult(results, 2, 'Account has Default password policy'); return callback(null, results, source); } diff --git a/plugins/aws/iam/passwordReusePrevention.spec.js b/plugins/aws/iam/passwordReusePrevention.spec.js new file mode 100644 index 000000000..9b45e2c22 --- /dev/null +++ b/plugins/aws/iam/passwordReusePrevention.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +const passwordReusePrevention = require('./passwordReusePrevention'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 90, + "PasswordReusePrevention": 36 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 210, + "PasswordReusePrevention": 10 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 400, + "PasswordReusePrevention": 3 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('passwordReusePrevention', function () { + describe('run', function () { + it('should PASS if maximum password reuse is suitable', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + var settings = { + password_reuse_fail: 5, + password_reuse_warn: 24 + }; + passwordReusePrevention.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if maximum password reuse is less than warn limit', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + var settings = { + password_reuse_fail: 5, + password_reuse_warn: 24 + }; + passwordReusePrevention.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if maximum password reuse is less than fail limit', function (done) { + const cache = createCache(getAccountPasswordPolicy[2]); + var settings = { + password_reuse_fail: 5, + password_reuse_warn: 24 + }; + passwordReusePrevention.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if password policy does not prevent reusing previous passwords', function (done) { + const cache = createCache([]); + passwordReusePrevention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + passwordReusePrevention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + passwordReusePrevention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + passwordReusePrevention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/policyAllowsToChangePassword.js b/plugins/aws/iam/policyAllowsToChangePassword.js new file mode 100644 index 000000000..0cd782988 --- /dev/null +++ b/plugins/aws/iam/policyAllowsToChangePassword.js @@ -0,0 +1,79 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Password Policy Allows To Change Password', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure IAM password policy allows users to change their passwords.', + more_info: 'Password policy should allow users to rotate their passwords as a security best practice.', + link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', + recommended_action: 'Update the password policy for users to change their passwords', + apis: ['IAM:getAccountPasswordPolicy'], + remediation_description: 'The password policy for password to be changed by users will be set to true.', + remediation_min_version: '202209040720', + apis_remediate: ['IAM:getAccountPasswordPolicy'], + remediation_inputs: { + allowUsersToChangePasswordsCreatePolicy: { + name: 'Create Password Policy', + description: 'Whether to create a new password policy if one does not already exist.', + regex: '^(true|false)$', + required: false + } + }, + actions: {remediate: ['IAM:updateAccountPasswordPolicy'], rollback: ['IAM:updateAccountPasswordPolicy']}, + permissions: {remediate: ['iam:UpdateAccountPasswordPolicy'], rollback: ['iam:UpdateAccountPasswordPolicy']}, + compliance: { + pci: 'PCI requires a strong password policy. Setting IAM password ' + + 'requirements enforces this policy.', + cis1: '1.6 Ensure IAM password policy allows users to change their passwords' + }, + realtime_triggers: ['iam:UpdateAccountPasswordPolicy','iam:DeleteAccountPasswordPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var getAccountPasswordPolicy = helpers.addSource(cache, source, + ['iam', 'getAccountPasswordPolicy', region]); + + if (!getAccountPasswordPolicy) return callback(null, results, source); + + // Handle default password policy + if (getAccountPasswordPolicy.err && + getAccountPasswordPolicy.err.code && + getAccountPasswordPolicy.err.code === 'NoSuchEntity') { + helpers.addResult(results, 2, 'Account has Default password policy'); + return callback(null, results, source); + } + + if (getAccountPasswordPolicy.err || !getAccountPasswordPolicy.data) { + helpers.addResult(results, 3, + 'Unable to query for password policy status: ' + helpers.addError(getAccountPasswordPolicy)); + return callback(null, results, source); + } + + var passwordPolicy = getAccountPasswordPolicy.data; + + if (!passwordPolicy.AllowUsersToChangePassword) { + helpers.addResult(results, 2, 'Password policy does not allow users to change their password'); + } else { + helpers.addResult(results, 0, 'Password policy allow users to change their password'); + } + + callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + var pluginName = 'policyAllowsToChangePassword'; + var passwordKey = 'AllowUsersToChangePassword'; + + var input = {}; + input[passwordKey] = true; + + helpers.remediatePasswordPolicy(putCall, pluginName, remediation_file, passwordKey, config, cache, settings, resource, input, callback); + } +}; \ No newline at end of file diff --git a/plugins/aws/iam/policyAllowsToChangePassword.spec.js b/plugins/aws/iam/policyAllowsToChangePassword.spec.js new file mode 100644 index 000000000..44400d4ce --- /dev/null +++ b/plugins/aws/iam/policyAllowsToChangePassword.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +const policyAllowsToChangePassword = require('./policyAllowsToChangePassword'); + +const getAccountPasswordPolicy = [ + { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 80 + }, + { + "MinimumPasswordLength": 8, + "RequireSymbols": false, + "RequireNumbers": false, + "RequireUppercaseCharacters": false, + "RequireLowercaseCharacters": false, + "AllowUsersToChangePassword": false, + "ExpirePasswords": true, + "MaxPasswordAge": 120 + } +]; + +const createCache = (report) => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createErrorCodeCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': { + err: { + code: 'NoSuchEntity', + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + getAccountPasswordPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('policyAllowsToChangePassword', function () { + describe('run', function () { + it('should PASS if Password policy allow users to change their password', function (done) { + const cache = createCache(getAccountPasswordPolicy[0]); + policyAllowsToChangePassword.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Password policy does not allow users to change their password', function (done) { + const cache = createCache(getAccountPasswordPolicy[1]); + policyAllowsToChangePassword.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Account has Default password policy', function (done) { + const cache = createErrorCodeCache(); + policyAllowsToChangePassword.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to get account password policy', function (done) { + const cache = createErrorCache(); + policyAllowsToChangePassword.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if get account password policy response not found', function (done) { + const cache = createNullCache(); + policyAllowsToChangePassword.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/rolePolicyUnusedServices.js b/plugins/aws/iam/rolePolicyUnusedServices.js new file mode 100644 index 000000000..bfca0f39c --- /dev/null +++ b/plugins/aws/iam/rolePolicyUnusedServices.js @@ -0,0 +1,493 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + + +module.exports = { + title: 'IAM Role Policy Unused Services', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that IAM role policies are scoped properly as to not provide access to unused AWS services.', + more_info: 'IAM role policies should only contain actions for resource types which are being used in your account i.e. dynamodb:ListTables permission should only be given when there are DynamoDB tables to adhere to security best practices and to follow principal of least-privilege.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html', + recommended_action: 'Ensure that all IAM roles are scoped to specific services and resource types.', + apis: ['IAM:listRoles', 'IAM:listRolePolicies', 'IAM:listAttachedRolePolicies', 'IAM:listPolicies', + 'IAM:getPolicy', 'IAM:getPolicyVersion', 'IAM:getRolePolicy', 'ConfigService:describeConfigurationRecorderStatus', 'ConfigService:getDiscoveredResourceCounts','IAM:getRole'], + settings: { + iam_role_policies_ignore_path: { + name: 'IAM Role Policies Ignore Path', + description: 'A comma-separated list indicating role paths which should PASS without checking', + regex: '^[0-9A-Za-z/._-]{3,512}$', + default: '' + }, + ignore_service_specific_wildcards: { + name: 'Ignore Service Specific Wildcards', + description: 'This allows enables you to allow attached policies (inline and managed) to use service specific wildcards in Action. ' + + 'Example: Consider a role has following inline policy' + + `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cognito-sync:*", + "cognito-identity:*" + ], + "Resource": [ + "*" + ] + } + ] + }` + + 'If ignore_service_specific_wildcards is true, a PASS result will be generated. ' + + 'If ignore_service_specific_wildcards is false, a FAIL result will be generated.', + regex: '^(true|false)$', + default: 'false' + }, + ignore_identity_federation_roles: { + name: 'Ignore Identity Federation Roles', + description: 'This setting allows you to skip IdP/SAML based roles ' + + 'i.e. if for a role, all trust relationship statements have "Action" either "sts:AssumeRoleWithWebIdentity" or "sts:AssumeRoleWithSAML" '+ + 'and value for this setting is set to true, a PASS results will be generated.', + regex: '^(true|false)$', + default: 'false' + }, + ignore_aws_managed_iam_policies: { + name: 'Ignore AWS-Managed IAM Policies', + description: 'If set to true, skip AWS-managed policies attached to the role with the exception of AWS-managed AdministratorAccess policy', + regex: '^(true|false)$', + default: 'false' + }, + ignore_customer_managed_iam_policies: { + name: 'Ignore Customer-Managed IAM Policies', + description: 'If set to true, skip customer-managed policies attached to the role', + regex: '^(true|false)$', + default: 'false' + }, + iam_role_policies_ignore_tag: { + name: 'IAM Role Policies Ignore Tag', + description: 'Ignores roles that contain the provided tag. Give key-value pair i.e. env:Finance ', + regex: '^.*$', + default: '' + }, + whitelist_unused_services: { + name: 'Whitelist Unused Services', + description: 'A comma-separated list indicating which services should be ignored', + regex: '.*$', + default: '' + }, + whitelist_unused_actions_for_resources: { + name: 'Whitelist Unused Actions For Resources', + description: 'A comma-separated list indicating which actions for specific service should be ignored i.e. Ignore the following actions ec2:CancelReservedInstancesListing, ec2:AcceptVpcPeeringConnection for ec2 Service', + regex: '.*$', + default: '' + }, + iam_policy_resource_specific_wildcards: { + name: 'IAM Policy Resource Specific Wildcards', + description: 'Allows policy resources to flag based on regular expression. All the resources in IAM policy, inline or managed, will be tested against this regex and if they don\'t pass the regex, they will be flagged by the plugin.', + regex: '^.*$', + default: '^.*$', + }, + ignore_iam_policy_resource_wildcards: { + name: 'IAM Role Policies Ignore Resource Specific Wildcards', + description: 'Enable this setting to ignore resource wildcards i.e. \'"Resource": "*"\' in the IAM policy, which by default, are being flagged.', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole','iam:AttachRolePolicy','iam:DetachRolePolicy','iam:PutRolePolicy','iam:DetachRolePolicy'], + + run: function(cache, settings, callback) { + var config = { + iam_role_policies_ignore_path: settings.iam_role_policies_ignore_path || this.settings.iam_role_policies_ignore_path.default, + ignore_service_specific_wildcards: settings.ignore_service_specific_wildcards || this.settings.ignore_service_specific_wildcards.default, + ignore_identity_federation_roles: settings.ignore_identity_federation_roles || this.settings.ignore_identity_federation_roles.default, + ignore_aws_managed_iam_policies: settings.ignore_aws_managed_iam_policies || this.settings.ignore_aws_managed_iam_policies.default, + ignore_customer_managed_iam_policies: settings.ignore_customer_managed_iam_policies || this.settings.ignore_customer_managed_iam_policies.default, + iam_role_policies_ignore_tag: settings.iam_role_policies_ignore_tag || this.settings.iam_role_policies_ignore_tag.default, + whitelist_unused_services: settings.whitelist_unused_services || this.settings.whitelist_unused_services.default, + whitelist_unused_actions_for_resources: settings.whitelist_unused_actions_for_resources || this.settings.whitelist_unused_actions_for_resources.default, + iam_policy_resource_specific_wildcards: settings.iam_policy_resource_specific_wildcards || this.settings.iam_policy_resource_specific_wildcards.default, + ignore_iam_policy_resource_wildcards: settings.ignore_iam_policy_resource_wildcards || this.settings.ignore_iam_policy_resource_wildcards.default + }; + + config.ignore_service_specific_wildcards = (config.ignore_service_specific_wildcards === 'true'); + config.ignore_identity_federation_roles = (config.ignore_identity_federation_roles === 'true'); + config.ignore_aws_managed_iam_policies = (config.ignore_aws_managed_iam_policies === 'true'); + config.ignore_customer_managed_iam_policies = (config.ignore_customer_managed_iam_policies === 'true'); + config.whitelist_unused_services = config.whitelist_unused_services.replace(/\s/g, ''); + config.whitelist_unused_actions_for_resources = config.whitelist_unused_actions_for_resources.replace(/\s/g, '').toLowerCase(); + var allowedRegex = new RegExp(config.iam_policy_resource_specific_wildcards); + config.ignore_iam_policy_resource_wildcards = (config.ignore_iam_policy_resource_wildcards === 'true'); + + + var custom = helpers.isCustom(settings, this.settings); + + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + var iamRegion = helpers.defaultRegion(settings); + + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + var allResources = []; + const allServices = { + apigateway: ['stage', 'restapi', 'api'], + cloudfront: ['distribution', 'streamingdistribution'], + cloudwatch: ['alarm'], + dynamodb: ['table'], + ec2: ['volume', 'host', 'eip', 'instance', 'networkinterface', 'securitygroup', 'natgateway', 'egressonlyinternetgateway', + 'flowlog', 'transitgateway', 'vpcendpoint', 'vpcendpointservice', 'vpcpeeringconnection', 'registeredhainstance', 'launchtemplate', + 'customergateway', 'internetgateway', 'networkacl', 'routetable', 'subnet', 'vpc', 'vpcconnection', 'vpngateway'], + ecr: ['repository', 'publicrepository'], + ecs: ['cluster', 'taskdefinition', 'service'], + efs: ['filesystem', 'accesspoint'], + eks: ['cluster'], + emr: ['securityconfiguration'], + guardduty: ['detector'], + elasticsearch: ['domain'], + opensearch: ['domain'], + qldb: ['ledger'], + kinesis: ['stream', 'streamconsumer'], + redshift: ['cluster', 'clusterparametergroup', 'clustersecuritygroup', 'clustersnapshot', 'clustersubnetgroup', 'eventsubscription'], + rds: ['dbinstance', 'dbsecuritygroup', 'dbsnapshot', 'dbsubnetgroup', 'eventsubscription', 'dbcluster', 'dbclustersnapshot'], + sagemaker: ['coderepository', 'model'], + sns: ['topic'], + sqs: ['queue'], + s3: ['bucket', 'accountpublicaccessblock'], + autoscaling: ['autoscalinggroup', 'launchconfguration', 'scalingpolicy', 'scheduledaction'], + backup: ['backupplan', 'backupselection', 'backupvault', 'recoverypoint'], + acm: ['certificate'], + cloudformation: ['stack'], + cloudtrail: ['trail'], + codebuild: ['project'], + codedeploy: ['application', 'deploymentconfig', 'deploymentgroup'], + codepipeline: ['pipeline'], + config: ['resourcecompliance', 'conformancepackcompliance'], + elasticbeanstalk: ['applicstion', 'applicationversion', 'environment'], + iam: ['user', 'group', 'role', 'policy'], + kms: ['key'], + lambda: ['function'], + networkfirewall: ['firewall', 'firewallpolicy', 'rulegroup'], + secretsmanager: ['secret'], + servicecatalog: ['cloudFormationproduct', 'cloudformationprovisionedproduct', 'portfolio'], + shield: ['protection', 'protection'], + stepfunctions: ['statemachine'], + ssm: ['managedinstanceinventory', 'patchcompliance', 'associationcompliance', 'filedata'], + waf: ['ratebasedrule', 'rule', 'webacl', 'rulegroup', 'ratebasedrule', 'rule', 'webacl'], + wafv2: ['webacl', 'rulegroup', 'managedruleset', 'ipset'], + xray: ['encryptionconfig'], + elasticloadbalancing: ['loadbalancer'], + elasticloadbalancingv2: ['loadbalancer'] + }; + + async.each(regions.configservice, function(region, rcb) { + var configRecorderStatus = helpers.addSource(cache, source, + ['configservice', 'describeConfigurationRecorderStatus', region]); + + if (!configRecorderStatus) { + return rcb(); + } + + if (configRecorderStatus.err || !configRecorderStatus.data) { + helpers.addResult(results, 3, + 'Unable to query config service: ' + helpers.addError(configRecorderStatus), region); + return rcb(); + } + + if (!configRecorderStatus.data.length) { + helpers.addResult(results, 2, + 'Config service is not enabled', region); + return rcb(); + } + + if (!configRecorderStatus.data[0].recording) { + helpers.addResult(results, 2, + 'Config service is not recording', region); + return rcb(); + } + + if (!configRecorderStatus.data[0].lastStatus || + (configRecorderStatus.data[0].lastStatus.toUpperCase() !== 'SUCCESS' && + configRecorderStatus.data[0].lastStatus.toUpperCase() !== 'PENDING')) { + helpers.addResult(results, 2, + 'Config Service is configured, and recording, but not delivering properly', region); + return rcb(); + } + + var discoveredResources = helpers.addSource(cache, source, + ['configservice', 'getDiscoveredResourceCounts', region]); + + if (discoveredResources.err || !discoveredResources.data) { + helpers.addResult(results, 3, + 'Unable to query for Discovered Resources: ' + helpers.addError(discoveredResources)); + return rcb(); + } + + allResources.push(...discoveredResources.data); + rcb(); + }, function() { + if (!allResources.length) { + helpers.addResult(results, 0, 'No Discovered Resources found.'); + return callback(null, results, source); + } + + allResources = allResources.reduce((result, resource) => { + let arr = resource.resourceType.split(':'); + if (arr.length && arr.length >= 5) { + let service = arr[2].toLowerCase(); + let subService = arr[4].toLowerCase(); + result[service] = result[service] || []; + if (resource.count > 0 && (allServices[service] && allServices[service].includes(subService))) { + result[service].push(subService); + } + return result; + } + }, {}); + if (!allResources) allResources = {}; + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', iamRegion]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + 'Unable to query for IAM roles: ' + helpers.addError(listRoles)); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + async.each(listRoles.data, function(role, cb){ + if (!role.RoleName) return cb(); + + // Skip roles with user-defined paths + if (config.iam_role_policies_ignore_path && + config.iam_role_policies_ignore_path.length && + role.Path && + role.Path.indexOf(config.iam_role_policies_ignore_path) > -1) { + return cb(); + } + + // Get role details + var getRole = helpers.addSource(cache, source, + ['iam', 'getRole', iamRegion, role.RoleName]); + + if (!getRole || getRole.err || !getRole.data || !getRole.data.Role) { + helpers.addResult(results, 3, + 'Unable to query for IAM role details: ' + role.RoleName + ': ' + helpers.addError(getRole), 'global', role.Arn); + return cb(); + } + + //Skip roles with user defined tags + if (config.iam_role_policies_ignore_tag && config.iam_role_policies_ignore_tag.length) { + if (config.iam_role_policies_ignore_tag.split(':').length == 2){ + var key = config.iam_role_policies_ignore_tag.split(':')[0].trim(); + var value= new RegExp(config.iam_role_policies_ignore_tag.split(':')[1].trim()); + if (getRole.data.Role.Tags && getRole.data.Role.Tags.length){ + if (getRole.data.Role.Tags.find(tag => + tag.Key == key && value.test(tag.Value))) return cb(); + } + } + } + + if (config.ignore_identity_federation_roles && + helpers.hasFederatedUserRole(helpers.normalizePolicyDocument(role.AssumeRolePolicyDocument))) { + helpers.addResult(results, 0, + 'Role is federated user role', + 'global', role.Arn, custom); + return cb(); + } + + // Get managed policies attached to role + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', iamRegion, role.RoleName]); + + // Get inline policies attached to role + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', iamRegion, role.RoleName]); + + var getRolePolicy = helpers.addSource(cache, source, + ['iam', 'getRolePolicy', iamRegion, role.RoleName]); + + if (!listAttachedRolePolicies || listAttachedRolePolicies.err) { + helpers.addResult(results, 3, + 'Unable to query for IAM attached policy for role: ' + role.RoleName + ': ' + helpers.addError(listAttachedRolePolicies), 'global', role.Arn); + return cb(); + } + + if (!listRolePolicies || listRolePolicies.err) { + helpers.addResult(results, 3, + 'Unable to query for IAM role policy for role: ' + role.RoleName + ': ' + helpers.addError(listRolePolicies), 'global', role.Arn); + return cb(); + } + + var policyFailures = []; + var roleFailures = []; + + // See if role has admin managed policy + if (listAttachedRolePolicies.data && + listAttachedRolePolicies.data.AttachedPolicies) { + + for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { + if (policy.PolicyArn === managedAdminPolicy) { + roleFailures.push('Role has managed AdministratorAccess policy'); + break; + } + + if (config.ignore_aws_managed_iam_policies && new RegExp(`^arn:${awsOrGov}:iam::aws:.*`).test(policy.PolicyArn)) continue; + + if (config.ignore_customer_managed_iam_policies && new RegExp(`^arn:${awsOrGov}:iam::[0-9]{12}:.*`).test(policy.PolicyArn)) continue; + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', iamRegion, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', iamRegion, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + for (let statement of statements) { + if (statement.Action && statement.Action.length) { + + for (let action of statement.Action) { + if (config.whitelist_unused_actions_for_resources.includes(action.toLowerCase())) continue; + let service = action.split(':')[0] ? action.split(':')[0].toLowerCase() : ''; + let resourceAction = action.split(':')[1] ? action.split(':')[1].toLowerCase() : ''; + + if (allServices[service] && !config.whitelist_unused_services.includes(service)) { + for (let supportedResource of allServices[service]) { + if (resourceAction.includes(supportedResource)) { + if (!allResources[service] || !allResources[service].includes(supportedResource)) { + if (policyFailures.indexOf(action) === -1) policyFailures.push(action); + } + } + } + } + } + } + } + + addRoleFailures(roleFailures, statements, 'managed', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } + } + } + } + + if (listRolePolicies.data && + listRolePolicies.data.PolicyNames) { + for (var p in listRolePolicies.data.PolicyNames) { + var policyName = listRolePolicies.data.PolicyNames[p]; + + if (getRolePolicy && + getRolePolicy[policyName] && + getRolePolicy[policyName].data && + getRolePolicy[policyName].data.PolicyDocument) { + var statements = getRolePolicy[policyName].data.PolicyDocument; + + if (!statements) break; + + for (let statement of statements) { + if ((statement.Action && statement.Action.length && statement.Action[0] === '*') || + (statement.Resource && statement.Resource.length && statement.Resource[0] === '*')) { + continue; + } + + if (statement.Action && statement.Action.length && + statement.Resource && statement.Resource.length) { + let service = statement.Resource[0].includes('arn') ? statement.Resource[0].split(':')[2].toLowerCase() : + statement.Action[0].split(':')[1].toLowerCase(); + if (statement.Action.length > 1 || statement.Action[0] !== '*') { + for (let action of statement.Action) { + if (config.whitelist_unused_actions_for_resources.includes(action)) continue; + let resourceAction = action.split(':')[1].toLowerCase(); + + if (allServices[service] && !config.whitelist_unused_services.includes(service)) { + for (let supportedResource of allServices[service]) { + if (resourceAction.includes(supportedResource)) { + if (!allResources[service] || !allResources[service].includes(supportedResource)) { + if (policyFailures.indexOf(action) === -1) policyFailures.push(action); + } + } + } + } + } + } + } + } + + addRoleFailures(roleFailures, statements, 'inline', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } + } + } + + if (policyFailures.length || roleFailures.length) { + let failureMsg = policyFailures.length ? 'Role policies contain actions for resource types which are not in use: ' + + '[ ' + policyFailures.join(', ') + ' ]' + '\r\n' + roleFailures.join(', ') : roleFailures.join(', '); + helpers.addResult(results, 2, failureMsg, 'global', role.Arn, custom); + } else { + helpers.addResult(results, 0, + 'Role does not have overly-permissive policy', + 'global', role.Arn, custom); + } + + cb(); + }, function() { + callback(null, results, source); + }); + }); + } +}; + +function addRoleFailures(roleFailures, statements, policyType, ignoreServiceSpecific, regResource, ignoreResourceSpecific) { + for (var statement of statements) { + if (statement.Effect === 'Allow' && + !statement.Condition) { + let failMsg; + if (statement.Action && + statement.Action.indexOf('*') > -1 && + statement.Resource && + statement.Resource.indexOf('*') > -1) { + failMsg = `Role ${policyType} policy allows all actions on all resources`; + } else if (statement.Action.indexOf('*') > -1) { + failMsg = `Role ${policyType} policy allows all actions on selected resources`; + } else if (!ignoreResourceSpecific && statement.Resource && statement.Resource == '*'){ + failMsg = `Role ${policyType} policy allows actions on all resources`; + } else if (!ignoreServiceSpecific && statement.Action && statement.Action.length) { + // Check each action for wildcards + let wildcards = []; + for (var a in statement.Action) { + if (/^.+:[a-zA-Z]?\*.?$/.test(statement.Action[a])) { + wildcards.push(statement.Action[a]); + } + } + if (wildcards.length) failMsg = `Role ${policyType} policy allows wildcard actions: ${wildcards.join(', ')}`; + } else if (statement.Resource && statement.Resource.length) { + // Check each resource for wildcard + let wildcards = []; + for (var resource of statement.Resource) { + if (!regResource.test(resource)) { + wildcards.push(resource); + } + } + if (wildcards.length) failMsg = `Role ${policyType} policy does not match provided regex: ${wildcards.join(', ')}`; + } + + if (failMsg && roleFailures.indexOf(failMsg) === -1) roleFailures.push(failMsg); + } + } +} diff --git a/plugins/aws/iam/rolePolicyUnusedServices.spec.js b/plugins/aws/iam/rolePolicyUnusedServices.spec.js new file mode 100644 index 000000000..99aea933b --- /dev/null +++ b/plugins/aws/iam/rolePolicyUnusedServices.spec.js @@ -0,0 +1,662 @@ +const expect = require('chai').expect; +var rolePolicyUnusedServices = require('./rolePolicyUnusedServices'); + + +const listRoles = [ + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "sts:AssumeRoleWithSAML", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-2", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-2", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "sts:AssumeRoleWithSAML", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "CreateDate": "2020-11-21T23:56:33Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::000011112222:root" + }, + "Action": "ec2:DescribeTransitGatewayRouteTables", + "Condition": {} + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-3", + "RoleId": "AROASZ433I6EC5DZRCA5R", + "Arn": "arn:aws:iam::123456789:role/ecs-role", + "CreateDate": "2022-11-25T12:29:26+00:00", + "AssumeRolePolicyDocument": { + "Version": "2008-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "ecs.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Description": "Allows ECS to create and manage AWS resources on your behalf.", + "MaxSessionDuration": 3600 + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "S3-Full" + ] + }, + { + "PolicyNames": [ + "All-Action-Resources" + ] + }, + { + "PolicyNames": [] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f7d427cc-970b-47af-9b7d-3e06121f83da' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'EC2-Full', + "PolicyArn": 'arn:aws:iam::000011112222:policy/EC2-Full' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'EC2-Wildcard', + "PolicyArn": 'arn:aws:iam::000011112222:policy/EC2-Wildcard' + } + ], + "IsTruncated": false + }, + { + "AttachedPolicies": [ + { + "PolicyName": "AmazonEC2ContainerServiceRole", + "PolicyArn": "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole" + } + ] + } +]; + +const getRolePolicy = [ + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-Full', + "PolicyDocument": [ + { + Sid: 'VisualEditor0', + Effect: 'Allow', + Action: [ 's3:*' ], + Resource: [ '*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-WildCard', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ 's3:g*' ], + Resource: [ '*' ] + } + ] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'S3-Limited', + "PolicyDocument": [ + { + Sid: 'AWSCloudTrailCreateLogStream2014110', + Effect: 'Allow', + Action: [ 'logs:CreateLogStream' ], + Resource: [ 'arn:aws:logs:us-east-1:193063503752:log-group:aws-cloudtrail-logs-193063503752-432bdd08:log-stream:193063503752_CloudTrail_us-east-1*' ] + }, + { + Sid: 'AWSCloudTrailPutLogEvents20141101', + Effect: 'Allow', + Action: [ 'logs:PutLogEvents' ], + Resource: [ 'arn:aws:logs:us-east-1:193063503752:log-group:aws-cloudtrail-logs-193063503752-432bdd08:log-stream:193063503752_CloudTrail_us-east-1*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'All-Action-Resources', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ '*' ], + Resource: [ '*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'All-Actions', + "PolicyDocument": [ + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ '*' ], + Resource: [ 'arn:aws:s3:::*' ] + }] + }, + { + "RoleName": 'test-role-2', + "PolicyName": 'All-Actions', + "PolicyDocument": [ + { + Sid: 'VisualEditor0', + Effect: 'Allow', + Action: [ 's3:putObject', 'ec2:CreateFleet' ], + Resource: [ 'arn:aws:s3:::*' ] }, + { + Sid: 'VisualEditor1', + Effect: 'Allow', + Action: [ 'ec2:CreateFleet' ], + Resource: [ 'arn:aws:ec2:us-east-1:193063503752:instance/*' ] + }] + } +]; + +const getPolicy = [ + { + "Policy": { + "PolicyName": 'EC2-Wildcard', + "PolicyId": 'ANPAYE32SRU57UHNCIGCT', + "Arn": 'arn:aws:iam::000011112222:policy/EC2-Wildcard', + "Path": '/', + "DefaultVersionId": 'v5', + "AttachmentCount": 2, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true + } + }, + { + "Policy": { + "PolicyName": "AmazonEC2ContainerServiceRole", + "PolicyId": "ANPAJO53W2XHNACG7V77Q", + "Arn": "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole", + "Path": "/service-role/", + "DefaultVersionId": "v2", + "AttachmentCount": 1, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true, + "Description": "Default policy for Amazon ECS service role.", + "CreateDate": "2015-04-09T16:14:19+00:00", + "UpdateDate": "2016-08-11T13:08:01+00:00", + "Tags": [] + } + + } +]; + +const getPolicyVersion = [ + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22s3%3A%2A%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22ec2%3A%2A%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22arn%3Aaws%3Aec2%3Aus-east-1%3A193063503752%3Ainstance%2Fi-0ed34b9c39ebd03ba%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v5', + } + }, + { + "PolicyVersion": { + "Document" : '%7B%0A%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%22ec2%3AAuthorizeSecurityGroupIngress%22%2C%0A%20%20%20%20%20%20%20%20%22ec2%3ADescribe%2A%22%2C%0A%20%20%20%20%20%20%20%20%22elasticloadbalancing%3ADeregisterInstancesFromLoadBalancer%22%2C%0A%20%20%20%20%20%20%20%20%22elasticloadbalancing%3ADeregisterTargets%22%2C%0A%20%20%20%20%20%20%20%20%22elasticloadbalancing%3ADescribe%2A%22%2C%0A%20%20%20%20%20%20%20%20%22elasticloadbalancing%3ARegisterInstancesWithLoadBalancer%22%2C%0A%20%20%20%20%20%20%20%20%22elasticloadbalancing%3ARegisterTargets%22%0A%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%22Resource%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22arn%3Aaws%3Alogs%3Aus-east-1%3A193063503752%3Alog-group%3A%2Faws%2Flambda%2Ftest_lambda_core%3A%2A%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v2', + } + } +]; + +const configStatus = [ + { + name: 'default', + lastStartTime: '2022-05-30T16:15:43.358Z', + lastStopTime: '2022-05-30T16:12:18.651Z', + recording: true, + lastStatus: 'SUCCESS', + lastStatusChangeTime: '2022-05-31T05:16:02.486Z' + }, + { + name: 'default', + lastStartTime: '2022-05-30T16:15:43.358Z', + lastStopTime: '2022-05-30T16:12:18.651Z', + recording: false, + lastStatus: 'SUCCESS', + lastStatusChangeTime: '2022-05-31T05:16:02.486Z' + }, + { + name: 'default', + lastStartTime: '2022-05-30T16:15:43.358Z', + lastStopTime: '2022-05-30T16:12:18.651Z', + recording: true, + lastStatus: 'FAILURE', + lastStatusChangeTime: '2022-05-31T05:16:02.486Z' + } +]; + +const discoveredResources = [ + { resourceType: 'AWS::IAM::Role', count: 91 }, + { resourceType: 'AWS::IAM::Policy', count: 38 }, + { resourceType: 'AWS::KMS::Key', count: 28 }, + { resourceType: 'AWS::S3::Bucket', count: 19 }, + { resourceType: 'AWS::EC2::SecurityGroup', count: 17 }, + { resourceType: 'AWS::CodeDeploy::DeploymentConfig', count: 17 }, + { resourceType: 'AWS::IAM::User', count: 8 }, + { resourceType: 'AWS::EC2::Subnet', count: 8 }, + { resourceType: 'AWS::CloudFormation::Stack', count: 6 }, + { resourceType: 'AWS::CloudWatch::Alarm', count: 5 }, + { resourceType: 'AWS::Lambda::Function', count: 4 }, + { resourceType: 'AWS::EC2::RouteTable', count: 4 }, + { resourceType: 'AWS::SNS::Topic', count: 4 }, + { resourceType: 'AWS::EC2::VPC', count: 3 }, + { resourceType: 'AWS::EC2::NetworkInterface', count: 3 }, + { resourceType: 'AWS::ApiGateway::Stage', count: 3 }, + { resourceType: 'AWS::EC2::NetworkAcl', count: 3 }, + { resourceType: 'AWS::EC2::InternetGateway', count: 2 }, + { resourceType: 'AWS::AccessAnalyzer::Analyzer', count: 2 }, + { resourceType: 'AWS::CloudTrail::Trail', count: 2 }, + { resourceType: 'AWS::ApiGatewayV2::Stage', count: 2 }, + { resourceType: 'AWS::Backup::BackupVault', count: 2 }, + { resourceType: 'AWS::ApiGateway::RestApi', count: 2 }, + { resourceType: 'AWS::SQS::Queue', count: 1 }, + { resourceType: 'AWS::EC2::Instance', count: 1 }, + { resourceType: 'AWS::RDS::DBSubnetGroup', count: 1 }, + { resourceType: 'AWS::Redshift::ClusterParameterGroup', count: 1 }, + { resourceType: 'AWS::S3::AccountPublicAccessBlock', count: 1 }, + { resourceType: 'AWS::SecretsManager::Secret', count: 1 }, + { resourceType: 'AWS::RDS::DBSnapshot', count: 1 }, + { resourceType: 'AWS::EC2::LaunchTemplate', count: 1 }, + { resourceType: 'AWS::Redshift::ClusterSubnetGroup', count: 1 }, + { resourceType: 'AWS::RDS::DBSecurityGroup', count: 1 }, + { resourceType: 'AWS::CodeBuild::Project', count: 1 }, + { resourceType: 'AWS::ApiGatewayV2::Api', count: 1 }, + { resourceType: 'AWS::EC2::Volume', count: 1 }, + { resourceType: 'AWS::ECR::Repository', count: 1 } +]; + +const getRole = [ + { + 'Role':{ + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "Tags": [ + { + "Key": "app_name", + "Value": "Khulnasoft CSPM" + } + ], + } + }, + { + 'Role':{ + "Path": "/", + "RoleName": "test-role-1", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-1", + "Tags": [ + ], + } + }, + { + 'Role':{ + "Path": "/", + "RoleName": "test-role-2", + "RoleId": "AROAYE32SRU5VIMXXL3BH", + "Arn": "arn:aws:iam::000011112222:role/test-role-2", + "Tags": [ + ], + } + } + +]; + +const createCache = (configStatus, discoveredResources, listRoles, getRole, listAttachedRolePolicies, listRolePolicies, getRolePolicy, getPolicy, getPolicyVersion, configStatusErr, discoveredResourcesErr, listRolesErr, listRolePoliciesErr, listAttachedRolePoliciesErr) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + var policyArn = (listAttachedRolePolicies && listAttachedRolePolicies.AttachedPolicies) ? listAttachedRolePolicies.AttachedPolicies[0].PolicyArn : null; + var policyName = (listRolePolicies && listRolePolicies.PolicyNames) ? listRolePolicies.PolicyNames[0] : null; + return { + configservice: { + describeConfigurationRecorderStatus: { + 'us-east-1': { + err: configStatusErr, + data: configStatus + } + }, + getDiscoveredResourceCounts: { + 'us-east-1': { + err: discoveredResourcesErr, + data: discoveredResources + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + err: listRolesErr, + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listAttachedRolePoliciesErr, + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + err: listRolePoliciesErr, + data: listRolePolicies + } + } + }, + getPolicy: { + 'us-east-1': { + [policyArn]: { + data: getPolicy + } + } + }, + getRolePolicy: { + 'us-east-1': { + [roleName]: { + [policyName]: { + data: getRolePolicy + } + } + } + }, + getPolicyVersion: { + 'us-east-1': { + [policyArn]: { + data: getPolicyVersion + } + } + }, + getRole: { + 'us-east-1': { + [roleName]: { + data: getRole + } + } + } + } + }; +}; + +describe('rolePolicyUnusedServices', function () { + describe('run', function () { + it('should PASS if role does not have overly-permissive policy', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], listAttachedRolePolicies[2], listRolePolicies[0], getRolePolicy[2]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Role does not have overly-permissive policy'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows wildcard actions', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy allows wildcard actions'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role policy allows wildcard actions but ignore managed iam policies is set to true', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + rolePolicyUnusedServices.run(cache, { ignore_customer_managed_iam_policies : 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows all actions on selected resources', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], {}, listRolePolicies[1], getRolePolicy[4]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy allows all actions on selected resources'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if role policy allows all actions on all resources', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[1]], getRole[1], {}, listRolePolicies[1], getRolePolicy[3]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy allows all actions on all resources'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role policy allows wildcard actions but ignore service specific roles setting is enabled', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], listAttachedRolePolicies[2], null, null, getPolicy[0], getPolicyVersion[0]); + rolePolicyUnusedServices.run(cache, { ignore_service_specific_wildcards: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if np IAM roles found', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, []); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No IAM roles found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list IAM roles', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, null, null, null, null, null, null, null,null,null, { message: 'Unable to list IAM roles'}); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM roles'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[1]], getRole[1], {}, null, null, null, null, null, null, null, null, { message: 'Unable to list attached role policies'}); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM attached policy for role:'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[1]], getRole[1], listAttachedRolePolicies[0], {}, null, null, null, null, null, null, { message: 'Unable to query role policies'}); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Unable to query for IAM role policy for role'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if no unable to query for Config Service', function (done) { + const cache = createCache(null, discoveredResources, [listRoles[1]], getRole[1], listAttachedRolePolicies[0], null, null, null, null, { message: 'Unable to query Config Service'}); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].message).to.include('Unable to query config service'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should FAIL if Config Service is not enabled', function (done) { + const cache = createCache([], discoveredResources, [listRoles[1]], getRole[1], listAttachedRolePolicies[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].message).to.include('Config service is not enabled'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Config Service is not recording', function (done) { + const cache = createCache([configStatus[1]], discoveredResources, [listRoles[1]], getRole[1], listAttachedRolePolicies[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].message).to.include('Config service is not recording'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Config Service is recording but not delivering properly', function (done) { + const cache = createCache([configStatus[2]], discoveredResources, [listRoles[1]], getRole[1], listAttachedRolePolicies[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].message).to.include('Config Service is configured, and recording, but not delivering properly'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Discovered Resources', function (done) { + const cache = createCache([configStatus[0]], null, [listRoles[1]], getRole[1], listAttachedRolePolicies[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].message).to.include('Unable to query for Discovered Resources'); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if no Discovered Resources found', function (done) { + const cache = createCache([configStatus[0]], [], [listRoles[1]], getRole[1], listAttachedRolePolicies[0]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No Discovered Resources found'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Role policies contain actions for resource types which are not in use', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[1], {}, listRolePolicies[1], getRolePolicy[4]); + rolePolicyUnusedServices.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if role with specific regex is ignored', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], {}, listRolePolicies[1], getRolePolicy[4]); + rolePolicyUnusedServices.run(cache, {iam_role_policies_ignore_tag:'app_name:Khulnasoft CSPM'}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should PASS if specific actions for service are ignored', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[3]], getRole[1], listAttachedRolePolicies[3], null, null, getPolicy[1], getPolicyVersion[1]); + rolePolicyUnusedServices.run(cache, {whitelist_unused_actions_for_resources: 'elasticloadbalancing:RegisterInstancesWithLoadBalancer, elasticloadbalancing:DeregisterInstancesFromLoadBalancer'}, (err,results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('Role does not have overly-permissive'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if role policy allows resources which does not match regex in iam_policy_resource_specific_wildcards', function (done) { + const cache = createCache([configStatus[0]], discoveredResources, [listRoles[0]], getRole[0], {}, listRolePolicies[1], getRolePolicy[5]); + rolePolicyUnusedServices.run(cache, {ignore_service_specific_wildcards: 'true', iam_policy_resource_specific_wildcards: '^[a-z]+:[a-z]+:[a-z0-9]+:::[a-z]+$'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('policy does not match provided regex'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/rootAccessKeys.js b/plugins/aws/iam/rootAccessKeys.js index 100915ccf..02e00ed5c 100644 --- a/plugins/aws/iam/rootAccessKeys.js +++ b/plugins/aws/iam/rootAccessKeys.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Root Access Keys', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures the root account is not using access keys', more_info: 'The root account should avoid using access keys. Since the root account has full permissions across the entire account, creating access keys for it only increases the chance that they are compromised. Instead, create IAM users with predefined roles.', link: 'http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html', @@ -15,6 +17,7 @@ module.exports = { 'should not be used.', cis1: '1.12 Ensure no root account access key exists' }, + realtime_triggers: ['iam:CreateAccessKey','iam:DeleteAccessKey'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/rootAccessKeys.spec.js b/plugins/aws/iam/rootAccessKeys.spec.js new file mode 100644 index 000000000..f6423aa10 --- /dev/null +++ b/plugins/aws/iam/rootAccessKeys.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +const rootAccessKeys = require('./rootAccessKeys'); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": false, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": false, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-11-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rootAccessKeys', function () { + describe('run', function () { + it('should PASS if access keys were not found for the root account', function (done) { + const cache = createCache([generateCredentialReport[0]]); + rootAccessKeys.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if access keys were found for the root account', function (done) { + const cache = createCache([generateCredentialReport[1]]); + rootAccessKeys.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for root user', function (done) { + const cache = createCache([]); + rootAccessKeys.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + rootAccessKeys.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + rootAccessKeys.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/rootAccountInUse.js b/plugins/aws/iam/rootAccountInUse.js index 7e99a0653..a51062237 100644 --- a/plugins/aws/iam/rootAccountInUse.js +++ b/plugins/aws/iam/rootAccountInUse.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Root Account In Use', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures the root account is not being actively used', more_info: 'The root account should not be used for day-to-day account management. IAM users, roles, and groups should be used instead.', link: 'http://docs.aws.amazon.com/general/latest/gr/root-vs-iam.html', diff --git a/plugins/aws/iam/rootHardwareMfa.js b/plugins/aws/iam/rootHardwareMfa.js index 0c7c1afdb..85d9641eb 100644 --- a/plugins/aws/iam/rootHardwareMfa.js +++ b/plugins/aws/iam/rootHardwareMfa.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Root Hardware MFA', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures the root account is using a hardware MFA device', more_info: 'The root account should use a hardware MFA device for added security, rather than a virtual device which could be more easily compromised.', link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_physical.html', recommended_action: 'Enable a hardware MFA device for the root account and disable any virtual devices', apis: ['IAM:listVirtualMFADevices', 'IAM:getAccountSummary'], + realtime_triggers: ['iam:CreateVirtualMFADevice','iam:DeleteVirtualMFADevice'], run: function(cache, settings, callback) { var results = []; @@ -27,7 +30,6 @@ module.exports = { var listVirtualMFADevices = helpers.addSource(cache, source, ['iam', 'listVirtualMFADevices', region]); - if (!listVirtualMFADevices || listVirtualMFADevices.err || !listVirtualMFADevices.data) { diff --git a/plugins/aws/iam/rootMfaEnabled.js b/plugins/aws/iam/rootMfaEnabled.js index 572d5f003..37d745738 100644 --- a/plugins/aws/iam/rootMfaEnabled.js +++ b/plugins/aws/iam/rootMfaEnabled.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Root MFA Enabled', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures a multi-factor authentication device is enabled for the root account', more_info: 'The root account should have an MFA device setup to enable two-factor authentication.', link: 'http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html', @@ -14,6 +16,7 @@ module.exports = { 'a safe location for use as backup for named IAM users.', cis1: '1.13 Ensure MFA is enabled for the "root" account' }, + realtime_triggers: ['iam:CreateVirtualMFADevice','iam:DeleteVirtualMFADevice'], run: function(cache, settings, callback) { var results = []; @@ -52,7 +55,9 @@ module.exports = { } } - if (!found) { + if (settings.govcloud && !found) { + helpers.addResult(results, 0, 'Root MFA is not required for AWS GovCloud'); + } else if (!found) { helpers.addResult(results, 3, 'Unable to query for root user'); } diff --git a/plugins/aws/iam/rootMfaEnabled.spec.js b/plugins/aws/iam/rootMfaEnabled.spec.js new file mode 100644 index 000000000..df4b83c06 --- /dev/null +++ b/plugins/aws/iam/rootMfaEnabled.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +const rootMfaEnabled = require('./rootMfaEnabled'); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": true, + "access_key_1_active": false, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": false, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": "2020-11-13T13:18:29+00:00", + "password_last_changed": "2020-09-12T17:02:21+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-11-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": null, + "access_key_2_last_used_date": null, + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rootMfaEnabled', function () { + describe('run', function () { + it('should PASS if an MFA device was found for the root account', function (done) { + const cache = createCache([generateCredentialReport[0]]); + rootMfaEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if an MFA device was not found for the root account', function (done) { + const cache = createCache([generateCredentialReport[1]]); + rootMfaEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to query for root user', function (done) { + const cache = createCache([]); + rootMfaEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + rootMfaEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + rootMfaEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/iam/rootSigningCertificate.js b/plugins/aws/iam/rootSigningCertificate.js new file mode 100644 index 000000000..144f925fd --- /dev/null +++ b/plugins/aws/iam/rootSigningCertificate.js @@ -0,0 +1,63 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Root Account Active Signing Certificates', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensures the root user is not using x509 signing certificates', + more_info: 'AWS supports using x509 signing certificates for API access, but these should not be attached to the root user, which has full access to the account.', + link: 'https://docs.aws.amazon.com/whitepapers/latest/aws-overview-security-processes/x.509-certificates.html', + recommended_action: 'Delete the x509 certificates associated with the root account.', + apis: ['IAM:generateCredentialReport'], + compliance: { + hipaa: 'HIPAA requires strong auditing controls surrounding actions ' + + 'taken in the environment. The root user lacks these controls ' + + 'since it is not tied to a specific user. The root signing keys ' + + 'should not be used.' + }, + realtime_triggers: ['iam:DeleteSigningCertificate','iam:UploadSigningCertificate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + + var generateCredentialReport = helpers.addSource(cache, source, + ['iam', 'generateCredentialReport', region]); + + if (!generateCredentialReport) return callback(null, results, source); + + if (generateCredentialReport.err || !generateCredentialReport.data) { + helpers.addResult(results, 3, + 'Unable to query for root user: ' + helpers.addError(generateCredentialReport)); + return callback(null, results, source); + } + + var found = false; + for (var r in generateCredentialReport.data) { + var obj = generateCredentialReport.data[r]; + const resource = obj.arn; + + if (obj && obj.user && obj.user === '') { + found = true; + + if (obj.cert_1_active || + obj.cert_2_active) { + helpers.addResult(results, 2, 'The root user uses x509 signing certificates.', 'global', resource); + } else { + helpers.addResult(results, 0, 'The root user does not use x509 signing certificates.', 'global', resource); + } + + break; + } + } + + if (!found) { + helpers.addResult(results, 3, 'Unable to query for root user'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/aws/iam/rootSigningCertificate.spec.js b/plugins/aws/iam/rootSigningCertificate.spec.js new file mode 100644 index 000000000..82338eb86 --- /dev/null +++ b/plugins/aws/iam/rootSigningCertificate.spec.js @@ -0,0 +1,162 @@ +var expect = require('chai').expect; +const rootSigningCertificate = require('./rootSigningCertificate'); + +const credentialReports = [ + { + user: '', + arn: 'arn:aws:iam::111122223333:root', + user_creation_time: '2020-08-09T16:55:28+00:00', + password_enabled: 'not_supported', + password_last_used: '2020-08-17T21:13:44+00:00', + password_last_changed: 'not_supported', + password_next_rotation: 'not_supported', + mfa_active: false, + access_key_1_active: true, + access_key_1_last_rotated: '2020-08-17T21:15:23+00:00', + access_key_1_last_used_date: null, + access_key_1_last_used_region: null, + access_key_1_last_used_service: null, + access_key_2_active: false, + access_key_2_last_rotated: null, + access_key_2_last_used_date: null, + access_key_2_last_used_region: null, + access_key_2_last_used_service: null, + cert_1_active: false, + cert_1_last_rotated: null, + cert_2_active: false, + cert_2_last_rotated: null + }, + { + user: '', + arn: 'arn:aws:iam::111122223333:user/cloudexploit', + user_creation_time: '2020-08-17T09:07:27+00:00', + password_enabled: true, + password_last_used: 'no_information', + password_last_changed: '2020-08-17T09:07:29+00:00', + password_next_rotation: null, + mfa_active: false, + access_key_1_active: true, + access_key_1_last_rotated: '2020-08-17T09:07:29+00:00', + access_key_1_last_used_date: null, + access_key_1_last_used_region: null, + access_key_1_last_used_service: null, + access_key_2_active: false, + access_key_2_last_rotated: null, + access_key_2_last_used_date: null, + access_key_2_last_used_region: null, + access_key_2_last_used_service: null, + cert_1_active: true, + cert_1_last_rotated: null, + cert_2_active: false, + cert_2_last_rotated: null + }, + { + user: 'codesploit', + arn: 'arn:aws:iam::111122223333:user/cloudexploit', + user_creation_time: '2020-08-17T09:07:27+00:00', + password_enabled: true, + password_last_used: 'no_information', + password_last_changed: '2020-08-17T09:07:29+00:00', + password_next_rotation: null, + mfa_active: false, + access_key_1_active: true, + access_key_1_last_rotated: '2020-08-17T09:07:29+00:00', + access_key_1_last_used_date: null, + access_key_1_last_used_region: null, + access_key_1_last_used_service: null, + access_key_2_active: false, + access_key_2_last_rotated: null, + access_key_2_last_used_date: null, + access_key_2_last_used_region: null, + access_key_2_last_used_service: null, + cert_1_active: true, + cert_1_last_rotated: null, + cert_2_active: false, + cert_2_last_rotated: null + } +] + +const createCache = (credentialReports) => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + data: credentialReports + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error describing cloudformation stacks' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rootSigningCertificate', function () { + describe('run', function () { + + it('should PASS if the root user is not using x509 singing certificates', function (done) { + const cache = createCache([credentialReports[0]]); + rootSigningCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if the root user is using x509 singing certificates', function (done) { + const cache = createCache([credentialReports[1]]); + rootSigningCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if the root user is not found', function (done) { + const cache = createCache([credentialReports[2]]); + rootSigningCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to fetch credential reports', function (done) { + const cache = createNullCache(); + rootSigningCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching credential reports', function (done) { + const cache = createErrorCache(); + rootSigningCertificate.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/sshKeysRotated.js b/plugins/aws/iam/sshKeysRotated.js index e565d0e44..f73db0d49 100644 --- a/plugins/aws/iam/sshKeysRotated.js +++ b/plugins/aws/iam/sshKeysRotated.js @@ -3,11 +3,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SSH Keys Rotated', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures SSH keys are not older than 180 days in order to reduce accidental exposures', more_info: 'SSH keys should be rotated frequently to avoid having them accidentally exposed.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_ssh-keys.html', recommended_action: 'To rotate an SSH key, first create a new public-private key pair, then upload the public key to AWS and delete the old key.', - apis: ['IAM:generateCredentialReport'], + apis: ['IAM:listUsers', 'IAM:listSSHPublicKeys'], settings: { ssh_keys_rotated_fail: { name: 'SSH Keys Rotated Fail', @@ -22,6 +24,7 @@ module.exports = { default: 180 } }, + realtime_triggers: ['iam:UploadSSHPublicKey','iam:DeleteSSHPublicKey'], run: function(cache, settings, callback) { var config = { @@ -29,73 +32,64 @@ module.exports = { ssh_keys_rotated_warn: settings.ssh_keys_rotated_warn || this.settings.ssh_keys_rotated_warn.default }; - var custom = helpers.isCustom(settings, this.settings); - var results = []; var source = {}; var region = helpers.defaultRegion(settings); - var generateCredentialReport = helpers.addSource(cache, source, - ['iam', 'generateCredentialReport', region]); + var listUsers = helpers.addSource(cache, source, + ['iam', 'listUsers', region]); - if (!generateCredentialReport) return callback(null, results, source); + if (!listUsers) return callback(null, results, source); - if (generateCredentialReport.err || !generateCredentialReport.data) { + if (listUsers.err || !listUsers.data) { helpers.addResult(results, 3, - 'Unable to query for users: ' + helpers.addError(generateCredentialReport)); + 'Unable to query for Users: ' + helpers.addError(listUsers)); return callback(null, results, source); } - if (generateCredentialReport.data.length === 1) { - // Only have the root user - helpers.addResult(results, 0, 'No user accounts with SSH keys found'); - return callback(null, results, source); - } - - var found = false; - - function addSSHKeyResults(lastRotated, keyNum, arn, userName, userCreationTime) { - var keyDate = new Date(lastRotated); - var daysOld = helpers.daysAgo(keyDate); - - var returnMsg = 'SSH key: ' + keyNum + ' for user: ' + userName + - ' is ' + daysOld + ' days old'; - - if (helpers.daysAgo(userCreationTime) > 180 && - (!lastRotated || lastRotated === 'N/A' || helpers.daysAgo(lastRotated) > config.ssh_keys_rotated_fail)) { - helpers.addResult(results, 2, returnMsg, 'global', arn, custom); - } else if (helpers.daysAgo(userCreationTime) > 90 && - (!lastRotated || lastRotated === 'N/A' || helpers.daysAgo(lastRotated) > config.ssh_keys_rotated_warn)) { - helpers.addResult(results, 1, returnMsg, 'global', arn, custom); - } else { - helpers.addResult(results, 0, - 'User SSH key ' + keyNum + ' ' + - ((lastRotated === 'N/A' || !lastRotated) ? 'has never been rotated but user is only ' + helpers.daysAgo(userCreationTime) + ' days old' : 'was last rotated ' + helpers.daysAgo(lastRotated) + ' days ago'), 'global', arn); + if (!listUsers.data.length) { + helpers.addResult(results, 0, 'No iam users found', 'global'); + } + + for (var user of listUsers.data) { + if (!user.UserName) continue; + + var found = false; + var listSSHPublicKeys = helpers.addSource(cache, source, + ['iam', 'listSSHPublicKeys', region, user.UserName]); + + if (!listSSHPublicKeys || listSSHPublicKeys.err || !listSSHPublicKeys.data || !listSSHPublicKeys.data.SSHPublicKeys) { + helpers.addResult(results, 3, + 'Unable to query for SSH Keys: ' + helpers.addError(listSSHPublicKeys), 'global', user.Arn); + continue; } - found = true; + for (var sshkey of listSSHPublicKeys.data.SSHPublicKeys) { + + if (sshkey.Status && sshkey.Status ==='Active') { + if (!sshkey.UploadDate) continue; + + var keyDate = new Date(sshkey.UploadDate); + var daysOld = helpers.daysAgo(keyDate); + var returnMsg = `SSH key with ID: ${sshkey.SSHPublicKeyId} is ${daysOld} days old`; + + if (daysOld > config.ssh_keys_rotated_fail) { + helpers.addResult(results, 2, returnMsg, 'global', user.Arn); + + } else if (daysOld > config.ssh_keys_rotated_warn) { + helpers.addResult(results, 1, returnMsg, 'global', user.Arn); + + } else { + helpers.addResult(results, 0, returnMsg, 'global', user.Arn); + } + found = true; + } + } + if (!found) { + helpers.addResult(results, 0, 'No SSH keys found', 'global', user.Arn); + } } - - for (var r in generateCredentialReport.data) { - var obj = generateCredentialReport.data[r]; - - // TODO: test the root account? - // if (obj.user === '') continue; - - if (obj.cert_1_active) { - addSSHKeyResults(obj.cert_1_last_rotated, '1', obj.arn, obj.user, obj.user_creation_time); - } - - if (obj.cert_2_active) { - addSSHKeyResults(obj.cert_2_last_rotated, '2', obj.arn, obj.user, obj.user_creation_time); - } - } - - if (!found) { - helpers.addResult(results, 0, 'No SSH keys found'); - } - callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/aws/iam/sshKeysRotated.spec.js b/plugins/aws/iam/sshKeysRotated.spec.js new file mode 100644 index 000000000..1108e1e94 --- /dev/null +++ b/plugins/aws/iam/sshKeysRotated.spec.js @@ -0,0 +1,163 @@ +const expect = require('chai').expect; +const sshKeysRotated = require('./sshKeysRotated'); + +var warnDate = new Date(); +warnDate.setMonth(warnDate.getMonth() - 6); +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 2); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 12); + +const listUsers = [ + { + 'Path': '/', + 'UserName': 'cloudexploit', + 'UserId': 'AIDARPGOCGXSSUH7TNLM4', + 'Arn': 'arn:aws:iam::000011111:user/cloudexploit', + 'CreateDate': '2021-12-12T13:15:54+00:00' + }, + { + 'Path': '/', + 'UserName': 'testUser', + 'UserId': 'AIDARPGOCGXSUSX63OQEM', + 'Arn': 'arn:aws:iam::0000111111112:user/testUser', + 'CreateDate': '2022-10-10T11:41:15+00:00' + } +]; + +var listSSHPublicKeys = [ + { + 'SSHPublicKeys': [ + { + 'UserName': 'cloudexploit', + 'SSHPublicKeyId': 'APKASZ433I6EO2NEWLNK', + 'Status': 'Active', + 'UploadDate': passDate + } + ] + },{ + 'SSHPublicKeys': [ + { + 'UserName': 'testUser', + 'SSHPublicKeyId': 'APKASZ433I6ELJJ6BQT5', + 'Status': 'Active', + 'UploadDate': warnDate + } + ] + },{ + 'SSHPublicKeys': [ + { + 'UserName': 'testUser', + 'SSHPublicKeyId': 'APKASZ433I6ELJJ6BQT5', + 'Status': 'Active', + 'UploadDate': failDate + } + ] + },{ + 'SSHPublicKeys': [ + {} + ] +}]; + + +const createCache = (listUsers, listSSHPublicKeys) => { + var userName = (listUsers && listUsers.length) ? listUsers[0].UserName : null; + return { + iam: { + listUsers: { + 'us-east-1': { + data: listUsers, + err: null + }, + }, + listSSHPublicKeys: { + 'us-east-1': { + [userName]:{ + data: listSSHPublicKeys, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listUsers: { + 'us-east-1': { + err: { + message: 'Unable to query for Users:' + }, + }, + } + } + }; +}; + + +describe('sshKeysRotated', function() { + describe('run', function() { + it('should PASS if SSH key is less than 180 days old', function(done) { + const cache = createCache([listUsers[0]],listSSHPublicKeys[0]); + sshKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].resource).to.equal('arn:aws:iam::000011111:user/cloudexploit'); + done(); + }); + }); + + it('should WARN if SSH key was created more than 180 days ago', function(done) { + const cache = createCache([listUsers[1]],listSSHPublicKeys[1]); + const settings = { ssh_keys_rotated_warn: 150 }; + sshKeysRotated.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if SSH key was created more than 360 days ago', function(done) { + const cache = createCache([listUsers[1]],listSSHPublicKeys[2]); + const settings = { ssh_keys_rotated_fail: 300 }; + sshKeysRotated.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no SSH keys found', function(done) { + const cache = createCache([listUsers[1]],listSSHPublicKeys[3]); + sshKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SSH keys found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for users', function(done) { + const cache = createErrorCache(); + sshKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Users:'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for SSH Keys', function(done) { + const cache = createCache([listUsers[0]]); + sshKeysRotated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SSH Keys:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/iam/trustedCrossAccountRoles.js b/plugins/aws/iam/trustedCrossAccountRoles.js new file mode 100644 index 000000000..887c61ef3 --- /dev/null +++ b/plugins/aws/iam/trustedCrossAccountRoles.js @@ -0,0 +1,134 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Trusted Cross Account Roles', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that only trusted cross-account IAM roles can be used.', + more_info: 'IAM roles should be configured to allow access to trusted account IDs.', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_aws-accounts.html', + recommended_action: 'Delete the IAM roles that are associated with untrusted account IDs.', + apis: ['IAM:listRoles', 'STS:getCallerIdentity', 'Organizations:listAccounts'], + settings: { + whitelisted_aws_account_principals: { + name: 'Whitelisted AWS Account Principals', + description: 'A comma-separated list of trusted cross account principals', + regex: '^.*$', + default: '' + }, + whitelisted_aws_account_principals_regex: { + name: 'Whitelisted AWS Account Principals Regex', + description: 'If set, plugin will compare cross account principals against this regex instead of otherwise given comma-separated list' + + 'Example regex: ^arn:aws:iam::(111111111111|222222222222|):.+$', + regex: '^.*$', + default: '' + }, + iam_whitelist_aws_organization_accounts: { + name: 'Whitelist AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['iam:CreateRole','iam:DeleteRole'], + + run: function(cache, settings, callback) { + var config= { + whitelisted_aws_account_principals : settings.whitelisted_aws_account_principals || this.settings.whitelisted_aws_account_principals.default, + whitelisted_aws_account_principals_regex : settings.whitelisted_aws_account_principals_regex || this.settings.whitelisted_aws_account_principals_regex.default, + iam_whitelist_aws_organization_accounts: settings.iam_whitelist_aws_organization_accounts || this.settings.iam_whitelist_aws_organization_accounts.default + }; + var makeRegexBased = (config.whitelisted_aws_account_principals_regex.length) ? true : false; + var whitelistOrganization = (config.iam_whitelist_aws_organization_accounts == 'true'); + config.whitelisted_aws_account_principals_regex = new RegExp(config.whitelisted_aws_account_principals_regex); + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', region, 'data']); + + var listRoles = helpers.addSource(cache, source, + ['iam', 'listRoles', region]); + + if (!listRoles) return callback(null, results, source); + + if (listRoles.err || !listRoles.data) { + helpers.addResult(results, 3, + `Unable to query for IAM roles: ${helpers.addError(listRoles)}`); + return callback(null, results, source); + } + + if (!listRoles.data.length) { + helpers.addResult(results, 0, 'No IAM roles found'); + return callback(null, results, source); + } + + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', region]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, region); + return callback(null, results, source); + } + + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + + listRoles.data.forEach(role => { + if (!role.Arn || !role.AssumeRolePolicyDocument) return; + + var statements = helpers.normalizePolicyDocument(role.AssumeRolePolicyDocument); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'IAM role does not contain trust relationship statements', + 'global', role.Arn); + return; + } + + var restrictedAccountPrincipals = []; + var crossAccountRole = false; + + for (var statement of statements) { + if (!statement.Effect || statement.Effect !== 'Allow') continue; + + if (statement.Principal && helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings)) { + crossAccountRole = true; + var principals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + if (principals.length) { + principals.forEach(principal => { + if (whitelistOrganization) { + if (organizationAccounts.find(account => principal.includes(account))) return; + } + if (makeRegexBased) { + if (!config.whitelisted_aws_account_principals_regex.test(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + } else if (!config.whitelisted_aws_account_principals.includes(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + }); + } + } + } + + if (crossAccountRole && !restrictedAccountPrincipals.length) { + helpers.addResult(results, 0, + `Cross-account role "${role.RoleName}" contains trusted account principals only`, + 'global', role.Arn); + } else if (crossAccountRole) { + helpers.addResult(results, 2, + `Cross-account role "${role.RoleName}" contains these untrusted account principals: ${restrictedAccountPrincipals.join(', ')}`, + 'global', role.Arn); + } else { + helpers.addResult(results, 0, + `IAM Role "${role.RoleName}" does not contain cross-account statements`, + 'global', role.Arn); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/aws/iam/trustedCrossAccountRoles.spec.js b/plugins/aws/iam/trustedCrossAccountRoles.spec.js new file mode 100644 index 000000000..a75bca96c --- /dev/null +++ b/plugins/aws/iam/trustedCrossAccountRoles.spec.js @@ -0,0 +1,200 @@ +var expect = require('chai').expect; +const trustedCrossAccountRoles = require('./trustedCrossAccountRoles'); + +const roles = [ + { + "Path": "/aws-service-role/support.amazonaws.com/", + "RoleName": "AWSServiceRoleForSupport", + "RoleId": "AROAYE32SRU57FHIKLZI5", + "Arn": "arn:aws:iam::123456654321:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport", + "CreateDate": "2020-08-09T16:55:28Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22trustedadvisor.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "Enables resource access for AWS to provide billing, administrative and support services", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-MFA-true", + "RoleId": "AROAYE32SRU5VULODBGFK", + "Arn": "arn:aws:iam::123456654321:role/test-role-MFA-true", + "CreateDate": "2020-08-30T17:48:45Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%3A123456654321%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%22Bool%22%3A%7B%22aws%3AMultiFactorAuthPresent%22%3A%22true%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-web-identity", + "RoleId": "AROAYE32SRU52CTP2RVNS", + "Arn": "arn:aws:iam::123456654321:role/test-role-web-identity", + "CreateDate": "2020-08-30T17:55:56Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Federated%22%3A%22graph.facebook.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRoleWithWebIdentity%22%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22graph.facebook.com%3Aapp_id%22%3A%22test-id%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-external-id", + "RoleId": "AROAYE32SRU52JAKJR5YY", + "Arn": "arn:aws:iam::123456654321:role/test-role-external-id", + "CreateDate": "2020-08-30T21:18:35Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%3A123456654321%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22sts%3AExternalId%22%3A%2212345%22%7D%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "test-role-MFA-externalid-false", + "RoleId": "AROAYE32SRU5ZCTFXUN6O", + "Arn": "arn:aws:iam::123456654321:role/test-role-MFA-externalid-false", + "CreateDate": "2020-08-30T19:09:22Z", + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22AWS%22%3A%22arn%3Aaws%3Aiam%3A%3A123456654322%3Aroot%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%2C%22Condition%22%3A%7B%7D%7D%5D%7D", + "MaxSessionDuration": 3600 + } +]; + +const createCache = (roles, accounts) => { + return { + iam: { + listRoles: { + 'us-east-1': { + data: roles, + }, + }, + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '112233445566' + } + } + }, + organizations: { + listAccounts: { + 'us-east-1': { + data: [ + { + "Id": "123456654321", + "Arn": "arn:aws:organizations::123456654321:account/o-sb9qmv2zif/123456654321", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + }, + { + "Id": "123456654322", + "Arn": "arn:aws:organizations::123456654322:account/o-sb9qmv2zif/123456654322", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + } + ] + } + } + } + }; +}; + +const createErrorCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': { + err: { + message: 'error listing IAM roles' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + iam: { + listRoles: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('trustedCrossAccountRoles', function () { + describe('run', function () { + it('should FAIL if cross-account role contains untrusted account IDs', function (done) { + const cache = createCache([roles[4]]); + trustedCrossAccountRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if cross-account role contains organization account ID and setting to allow organization account is true', function (done) { + const cache = createCache([roles[4]]); + trustedCrossAccountRoles.run(cache, { iam_whitelist_aws_organization_accounts: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if cross-account role contains trusted account IDs', function (done) { + const cache = createCache([roles[1]]); + trustedCrossAccountRoles.run(cache, { whitelisted_aws_account_principals:'arn:aws:iam::123456654321:root' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if cross-account role contains trusted account IDs validated againt whitelisted account regex', function (done) { + const cache = createCache([roles[1]]); + trustedCrossAccountRoles.run(cache, { whitelisted_aws_account_principals_regex:'^arn:aws:iam::123456654321:.+$' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no IAM roles found', function (done) { + const cache = createCache([]); + trustedCrossAccountRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if role does not contain cross-account statements', function (done) { + const cache = createCache([roles[0]]); + trustedCrossAccountRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for IAM roles', function (done) { + const cache = createErrorCache(); + trustedCrossAccountRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return any results if unable to query for IAM roles', function (done) { + const cache = createNullCache(); + trustedCrossAccountRoles.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/iam/usersMfaEnabled.js b/plugins/aws/iam/usersMfaEnabled.js index 1e9df89f8..2a1e40652 100644 --- a/plugins/aws/iam/usersMfaEnabled.js +++ b/plugins/aws/iam/usersMfaEnabled.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Users MFA Enabled', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures a multi-factor authentication device is enabled for all users within the account', more_info: 'User accounts should have an MFA device setup to enable two-factor authentication', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/Using_ManagingPasswordPolicies.html', @@ -18,6 +20,19 @@ module.exports = { cis1: '1.2 Ensure multi-factor authentication (MFA) is enabled for all ' + 'IAM users that have a console password' }, + asl: { + conditions: [ + { + service: 'iam', + api: 'generateCredentialReport', + property: 'mfa_active', + transform: 'STRING', + op: 'EQ', + value: 'true' + } + ] + }, + realtime_triggers: ['iam:CreateVirtualMFADevice','iam:DeleteVirtualMFADevice'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/usersPasswordAndKeys.js b/plugins/aws/iam/usersPasswordAndKeys.js index 8b03a6bc2..9baf249fd 100644 --- a/plugins/aws/iam/usersPasswordAndKeys.js +++ b/plugins/aws/iam/usersPasswordAndKeys.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Users Password And Keys', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Detects whether users with a console password are also using access keys', more_info: 'Access keys should only be assigned to machine users and should not be used for accounts that have console password access.', link: 'http://docs.aws.amazon.com/IAM/latest/UserGuide/ManagingCredentials.html', @@ -17,6 +19,7 @@ module.exports = { default: '^.*$' } }, + realtime_triggers: ['iam:CreateLoginProfile','iam:DeleteLoginProfile','iam:CreateAccessKey','iam:DeleteAccessKey'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/iam/usersPasswordLastUsed.js b/plugins/aws/iam/usersPasswordLastUsed.js index 40dbd14ec..81d30b96b 100644 --- a/plugins/aws/iam/usersPasswordLastUsed.js +++ b/plugins/aws/iam/usersPasswordLastUsed.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Users Password Last Used', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Detects users with password logins that have not been used ' + 'for a period of time and that should be decommissioned', more_info: 'Having numerous, unused user accounts extends the attack surface. ' + @@ -31,6 +33,7 @@ module.exports = { default: 90 } }, + realtime_triggers: ['iam:CreateUser','iam:DeleteUser'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/iam/usersPasswordLastUsed.spec.js b/plugins/aws/iam/usersPasswordLastUsed.spec.js new file mode 100644 index 000000000..161a5b0d3 --- /dev/null +++ b/plugins/aws/iam/usersPasswordLastUsed.spec.js @@ -0,0 +1,325 @@ +var expect = require('chai').expect; +const usersPasswordLastUsed = require('./usersPasswordLastUsed'); + +var warnDate = new Date(); +warnDate.setMonth(warnDate.getMonth() - 4); +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 2); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 7); + +const generateCredentialReport = [ + { + "user": "", + "arn": "arn:aws:iam::111122223333:root", + "user_creation_time": "2020-08-09T16:55:28+00:00", + "password_enabled": "not_supported", + "password_last_used": "2020-11-11T10:33:09+00:00", + "password_last_changed": "not_supported", + "password_next_rotation": "not_supported", + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-08-18T14:36:56+00:00", + "access_key_1_last_used_date": "2020-08-18T15:24:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "iam", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-08-23T22:07:52+00:00", + "access_key_2_last_used_date": "2020-09-06T00:35:00+00:00", + "access_key_2_last_used_region": "us-east-1", + "access_key_2_last_used_service": "s3", + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2020-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": passDate, + "password_last_changed": "2020-10-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-10-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": warnDate, + "password_last_changed": "2020-07-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": "2019-09-12T16:58:32+00:00", + "password_enabled": true, + "password_last_used": failDate, + "password_last_changed": "2020-02-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-02-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": passDate, + "password_enabled": true, + "password_last_used": null, + "password_last_changed": "2020-10-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-09-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-10-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-10-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": warnDate, + "password_enabled": true, + "password_last_used": null, + "password_last_changed": "2020-07-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-07-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + }, + { + "user": "cloudexploit", + "arn": "arn:aws:iam::111122223333:user/cloudexploit", + "user_creation_time": failDate, + "password_enabled": true, + "password_last_used": null, + "password_last_changed": "2020-02-13T16:14:00+00:00", + "password_next_rotation": null, + "mfa_active": false, + "access_key_1_active": true, + "access_key_1_last_rotated": "2020-02-12T16:58:34+00:00", + "access_key_1_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_1_last_used_region": "us-east-1", + "access_key_1_last_used_service": "kms", + "access_key_2_active": true, + "access_key_2_last_rotated": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_date": "2020-02-13T16:14:00+00:00", + "access_key_2_last_used_region": null, + "access_key_2_last_used_service": null, + "cert_1_active": false, + "cert_1_last_rotated": null, + "cert_2_active": false, + "cert_2_last_rotated": null + } +]; + +const createCache = (report) => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + data: report + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': { + err: { + message: 'error generating credential report' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + iam:{ + generateCredentialReport: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('usersPasswordLastUsed', function () { + describe('run', function () { + it('should PASS if the user password was last used within the pass limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[1]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if the user was created within the pass limit but never used', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[4]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should WARN if the password was last used within the warn limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[2]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should WARN if the user was created within the warn limit but never used', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[5]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should FAIL if the user password was last used more than the fail limit', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[3]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if the user was created more than the fail limit but never used', function (done) { + const cache = createCache([generateCredentialReport[0],generateCredentialReport[0],generateCredentialReport[6]]); + var settings = { + users_password_last_used_fail: 180, + users_password_last_used_warn: 90 + }; + usersPasswordLastUsed.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no user accounts found', function (done) { + const cache = createCache([]); + usersPasswordLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to generate credential report', function (done) { + const cache = createErrorCache(); + usersPasswordLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if generate credential report response not found', function (done) { + const cache = createNullCache(); + usersPasswordLastUsed.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.js b/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.js new file mode 100644 index 000000000..fae28c29e --- /dev/null +++ b/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.js @@ -0,0 +1,134 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Dockerfile Template Encrypted', + category: 'Image Builder', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Image Recipe dockerfile templates are encrypted.', + more_info: 'Image Builder now offers a managed service for building Docker images. With Image Builder, you can automatically produce new up-to-date container images and publish them to specified Amazon Elastic Container Registry (Amazon ECR) repositories after running stipulated tests. Custom components are encrypted with your KMS key or a KMS key owned by Image Builder.', + link: 'https://docs.aws.amazon.com/imagebuilder/latest/userguide/data-protection.html', + recommended_action: 'Ensure that container recipe docker file templates are encrypted using AWS keys or customer managed keys in Imagebuilder service', + apis: ['Imagebuilder:listContainerRecipes', 'Imagebuilder:getContainerRecipe', 'KMS:listAliases', 'KMS:listKeys', + 'KMS:describeKey'], + settings: { + docker_file_desired_encryption_level: { + name: 'Docker File Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['imagebuilder:CreateContainerRecipe','imagebuilder:DeleteContainerRecipe'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.docker_file_desired_encryption_level || this.settings.docker_file_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.imagebuilder, function(region, rcb){ + var listContainerRecipes = helpers.addSource(cache, source, + ['imagebuilder', 'listContainerRecipes', region]); + + if (!listContainerRecipes) return rcb(); + + if (listContainerRecipes.err) { + helpers.addResult(results, 3, + 'Unable to query container recipe: ' + helpers.addError(listContainerRecipes), region); + return rcb(); + } + + if (!listContainerRecipes.data || !listContainerRecipes.data.length) { + helpers.addResult(results, 0, 'No container recipes found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasArn] = keyArn; + }); + + for (let recipe of listContainerRecipes.data) { + let resource = recipe.arn; + + var getContainerRecipe = helpers.addSource(cache, source, + ['imagebuilder', 'getContainerRecipe', region, recipe.arn]); + + if (!getContainerRecipe || getContainerRecipe.err || !getContainerRecipe.data || + !getContainerRecipe.data.containerRecipe) { + helpers.addResult(results, 3, + `Unable to get container recipe description: ${helpers.addError(getContainerRecipe)}`, + region, resource); + continue; + } + + if (getContainerRecipe.data.containerRecipe.kmsKeyId) { + var encryptionKey = getContainerRecipe.data.containerRecipe.kmsKeyId; + let kmsKeyArn = (encryptionKey.includes('alias/')) ? + (kmsAliasArnMap[encryptionKey]) ? kmsAliasArnMap[encryptionKey] : + encryptionKey : encryptionKey; + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else currentEncryptionLevel = 2; //awskms + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Dockerfile Template is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Dockerfile Template is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.spec.js b/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.spec.js new file mode 100644 index 000000000..baa7bbafa --- /dev/null +++ b/plugins/aws/imagebuilder/dockerfileTemplateEncrypted.spec.js @@ -0,0 +1,242 @@ +var expect = require('chai').expect; +var dockerfileTemplateEncrypted = require('./dockerfileTemplateEncrypted'); + +const listContainerRecipes = [ + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:container-recipe/test-dcr-image/1.0.0", + "containerType": "DOCKER", + "name": "test-dcr-image", + "platform": "Linux", + "owner": "000011112222", + "parentImage": "amazonlinux:latest", + "dateCreated": "2022-03-29T10:23:16.837Z", + "tags": {} + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:container-recipe/test-dcr-image1/1.0.0", + "containerType": "DOCKER", + "name": "test-dcr-image1", + "platform": "Linux", + "owner": "000011112222", + "parentImage": "amazonlinux:latest", + "dateCreated": "2022-03-29T10:26:05.280Z", + "tags": {} + }, +]; + + +const getContainerRecipe = [ + { + "requestId": "70fcd2ec-f5d6-4beb-999b-b1376d2cc69c", + "containerRecipe": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:container-recipe/test-dcr-image/1.0.0", + "containerType": "DOCKER", + "name": "test-dcr-image", + "platform": "Linux", + "owner": "000011112222", + "version": "1.0.0", + "components": [ + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/amazon-cloudwatch-agent-linux/x.x.x" + } + ], + "dockerfileTemplateData": "FROM {{{ imagebuilder:parentImage }}}\\n{{{ imagebuilder:environments }}}\\n{{{ imagebuilder:components }}}", + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3", + "encrypted": true, + "parentImage": "amazonlinux:latest", + "dateCreated": "2022-03-29T10:23:16.837Z", + "tags": {}, + "targetRepository": { + "service": "ECR", + "repositoryName": "sadeedrep" + } + } + }, + { + "requestId": "28a69c0b-1296-4220-a755-02edac9db0ce", + "containerRecipe": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:container-recipe/test-dcr-image1/1.0.0", + "containerType": "DOCKER", + "name": "test-dcr-image1", + "platform": "Linux", + "owner": "000011112222", + "version": "1.0.0", + "components": [ + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/amazon-cloudwatch-agent-linux/x.x.x" + } + ], + "dockerfileTemplateData": "FROM {{{ imagebuilder:parentImage }}}\\n{{{ imagebuilder:environments }}}\\n{{{ imagebuilder:components }}}", + "encrypted": true, + "parentImage": "amazonlinux:latest", + "dateCreated": "2022-03-29T10:26:05.280Z", + "tags": {}, + "targetRepository": { + "service": "ECR", + "repositoryName": "testrep" + } + } + } +]; + +const listAliases = [ + { + 'AliasName': 'alias/akhtar-key', + 'AliasArn': 'arn:aws:kms:us-east-1:000011112222:alias/akhtar-key', + 'TargetKeyId': '1425f881-3b10-486a-aca8-14d2649881e3', + 'CreationDate': '2022-03-29T10:22:52.016Z', + 'LastUpdatedDate': '2022-03-29T10:22:52.016Z' + } +]; + +const describeKey = [ + { + 'KeyMetadata': { + 'AWSAccountId': '000011112222', + 'KeyId': '1425f881-3b10-486a-aca8-14d2649881e3', + 'Arn': 'arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3', + 'CreationDate': '2022-03-29T10:22:51.516Z', + 'Enabled': true, + 'Description': '', + 'KeyUsage': 'ENCRYPT_DECRYPT', + 'KeyState': 'Enabled', + 'Origin': 'AWS_KMS', + 'KeyManager': 'CUSTOMER', + 'CustomerMasterKeySpec': 'SYMMETRIC_DEFAULT', + 'KeySpec': 'SYMMETRIC_DEFAULT', + 'EncryptionAlgorithms': [Array], + 'MultiRegion': false + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "1425f881-3b10-486a-aca8-14d2649881e3", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "1425f881-3b10-486a-aca8-14d2649881e3", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3" + } +] + +const createCache = (recipe, keys, kmsAliases, getContainerRecipe, describeKey, recipeErr, kmsAliasesErr, keysErr, describeKeyErr, getContainerRecipeErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var recipeArn = (recipe && recipe.length) ? recipe[0].arn: null; + return { + imagebuilder: { + listContainerRecipes: { + 'us-east-1': { + err: recipeErr, + data: recipe + }, + }, + getContainerRecipe: { + 'us-east-1': { + [recipeArn]: { + data: getContainerRecipe, + err: getContainerRecipeErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('dockerfileTemplateEncrypted', function () { + describe('run', function () { + it('should PASS if Dockerfile Template is encrypted with desired encryption level', function (done) { + const cache = createCache([listContainerRecipes[0]], listKeys, listAliases, getContainerRecipe[0], describeKey[0]); + dockerfileTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Dockerfile Template is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Dockerfile Template is encrypted with awskms', function (done) { + const cache = createCache([listContainerRecipes[1]], listKeys, listAliases, getContainerRecipe[1], describeKey[1]); + dockerfileTemplateEncrypted.run(cache, {docker_file_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Dockerfile Template is encrypted with'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No container recipes found', function (done) { + const cache = createCache([]); + dockerfileTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No container recipes found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get container recipe description', function (done) { + const cache = createCache([listContainerRecipes[0]], listKeys, listAliases, null, null, null, null, + null, null, { message: "Unable to get container recipe description" }); + dockerfileTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get container recipe description'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listContainerRecipes, null, null, null, null, null, null, { message: "Unable to list KMS keys" }); + dockerfileTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/imagebuilder/enhancedMetadataEnabled.js b/plugins/aws/imagebuilder/enhancedMetadataEnabled.js new file mode 100644 index 000000000..d6703d512 --- /dev/null +++ b/plugins/aws/imagebuilder/enhancedMetadataEnabled.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Enhanced Metadata Collection Enabled', + category: 'Image Builder', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that enhanced metadata collection is enabled for image pipelines.', + more_info: 'EC2 Image Builder is a fully managed AWS service that makes it easier to automate the creation, management, and deployment of customized, secure, and up-to-date server images that are pre-installed and pre-configured with software and settings to meet specific IT standards.', + link: 'https://docs.aws.amazon.com/imagebuilder/latest/userguide/start-build-image-pipeline.html', + recommended_action: 'Enable enhanced metadata collection for image pipeline.', + apis: ['Imagebuilder:listImagePipelines'], + realtime_triggers: ['imagebuilder:CreateImagePipeline','imagebuilder:UpdateImagePipeline','imagebuilder:DeleteImagePipeline'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + async.each(regions.imagebuilder, function(region, rcb){ + var listImagePipelines = helpers.addSource(cache, source, + ['imagebuilder', 'listImagePipelines', region]); + + if (!listImagePipelines) return rcb(); + + if (listImagePipelines.err || !listImagePipelines.data) { + helpers.addResult(results, 3, + 'Unable to list image pipeline: ' + helpers.addError(listImagePipelines), region); + return rcb(); + } + + if (!listImagePipelines.data.length) { + helpers.addResult(results, 0, + 'No Image Builder image pipelines found', region); + return rcb(); + } + + for (let image of listImagePipelines.data) { + if (!image.arn) continue; + + let resource = image.arn; + + if (image.enhancedImageMetadataEnabled) { + helpers.addResult(results, 0, + 'Image pipeline has enhanced metadata collection enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Image pipeline does not have enhanced metadata collection enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/imagebuilder/enhancedMetadataEnabled.spec.js b/plugins/aws/imagebuilder/enhancedMetadataEnabled.spec.js new file mode 100644 index 000000000..794cb0d44 --- /dev/null +++ b/plugins/aws/imagebuilder/enhancedMetadataEnabled.spec.js @@ -0,0 +1,109 @@ +var expect = require('chai').expect;; +var enhancedMetadataEnabled = require('./enhancedMetadataEnabled'); + +const listImagePipelines = [ + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-pipeline/akhtar-img-pipeline", + "name": "akhtar-img-pipeline", + "platform": "Linux", + "enhancedImageMetadataEnabled": true, + "imageRecipeArn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/akhtar-img-rc/1.0.0", + "infrastructureConfigurationArn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/akhtar-img-pipeline-914d5fdf-45db-4231-ae0e-991c39f9e594", + "distributionConfigurationArn": "arn:aws:imagebuilder:us-east-1:000011112222:distribution-configuration/akhtar-img-pipeline-914d5fdf-45db-4231-ae0e-991c39f9e594", + "imageTestsConfiguration": { + "imageTestsEnabled": true, + "timeoutMinutes": 720 + }, + "status": "ENABLED", + "dateCreated": "2022-03-08T11:20:43.395Z", + "dateUpdated": "2022-03-08T11:20:43.395Z", + "tags": {} + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-pipeline/akhtar-img-pipeline", + "name": "akhtar-img-pipeline", + "platform": "Linux", + "enhancedImageMetadataEnabled": false, + "imageRecipeArn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/akhtar-img-rc/1.0.0", + "infrastructureConfigurationArn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/akhtar-img-pipeline-914d5fdf-45db-4231-ae0e-991c39f9e594", + "distributionConfigurationArn": "arn:aws:imagebuilder:us-east-1:000011112222:distribution-configuration/akhtar-img-pipeline-914d5fdf-45db-4231-ae0e-991c39f9e594", + "imageTestsConfiguration": { + "imageTestsEnabled": true, + "timeoutMinutes": 720 + }, + "status": "ENABLED", + "dateCreated": "2022-03-08T11:20:43.395Z", + "dateUpdated": "2022-03-08T11:20:43.395Z", + "tags": {} + } +]; + +const createCache = (images) => { + return { + imagebuilder: { + listImagePipelines: { + "us-east-1": { + data: images + } + } + } + } +} + +const createNullCache = () => { + return { + imagebuilder: { + listImagePipelines: { + "us-east-1": { + data: null + } + } + } + } +} + +describe('enhancedMetadataEnabled', () => { + describe('run', () => { + it('should PASS if Image pipeline has enhanced metadata collection enabled', () => { + const cache = createCache([listImagePipelines[0]]); + enhancedMetadataEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Image pipeline has enhanced metadata collection enabled'); + expect(results[0].region).to.equal('us-east-1'); + }) + }); + it('should FAIL if Image pipeline does not have enhanced metadata collection enabled', () => { + const cache = createCache([listImagePipelines[1]]); + enhancedMetadataEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Image pipeline does not have enhanced metadata collection enabled'); + expect(results[0].region).to.equal('us-east-1'); + }) + }); + it('should PASS if No image pipeline list found', () => { + const cache = createCache([]); + enhancedMetadataEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Image Builder image pipelines found'); + expect(results[0].region).to.equal('us-east-1'); + }) + }); + it('should UNKNOWN if Unable to list image pipeline', () => { + const cache = createNullCache(); + enhancedMetadataEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list image pipeline'); + expect(results[0].region).to.equal('us-east-1'); + }) + }); + it('should not return anything if list image pipeline response is not found', () => { + enhancedMetadataEnabled.run({}, {}, (err, results) => { + expect(results.length).to.equal(0); + }) + }); + }); +}); diff --git a/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.js b/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.js new file mode 100644 index 000000000..6f93d806a --- /dev/null +++ b/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.js @@ -0,0 +1,151 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Image Recipe Storage Volumes Encrypted', + category: 'Image Builder', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Image Recipe storage ebs volumes are encrypted.', + more_info: 'EC2 Image Builder is a fully managed AWS service that makes it easier to automate the creation, management, and deployment of customized, secure, and up-to-date server images that are pre-installed and pre-configured with software and settings to meet specific IT standards.', + link: 'https://docs.aws.amazon.com/imagebuilder/latest/userguide/data-protection.html', + recommended_action: 'Ensure that storage volumes for ebs are encrypted using AWS keys or customer managed keys in Image recipe', + apis: ['Imagebuilder:listImageRecipes', 'Imagebuilder:getImageRecipe', 'KMS:listKeys', 'KMS:describeKey', + 'KMS:listAliases'], + settings: { + image_recipe_ebs_volumes_desired_encryption_level: { + name: 'Image Recipe EBS Volumes Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['Imagebuilder:CreateImageRecipe','Imagebuilder:DeleteImageRecipe'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.image_recipe_ebs_volumes_desired_encryption_level || this.settings.image_recipe_ebs_volumes_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.imagebuilder, function(region, rcb){ + var listImageRecipes = helpers.addSource(cache, source, + ['imagebuilder', 'listImageRecipes', region]); + + if (!listImageRecipes) return rcb(); + + if (listImageRecipes.err || !listImageRecipes.data) { + helpers.addResult(results, 3, + `Unable to query for image recipe summary list: ${helpers.addError(listImageRecipes)}`, region); + return rcb(); + } + + if (!listImageRecipes.data.length) { + helpers.addResult(results, 0, 'No Image Builder image recipes found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + 'Unable to list KMS keys:' + helpers.addError(listKeys), region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + 'Unable to query for KMS aliases: ' + helpers.addError(listAliases), + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasName] = keyArn; + }); + + for (let recipe of listImageRecipes.data) { + if (!recipe.arn) continue; + + let resource = recipe.arn; + var getImageRecipe = helpers.addSource(cache, source, + ['imagebuilder', 'getImageRecipe', region, recipe.arn]); + + if (!getImageRecipe || getImageRecipe.err || !getImageRecipe.data || !getImageRecipe.data.imageRecipe) { + helpers.addResult(results, 3, + `Unable to get image Recipe description: ${helpers.addError(getImageRecipe)}`, + region, resource); + continue; + } + let poorlyEncrypted = []; + for (let mapping of getImageRecipe.data.imageRecipe.blockDeviceMappings){ + if (mapping.ebs && !mapping.ebs.encrypted){ + poorlyEncrypted.push(mapping.ebs); + continue; + } + + if (mapping.ebs && mapping.ebs.kmsKeyId && mapping.ebs.kmsKeyId.includes('alias/aws/ebs')){ + currentEncryptionLevel = 2; + } else { + var encryptionKey = mapping.ebs.kmsKeyId; + var encryptionKeyArr = encryptionKey.split(':'); + encryptionKey = encryptionKeyArr[encryptionKeyArr.length-1]; + let kmsKeyArn = (encryptionKey.includes('alias/')) ? + (kmsAliasArnMap[encryptionKey]) ? kmsAliasArnMap[encryptionKey] : + encryptionKey : encryptionKey; + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + } + + if (poorlyEncrypted.length){ + helpers.addResult(results, 2, + 'Image recipe : ' + poorlyEncrypted.length + ' ebs volumes does not have encryption enabled', + region, resource); + continue; + } + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Image recipe ebs volumes are encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Image recipe ebs volumes are encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.spec.js b/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.spec.js new file mode 100644 index 000000000..38c943350 --- /dev/null +++ b/plugins/aws/imagebuilder/imageRecipeVolumeEncrypted.spec.js @@ -0,0 +1,331 @@ +var expect = require('chai').expect; +var imageRecipeVolumeEncrypted = require('./imageRecipeVolumeEncrypted'); + +const listImageRecipes = [ + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/akhtar-img-rc/1.0.0", + "name": "akhtar-img-rc", + "platform": "Linux", + "owner": "000011112222", + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "dateCreated": "2022-03-08T10:04:38.931Z", + "tags": {} + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/mine3/3.0.0", + "name": "mine3", + "platform": "Linux", + "owner": "000011112222", + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "dateCreated": "2022-05-17T09:27:41.059Z", + "tags": {} + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/myrecipe1/1.0.2", + "name": "myrecipe1", + "platform": "Linux", + "owner": "000011112222", + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "dateCreated": "2022-06-22T08:33:58.556Z", + "tags": {} + } +]; + +const getImageRecipe = [ + { + "requestId": "f82f5f6b-1ed5-49c2-86a6-1a264b7db458", + "imageRecipe": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/akhtar-img-rc/1.0.2", + "name": "akhtar-img-rc", + "platform": "Linux", + "owner": "000011112222", + "version": "1.0.2", + "components": [ + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/amazon-cloudwatch-agent-linux/x.x.x" + } + ], + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "blockDeviceMappings": [ + { + "deviceName": "/dev/xvda", + "ebs": { + "encrypted": false, + "deleteOnTermination": true, + "volumeSize": 8, + "volumeType": "gp2" + } + }, + { + "deviceName": "/dev/sdb", + "ebs": { + "encrypted": true, + "deleteOnTermination": false, + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:alias/aws/ebs", + "volumeSize": 8, + "volumeType": "gp2" + } + } + ], + "dateCreated": "2022-03-08T10:42:03.172Z", + "tags": {}, + "workingDirectory": "/tmp", + "additionalInstanceConfiguration": { + "systemsManagerAgent": { + "uninstallAfterBuild": false + } + } + } + }, + { + "requestId": "ebeb7bb6-9ff6-43b5-aaf7-40f3f6c5f0e3", + "imageRecipe": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/mine3/3.0.0", + "name": "mine3", + "platform": "Linux", + "owner": "000011112222", + "version": "3.0.0", + "components": [ + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/amazon-cloudwatch-agent-linux/x.x.x" + }, + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/chrony-time-configuration-test/x.x.x" + } + ], + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "blockDeviceMappings": [ + { + "deviceName": "/dev/xvda", + "ebs": { + "encrypted": true, + "deleteOnTermination": true, + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:alias/aws/ebs", + "volumeSize": 8, + "volumeType": "gp2" + } + } + ], + "dateCreated": "2022-05-17T09:27:41.059Z", + "tags": {}, + "workingDirectory": "/tmp", + "additionalInstanceConfiguration": { + "systemsManagerAgent": { + "uninstallAfterBuild": false + } + } + } + }, + { + "requestId": "1e87da1a-92f6-4486-97dc-98a039ad4c3a", + "imageRecipe": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:image-recipe/myrecipe1/1.0.2", + "name": "myrecipe1", + "platform": "Linux", + "owner": "000011112222", + "version": "1.0.2", + "components": [ + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/amazon-cloudwatch-agent-linux/x.x.x" + }, + { + "componentArn": "arn:aws:imagebuilder:us-east-1:aws:component/chrony-time-configuration-test/x.x.x" + } + ], + "parentImage": "arn:aws:imagebuilder:us-east-1:aws:image/amazon-linux-2-arm64/x.x.x", + "blockDeviceMappings": [ + { + "deviceName": "/dev/xvda", + "ebs": { + "encrypted": true, + "deleteOnTermination": true, + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:alias/sadeed-k1", + "volumeSize": 1, + "volumeType": "gp2" + } + } + ], + "dateCreated": "2022-06-22T08:33:58.556Z", + "tags": {}, + "workingDirectory": "/tmp", + "additionalInstanceConfiguration": { + "systemsManagerAgent": { + "uninstallAfterBuild": true + } + } + } + } + +]; + + +const listAliases = [ + { + "AliasName": "alias/sadeed-k1", + "AliasArn": "arn:aws:kms:us-east-1:000111222333:alias/sadeed-k1", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2021-11-15T17:05:31.308000+05:00", + "LastUpdatedDate": "2021-11-15T17:05:31.308000+05:00" + }, +]; + +var describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + + +const createCache = (analyzer, keys, kmsAliases, getImageRecipe, describeKey, analyzerErr, kmsAliasesErr, keysErr, describeKeyErr, getImageRecipeErr) => { + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var analyzerArn = (analyzer && analyzer.length) ? analyzer[0].arn: null; + return { + imagebuilder: { + listImageRecipes: { + 'us-east-1': { + err: analyzerErr, + data: analyzer + }, + }, + getImageRecipe: { + 'us-east-1': { + [analyzerArn]: { + data:getImageRecipe, + err: getImageRecipeErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('imageRecipeVolumeEncrypted', function () { + describe('run', function () { + it('should PASS if Image recipe ebs volumes are encrypted with awscmk', function (done) { + const cache = createCache([listImageRecipes[2]], listKeys, listAliases, getImageRecipe[2], describeKey[0]); + imageRecipeVolumeEncrypted.run(cache, { image_recipe_ebs_volumes_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Image recipe ebs volumes are encrypted with awscmk'); + done(); + }); + }); + + it('should FAIL if Image recipe ebs volumes does not have encryption enabled', function (done) { + const cache = createCache([listImageRecipes[0]], listKeys, listAliases, getImageRecipe[0], describeKey[0]); + imageRecipeVolumeEncrypted.run(cache, { image_recipe_ebs_volumes_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('ebs volumes does not have encryption enabled'); + done(); + }); + }); + + it('should FAIL if Image recipe ebs volumes are encrypted with awskms', function (done) { + const cache = createCache([listImageRecipes[1]], listKeys, listAliases, getImageRecipe[1], describeKey[1]); + imageRecipeVolumeEncrypted.run(cache, { image_recipe_ebs_volumes_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Image recipe ebs volumes are encrypted with awskms'); + done(); + }); + }); + + it('should PASS if No Image Builder image recipes found', function (done) { + const cache = createCache([]); + imageRecipeVolumeEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Image Builder image recipes found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for image recipe summary list', function (done) { + const cache = createCache(null, null, { message: "Unable to query for image recipe summary list" }); + imageRecipeVolumeEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for image recipe summary list'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache([listImageRecipes[0]], null, null, null, { message: "Unable to list KMS keys" }); + imageRecipeVolumeEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.js b/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.js new file mode 100644 index 000000000..53458a6b2 --- /dev/null +++ b/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.js @@ -0,0 +1,134 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Image Builder Components Encrypted', + category: 'Image Builder', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Image Builder components are encrypted.', + more_info: 'Build components contain software, settings, and configurations that are installed or applied during the process of building custom images. Tests are run after a custom image is built to validate functionality, security, performance, etc. Custom components are encrypted with your KMS key or a KMS key owned by Image Builder.', + link: 'https://docs.aws.amazon.com/imagebuilder/latest/userguide/data-protection.html', + recommended_action: 'Ensure that components are encrypted using AWS keys or customer managed keys in Image Builder service', + apis: ['Imagebuilder:listComponents', 'Imagebuilder:getComponent', 'KMS:listAliases', 'KMS:listKeys', + 'KMS:describeKey'], + settings: { + image_component_desired_encryption_level: { + name: 'Image Builder Component Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['Imagebuilder:CreateComponent','Imagebuilder:DeleteComponent'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.image_component_desired_encryption_level || this.settings.image_component_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.imagebuilder, function(region, rcb){ + var listComponents = helpers.addSource(cache, source, + ['imagebuilder', 'listComponents', region]); + + if (!listComponents) return rcb(); + + if (listComponents.err) { + helpers.addResult(results, 3, + 'Unable to query component version list: ' + helpers.addError(listComponents), region); + return rcb(); + } + + if (!listComponents.data || !listComponents.data.length) { + helpers.addResult(results, 0, 'No component version list found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasArn] = keyArn; + }); + + for (let recipe of listComponents.data) { + let resource = recipe.arn; + + var getComponent = helpers.addSource(cache, source, + ['imagebuilder', 'getComponent', region, recipe.arn]); + + if (!getComponent || getComponent.err || !getComponent.data || + !getComponent.data.component) { + helpers.addResult(results, 3, + `Unable to query for component description: ${helpers.addError(getComponent)}`, + region, resource); + continue; + } + + if (getComponent.data.component.kmsKeyId) { + var encryptionKey = getComponent.data.component.kmsKeyId; + let kmsKeyArn = (encryptionKey.includes('alias/')) ? + (kmsAliasArnMap[encryptionKey]) ? kmsAliasArnMap[encryptionKey] : + encryptionKey : encryptionKey; + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else currentEncryptionLevel = 2; //awskms + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Image Builder component is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Image Builder component is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.spec.js b/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.spec.js new file mode 100644 index 000000000..6eeb0bba4 --- /dev/null +++ b/plugins/aws/imagebuilder/imgBuilderComponentsEncrypted.spec.js @@ -0,0 +1,226 @@ +var expect = require('chai').expect; +var imgBuilderComponentsEncrypted = require('./imgBuilderComponentsEncrypted'); + +const listComponents = [ + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:component/akhtar-compo/1.0.0", + "name": "akhtar-compo", + "version": "1.0.0", + "platform": "Linux", + "type": "BUILD", + "owner": "000011112222", + "dateCreated": "2022-03-29T10:42:58.859Z" + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:component/sadeedcomponent/1.0.0", + "name": "sadeedcomponent", + "version": "1.0.0", + "platform": "Linux", + "supportedOsVersions": [ + "Amazon Linux 2" + ], + "type": "BUILD", + "owner": "000011112222", + "dateCreated": "2022-03-24T15:31:10.328Z" + } +]; + + +const getComponent = [ + { + "requestId": "c061f565-2a9c-4fb5-9eb3-be2da3816257", + "component": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:component/akhtar-compo/1.0.0/1", + "name": "akhtar-compo", + "version": "1.0.0", + "type": "BUILD", + "platform": "Linux", + "owner": "000011112222", + "data": "name: HelloWorldTestingDocument\ndescription: This is hello world testing document.\nschemaVersion: 1.0\n\nphases:\n - name: build\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Build.\"\n\n - name: validate\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Validate.\"\n\n - name: test\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Test.\"\n", + "kmsKeyId": "arn:aws:kms:us-east-1:000011112222:alias/akhtar-key", + "encrypted": true, + "dateCreated": "2022-03-29T10:42:58.859Z", + "tags": {} + } + }, + { + "requestId": "9705dd22-00d6-4e31-beec-37b3d025e943", + "component": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:component/sadeedcomponent/1.0.0/1", + "name": "sadeedcomponent", + "version": "1.0.0", + "type": "BUILD", + "platform": "Linux", + "supportedOsVersions": [ + "Amazon Linux 2" + ], + "owner": "000011112222", + "data": "name: HelloWorldTestingDocument\ndescription: This is hello world testing document.\nschemaVersion: 1.0\n\nphases:\n - name: build\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Build.\"\n\n - name: validate\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Validate.\"\n\n - name: test\n steps:\n - name: HelloWorldStep\n action: ExecuteBash\n inputs:\n commands:\n - echo \"Hello World! Test.\"\n", + "encrypted": true, + "dateCreated": "2022-03-24T15:31:10.328Z", + "tags": {} + } + } +]; + +const listAliases = [ + { + 'AliasName': 'alias/akhtar-key', + 'AliasArn': 'arn:aws:kms:us-east-1:000011112222:alias/akhtar-key', + 'TargetKeyId': '1425f881-3b10-486a-aca8-14d2649881e3', + 'CreationDate': '2022-03-29T10:22:52.016Z', + 'LastUpdatedDate': '2022-03-29T10:22:52.016Z' + } +]; + +const describeKey = [ + { + 'KeyMetadata': { + 'AWSAccountId': '000011112222', + 'KeyId': '1425f881-3b10-486a-aca8-14d2649881e3', + 'Arn': 'arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3', + 'CreationDate': '2022-03-29T10:22:51.516Z', + 'Enabled': true, + 'Description': '', + 'KeyUsage': 'ENCRYPT_DECRYPT', + 'KeyState': 'Enabled', + 'Origin': 'AWS_KMS', + 'KeyManager': 'CUSTOMER', + 'CustomerMasterKeySpec': 'SYMMETRIC_DEFAULT', + 'KeySpec': 'SYMMETRIC_DEFAULT', + 'EncryptionAlgorithms': [Array], + 'MultiRegion': false + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "1425f881-3b10-486a-aca8-14d2649881e3", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "1425f881-3b10-486a-aca8-14d2649881e3", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/1425f881-3b10-486a-aca8-14d2649881e3" + } +] + +const createCache = (recipe, keys, kmsAliases, getComponent, describeKey, recipeErr, kmsAliasesErr, keysErr, describeKeyErr, getComponentErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var recipeArn = (recipe && recipe.length) ? recipe[0].arn: null; + return { + imagebuilder: { + listComponents: { + 'us-east-1': { + err: recipeErr, + data: recipe + }, + }, + getComponent: { + 'us-east-1': { + [recipeArn]: { + data: getComponent, + err: getComponentErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('imgBuilderComponentsEncrypted', function () { + describe('run', function () { + it('should PASS if Image Builder component is encrypted with awscmk', function (done) { + const cache = createCache([listComponents[0]], listKeys, listAliases, getComponent[0], describeKey[0]); + imgBuilderComponentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Image Builder component is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Image Builder component is encrypted with awskms', function (done) { + const cache = createCache([listComponents[1]], listKeys, listAliases, getComponent[1], describeKey[1]); + imgBuilderComponentsEncrypted.run(cache, {image_component_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Image Builder component is encrypted with'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no component version list found', function (done) { + const cache = createCache([]); + imgBuilderComponentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No component version list found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for component description', function (done) { + const cache = createCache([listComponents[0]], listKeys, listAliases, null, null, null, null, + null, null, { message: "Unable to query for component description" }); + imgBuilderComponentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for component description'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listComponents, null, null, null, null, null, null, { message: "Unable to list KMS keys" }); + imgBuilderComponentsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/imagebuilder/infraConfigNotificationEnabled.js b/plugins/aws/imagebuilder/infraConfigNotificationEnabled.js new file mode 100644 index 000000000..7bd10f4a7 --- /dev/null +++ b/plugins/aws/imagebuilder/infraConfigNotificationEnabled.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Infrastructure Configuration Notification Enabled', + category: 'Image Builder', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Image Builder infrastructure configurations have SNS notifications enabled.', + more_info: 'Infrastructure configurations allow you to specify the infrastructure within which to build and test your EC2 Image Builder image.', + link: 'https://docs.aws.amazon.com/imagebuilder/latest/userguide/manage-infra-config.html', + recommended_action: 'Enable SNS notification in EC2 Image Builder infrastructure configurations to get notified of any changes in the service.', + apis: ['Imagebuilder:listInfrastructureConfigurations', 'Imagebuilder:getInfrastructureConfiguration'], + realtime_triggers: ['Imagebuilder:CreateInfrastructureConfiguration','Imagebuilder:UpdateInfrastructureConfiguration','Imagebuilder:DeleteInfrastructureConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.imagebuilder, function(region, rcb){ + var listInfrastructureConfigurations = helpers.addSource(cache, source, + ['imagebuilder', 'listInfrastructureConfigurations', region]); + + if (!listInfrastructureConfigurations) return rcb(); + + if (listInfrastructureConfigurations.err || !listInfrastructureConfigurations.data) { + helpers.addResult(results, 3, + `Unable to query for infrastructure configuration summary List: ${helpers.addError(listInfrastructureConfigurations)}`, region); + return rcb(); + } + + if (!listInfrastructureConfigurations.data.length) { + helpers.addResult(results, 0, 'No list infrastructure configuration found', region); + return rcb(); + } + + for (let recipe of listInfrastructureConfigurations.data) { + if (!recipe.arn) continue; + + let resource = recipe.arn; + + var getInfrastructureConfiguration = helpers.addSource(cache, source, + ['imagebuilder', 'getInfrastructureConfiguration', region, recipe.arn]); + + if (!getInfrastructureConfiguration || getInfrastructureConfiguration.err || !getInfrastructureConfiguration.data) { + helpers.addResult(results, 3, + `Unable to get infrastructure configuration description: ${helpers.addError(getInfrastructureConfiguration)}`, + region, resource); + continue; + } + + if (getInfrastructureConfiguration.data.infrastructureConfiguration && + getInfrastructureConfiguration.data.infrastructureConfiguration.snsTopicArn) { + helpers.addResult(results, 0, + 'Infrastructure configuration has SNS notifications enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Infrastructure configuration does not have SNS notifications enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/imagebuilder/infraConfigNotificationEnabled.spec.js b/plugins/aws/imagebuilder/infraConfigNotificationEnabled.spec.js new file mode 100644 index 000000000..327889d9a --- /dev/null +++ b/plugins/aws/imagebuilder/infraConfigNotificationEnabled.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var infraConfigNotificationEnabled = require('./infraConfigNotificationEnabled'); + +const listInfrastructureConfigurations = [ + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/akhtar-conf", + "name": "akhtar-conf", + "dateCreated": "2022-03-08T10:51:11.222Z", + "tags": {}, + "instanceProfileName": "AmazonSSMRoleForInstancesQuickSetup" + }, + { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/sadeedinfra", + "name": "sadeedinfra", + "dateCreated": "2022-03-24T15:38:07.970Z", + "tags": {}, + "instanceTypes": [ + "a1.2xlarge" + ], + "instanceProfileName": "AmazonSSMRoleForInstancesQuickSetup" + } +]; + +const getInfrastructureConfiguration = [ + { + "requestId": "d54c83f4-7d84-4fd2-b79d-88e8e7f20f5e", + "infrastructureConfiguration": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/sadeedinfra", + "name": "sadeedinfra", + "instanceTypes": [ + "a1.2xlarge" + ], + "instanceProfileName": "AmazonSSMRoleForInstancesQuickSetup", + "logging": { + "s3Logs": {} + }, + "terminateInstanceOnFailure": true, + "snsTopicArn": "arn:aws:sns:us-east-1:000011112222:mine1", + "dateCreated": "2022-03-24T15:38:07.970Z", + "tags": {} + } + }, + { + "requestId": "538dfa42-bcce-412e-9cb4-5abc1a1b5be0", + "infrastructureConfiguration": { + "arn": "arn:aws:imagebuilder:us-east-1:000011112222:infrastructure-configuration/akhtar-conf", + "name": "akhtar-conf", + "instanceProfileName": "AmazonSSMRoleForInstancesQuickSetup", + "logging": { + "s3Logs": {} + }, + "terminateInstanceOnFailure": true, + "dateCreated": "2022-03-08T10:51:11.222Z", + "tags": {} + } + } +]; + + +const createCache = (recipe, getInfrastructureConfiguration, recipeErr, getInfrastructureConfigurationErr) => { + var recipeArn = (recipe && recipe.length) ? recipe[0].arn: null; + return { + imagebuilder: { + listInfrastructureConfigurations: { + 'us-east-1': { + err: recipeErr, + data: recipe + }, + }, + getInfrastructureConfiguration: { + 'us-east-1': { + [recipeArn]: { + data:getInfrastructureConfiguration, + err: getInfrastructureConfigurationErr + } + } + } + }, + }; +}; + +describe('infraConfigNotificationEnabled', function () { + describe('run', function () { + it('should FAIL if Infrastructure configuration does not have SNS notifications enabled', function (done) { + const cache = createCache([listInfrastructureConfigurations[0]], getInfrastructureConfiguration[1]); + infraConfigNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Infrastructure configuration does not have SNS notifications enabled'); + done(); + }); + }); + + it('should PASS if Infrastructure configuration has SNS notifications enabled', function (done) { + const cache = createCache([listInfrastructureConfigurations[1]], getInfrastructureConfiguration[0]); + infraConfigNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Infrastructure configuration has SNS notifications enabled'); + done(); + }); + }); + + it('should PASS if No list infrastructure configuration found', function (done) { + const cache = createCache([]); + infraConfigNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No list infrastructure configuration found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for infrastructure configuration summary List', function (done) { + const cache = createCache(null, null, { message: "Unable to query for infrastructure configuration summary List" }); + infraConfigNotificationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for infrastructure configuration summary List'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.js b/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.js new file mode 100644 index 000000000..8b39d1868 --- /dev/null +++ b/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'IoT SiteWise Data Encrypted', + category: 'IoT SiteWise', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that AWS IoT SiteWise is using desired encryption level for data at-rest.', + more_info: 'AWS IoT SiteWise encrypts data such as your asset property values and aggregate values by default.'+ + 'It is recommended to use customer managed keys in order to gain more control over data encryption/decryption process.', + link: 'https://docs.aws.amazon.com/iot-sitewise/latest/userguide/encryption-at-rest.html', + recommended_action: 'Update IoT SiteWise encryption configuration to use a CMK.', + apis: ['IoTSiteWise:describeDefaultEncryptionConfiguration', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + iot_sitewise_data_desired_encryption_level: { + name: 'IoT SiteWise Data Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['imagebuilder:PutDefaultEncryptionConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.iot_sitewise_data_desired_encryption_level || this.settings.iot_sitewise_data_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.iotsitewise, function(region, rcb){ + var describeDefaultEncryptionConfiguration = helpers.addSource(cache, source, + ['iotsitewise', 'describeDefaultEncryptionConfiguration', region]); + + if (!describeDefaultEncryptionConfiguration) return rcb(); + + if (describeDefaultEncryptionConfiguration.err || !describeDefaultEncryptionConfiguration.data) { + helpers.addResult(results, 3, `Unable to query IoT SiteWise encryption configuration: ${helpers.addError(describeDefaultEncryptionConfiguration)}`, region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + if (describeDefaultEncryptionConfiguration.data.encryptionType == 'KMS_BASED_ENCRYPTION' && + describeDefaultEncryptionConfiguration.data.kmsKeyArn) { + let kmsKeyArn = describeDefaultEncryptionConfiguration.data.kmsKeyArn; + var kmsKeyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, region, kmsKeyArn); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `IoT SiteWise is configured to use encryption at level ${currentEncryptionLevelString} which is greater than or equal to desired level ${config.desiredEncryptionLevelString}`, + region); + } else { + helpers.addResult(results, 2, + `IoT SiteWise is configured to use encryption at level ${currentEncryptionLevelString} which is less than desired level ${config.desiredEncryptionLevelString}`, + region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.spec.js b/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.spec.js new file mode 100644 index 000000000..3d72d14a4 --- /dev/null +++ b/plugins/aws/iotsitewise/iotsitewiseDataEncrypted.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var iotsitewiseDataEncrypted = require('./iotsitewiseDataEncrypted'); + +var describeDefaultEncryptionConfiguration = [ + { + "encryptionType": "KMS_BASED_ENCRYPTION", + "kmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "configurationStatus": { + "state": "ACTIVE" + } + }, + { + "encryptionType": "SITEWISE_DEFAULT_ENCRYPTION", + "kmsKeyArn": null, + "configurationStatus": { + "state": "ACTIVE" + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +var describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (describeDefaultEncryptionConfiguration, keys, describeKey, describeDefaultEncryptionConfigurationErr, keysErr, describeKeyErr) => { + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + return { + iotsitewise: { + describeDefaultEncryptionConfiguration: { + 'us-east-1': { + err: describeDefaultEncryptionConfigurationErr, + data: describeDefaultEncryptionConfiguration + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + iotsitewise: { + 'us-east-1': { + describeDefaultEncryptionConfiguration: null + } + } + } +} + +describe('iotsitewiseDataEncrypted', function () { + describe('run', function () { + it('should FAIL if current encryption level is less than desired encryption level', function (done) { + const cache = createCache(describeDefaultEncryptionConfiguration[1], listKeys, describeKey[1]); + iotsitewiseDataEncrypted.run(cache, {iot_sitewise_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if current encryption level is greater than or equal to desired encryption level', function (done) { + const cache = createCache(describeDefaultEncryptionConfiguration[0], listKeys, describeKey[0]); + iotsitewiseDataEncrypted.run(cache, { iot_sitewise_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for IoT SiteWise encryption configuration', function (done) { + const cache = createCache(null); + iotsitewiseDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query KMS key', function (done) { + const cache = createCache(describeDefaultEncryptionConfiguration[0]); + iotsitewiseDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should return nothing if describe IoT SiteWise encryption config result not found', function (done) { + const cache = createNullCache(); + iotsitewiseDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/kendra/kendraIndexEncrypted.js b/plugins/aws/kendra/kendraIndexEncrypted.js new file mode 100644 index 000000000..8494393f3 --- /dev/null +++ b/plugins/aws/kendra/kendraIndexEncrypted.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Kendra Index Encrypted', + category: 'AI & ML', + domain: 'Databases', + severity: 'High', + description: 'Ensure that the Kendra index is encrypted using desired encryption level.', + more_info: 'Amazon Kendra encrypts your data at rest with AWS-manager keys by default. Use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create Kendra Index with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/kendra/latest/dg/encryption-at-rest.html', + apis: ['Kendra:listIndices','Kendra:describeIndex', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + kendra_index_desired_encryption_level: { + name: 'Kendra Index Encrypted', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['kendra:CreateIndex','kendra:UpdateIndex','kendra:DeleteIndex'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.kendra_index_desired_encryption_level || this.settings.kendra_index_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.kendra, function(region, rcb){ + var listIndices = helpers.addSource(cache, source, + ['kendra', 'listIndices', region]); + + if (!listIndices) return rcb(); + + if (listIndices.err || !listIndices.data) { + helpers.addResult(results, 3, + 'Unable to query Kendra Indices: ' + helpers.addError(listIndices), region); + return rcb(); + } + + if (!listIndices.data.length) { + helpers.addResult(results, 0, 'No Kendra Indices found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let index of listIndices.data) { + var resource = `arn:${awsOrGov}:kendra:${region}:${accountId}:index/${index.Name}`; + + var describeIndex = helpers.addSource(cache, source, + ['kendra', 'describeIndex', region, index.Id]); + + if (!describeIndex || describeIndex.err || !describeIndex.data ) { + helpers.addResult(results, 3, + `Unable to get Kendra index description: ${helpers.addError(describeIndex)}`, + region, resource); + continue; + } + + if (describeIndex.data.ServerSideEncryptionConfiguration && + describeIndex.data.ServerSideEncryptionConfiguration.KmsKeyId) { + var kmsKeyId = describeIndex.data.ServerSideEncryptionConfiguration.KmsKeyId; + var keyId = kmsKeyId.split('/')[1] ? kmsKeyId.split('/')[1] : kmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Kendra index is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Kendra index is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/kendra/kendraIndexEncrypted.spec.js b/plugins/aws/kendra/kendraIndexEncrypted.spec.js new file mode 100644 index 000000000..57a6e5771 --- /dev/null +++ b/plugins/aws/kendra/kendraIndexEncrypted.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var kendraIndexEncrypted = require('./kendraIndexEncrypted'); + +const listIndices = [ + { + "Name": "sadeed2", + "Id": "1b5d0d81-224e-4a8b-bbc8-2c2a4a0a615c", + "Edition": "DEVELOPER_EDITION", + "CreatedAt": "2021-11-17T15:29:30.124000+05:00", + "UpdatedAt": "2021-11-17T15:29:30.124000+05:00", + "Status": "CREATING" + }, + { + "Name": "sadeed1", + "Id": "9280dadd-5d45-4f9c-a105-896e5b230c05", + "Edition": "DEVELOPER_EDITION", + "CreatedAt": "2021-11-17T15:23:58.841000+05:00", + "UpdatedAt": "2021-11-17T15:23:58.841000+05:00", + "Status": "CREATING" + } +]; + +const describeIndex = [ + { + "Name": "sadeed1", + "Id": "9280dadd-5d45-4f9c-a105-896e5b230c05", + "Edition": "DEVELOPER_EDITION", + "RoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonKendra-us-east-1-role1", + "ServerSideEncryptionConfiguration": { + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + "Status": "ACTIVE", + "CreatedAt": "2021-11-17T15:23:58.841000+05:00", + "UpdatedAt": "2021-11-17T15:23:58.841000+05:00", + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (index, keys, describeIndex, describeKey, indexErr, keysErr, describeKeyErr, describeIndexErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var id = (index && index.length) ? index[0].Id: null; + return { + kendra: { + listIndices: { + 'us-east-1': { + err: indexErr, + data: index + }, + }, + describeIndex: { + 'us-east-1': { + [id]: { + data: describeIndex, + err: describeIndexErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('kendraIndexEncrypted', function () { + describe('run', function () { + it('should PASS if Kendra Indices is encrypted with desired encryption level', function (done) { + const cache = createCache([listIndices[0]], listKeys, describeIndex[0], describeKey[0]); + kendraIndexEncrypted.run(cache, { kendra_index_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Kendra Indices is not encrypted with desired encryption level', function (done) { + const cache = createCache([listIndices[1]],listKeys, describeIndex[0], describeKey[1]); + kendraIndexEncrypted.run(cache, { kendra_index_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Kendra Indices found', function (done) { + const cache = createCache([]); + kendraIndexEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Kendra Indices', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Kendra Indices" }); + kendraIndexEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listIndices, null, null, null, null, { message: "Unable to list KMS keys" }); + kendraIndexEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/kinesis/kinesisDataStreamsEncrypted.js b/plugins/aws/kinesis/kinesisDataStreamsEncrypted.js new file mode 100644 index 000000000..8aa16ea2a --- /dev/null +++ b/plugins/aws/kinesis/kinesisDataStreamsEncrypted.js @@ -0,0 +1,144 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Kinesis Data Streams Encrypted', + category: 'Kinesis', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures Kinesis data streams are encrypted using AWS KMS key of desired encryption level.', + more_info: 'Data sent to Kinesis data streams can be encrypted using KMS server-side encryption. Existing streams can be modified to add encryption with minimal overhead. '+ + 'Use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Enable encryption using desired level for all Kinesis streams', + link: 'https://docs.aws.amazon.com/streams/latest/dev/server-side-encryption.html', + apis: ['Kinesis:listStreams', 'Kinesis:describeStream', 'KMS:listKeys', 'KMS:describeKey', + 'KMS:listAliases', 'STS:getCallerIdentity'], + settings: { + data_streams_desired_encryption_level: { + name: 'Kinesis Data Stream Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['kinesis:CreateStream','kinesis:StartStreamEncryption','kinesis:StopStreamEncryption','kinesis:DeleteStream'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.data_streams_desired_encryption_level || this.settings.data_streams_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.kinesis, function(region, rcb){ + var listStreams = helpers.addSource(cache, source, + ['kinesis', 'listStreams', region]); + + if (!listStreams) return rcb(); + + if (listStreams.err) { + helpers.addResult(results, 3, + 'Unable to query for Kinesis streams: ' + helpers.addError(listStreams), region); + return rcb(); + } + + if (!listStreams.data || !listStreams.data.length) { + helpers.addResult(results, 0, 'No Kinesis streams found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + 'Unable to list KMS keys:' + helpers.addError(listKeys), region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + 'Unable to query for KMS aliases: ' + helpers.addError(listAliases), + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasName] = keyArn; + }); + + for (let stream of listStreams.data) { + let resource = `arn:${awsOrGov}:kinesis:${region}:${accountId}:stream/${stream}`; + + var describeStream = helpers.addSource(cache, source, + ['kinesis', 'describeStream', region, stream]); + + if (!describeStream || describeStream.err || !describeStream.data || !describeStream.data.StreamDescription) { + helpers.addResult(results, 3, + 'Unable to query Kinesis for stream: ' + stream + ': ' + helpers.addError(describeStream), + region, resource); + continue; + } + + if (describeStream.data.StreamDescription.KeyId) { + var encryptionKey = describeStream.data.StreamDescription.KeyId; + + let kmsKeyArn = (encryptionKey.startsWith('alias/')) ? + (kmsAliasArnMap[encryptionKey]) ? kmsAliasArnMap[encryptionKey] : + encryptionKey : encryptionKey; + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Kinesis stream is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Kinesis stream is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Kinesis stream does not have encryption enabled', + region, resource); + } + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/kinesis/kinesisDataStreamsEncrypted.spec.js b/plugins/aws/kinesis/kinesisDataStreamsEncrypted.spec.js new file mode 100644 index 000000000..12edd5e58 --- /dev/null +++ b/plugins/aws/kinesis/kinesisDataStreamsEncrypted.spec.js @@ -0,0 +1,274 @@ +var expect = require('chai').expect; +var kinesisDataStreamsEncrypted = require('./kinesisDataStreamsEncrypted'); + +const listStreams = [ + "mine2", + "mine3" +]; + + +const describeStream = [ + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625307919684448350212376424208934206295946525447028738" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:000011112222:stream/mine2", + "StreamName": "mine2", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "StreamCreationTimestamp": "2021-12-29T17:48:10+05:00" + } + }, + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625308470958869657889380482971889906163549124874469378" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:000011112222:stream/mine3", + "StreamName": "mine3", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "KMS", + "KeyId": "alias/aws/kinesis", + "StreamCreationTimestamp": "2021-12-29T18:13:55+05:00" + } + }, + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625308561945910067894322900437620458568881592741134338" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:000011112222:stream/mine1", + "StreamName": "mine1", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "NONE", + "KeyId": null, + "StreamCreationTimestamp": "2021-12-29T18:18:10+05:00" + } + } +]; + +const listAliases = [ + { + "AliasName": "alias/sadeed-k1", + "AliasArn": "arn:aws:kms:us-east-1:000111222333:alias/sadeed-k1", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2021-11-15T17:05:31.308000+05:00", + "LastUpdatedDate": "2021-11-15T17:05:31.308000+05:00" + }, + { + "AliasName": "alias/aws/kinesis", + "AliasArn": "arn:aws:kms:us-east-1:000111222333:alias/aws/kinesis", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2021-11-15T17:05:31.308000+05:00", + "LastUpdatedDate": "2021-11-15T17:05:31.308000+05:00" + }, +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (streams, keys, kmsAliases, describeStream, describeKey, streamsErr, kmsAliasesErr, keysErr, describeKeyErr, describeStreamErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var stream = (streams && streams.length) ? streams[0]: null; + return { + kinesis: { + listStreams: { + 'us-east-1': { + err: streamsErr, + data: streams + }, + }, + describeStream: { + 'us-east-1': { + [stream]: { + data: describeStream, + err: describeStreamErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('kinesisDataStreamsEncrypted', function () { + describe('run', function () { + it('should PASS if Kinesis stream is encrypted with desired encryption level', function (done) { + const cache = createCache([listStreams[0]], listKeys, [listAliases[0]], describeStream[0], describeKey[0]); + kinesisDataStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Kinesis stream is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Kinesis stream not encrypted with desired encryption level', function (done) { + const cache = createCache([listStreams[1]], listKeys, [listAliases[1]], describeStream[1], describeKey[1]); + kinesisDataStreamsEncrypted.run(cache, {data_streams_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Kinesis stream is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Kinesis streams does not have encryption enabled', function (done) { + const cache = createCache([listStreams[0]], listKeys, listAliases, describeStream[2], describeKey[1]); + kinesisDataStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Kinesis stream does not have encryption enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Kinesis streams found', function (done) { + const cache = createCache([]); + kinesisDataStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kinesis streams found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to to query for Kinesis streams', function (done) { + const cache = createCache([listStreams[0]], listKeys, listAliases, null, null, null, null, null, null ,{ message: "Unable to to query for Kinesis streams" }); + kinesisDataStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kinesis for stream'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listStreams, null, null, null, null, null, null, { message: "Unable to list KMS keys" }); + kinesisDataStreamsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/kinesis/kinesisEncrypted.js b/plugins/aws/kinesis/kinesisEncrypted.js index 7dabdfb85..11cc88612 100644 --- a/plugins/aws/kinesis/kinesisEncrypted.js +++ b/plugins/aws/kinesis/kinesisEncrypted.js @@ -6,6 +6,8 @@ var defaultKmsKey = 'alias/aws/kinesis'; module.exports = { title: 'Kinesis Streams Encrypted', category: 'Kinesis', + domain: 'Content Delivery', + severity: 'High', description: 'Ensures Kinesis Streams encryption is enabled', more_info: 'Data sent to Kinesis Streams can be encrypted using KMS server-side encryption. Existing streams can be modified to add encryption with minimal overhead.', recommended_action: 'Enable encryption using KMS for all Kinesis Streams.', @@ -16,6 +18,26 @@ module.exports = { 'AWS KMS encryption ensures that the Kinesis message payload meets the ' + 'encryption in transit and at rest requirements of HIPAA.' }, + remediation_description: 'Encryption for the affected Kinesis streams will be enabled.', + remediation_min_version: '202010301919', + apis_remediate: ['Kinesis:listStreams', 'Kinesis:describeStream'], + remediation_inputs: { + kmsKeyIdforKinesis: { + name: '(Optional) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + actions: { + remediate: ['Kinesis:startStreamEncryption'], + rollback: ['Kinesis:stopStreamEncryption'] + }, + permissions: { + remediate: ['kinesis:StartStreamEncryption'], + rollback: ['kinesis:StopStreamEncryption'] + }, + realtime_triggers: ['kinesis:CreateStream','kinesis:StartStreamEncryption','kinesis:StopStreamEncryption','kinesis:DeleteStream'], run: function(cache, settings, callback) { var results = []; @@ -38,7 +60,7 @@ module.exports = { helpers.addResult(results, 0, 'No Kinesis streams found', region); return rcb(); } - + async.each(listStreams.data, function(stream, cb){ var describeStream = helpers.addSource(cache, source, @@ -63,7 +85,7 @@ module.exports = { return cb(); } - var streamArn = describeStream.data.StreamDescription.StreamArn; + var streamArn = describeStream.data.StreamDescription.StreamARN; if (describeStream.data.StreamDescription.KeyId) { if (describeStream.data.StreamDescription.KeyId === defaultKmsKey) { @@ -88,6 +110,74 @@ module.exports = { }, function(){ callback(null, results, source); }); - } -}; + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'kinesisEncrypted'; + var streamNameArr = resource.split(':'); + var streamName = streamNameArr[streamNameArr.length - 1].split('/'); + var defaultKeyDesc = 'Default master key that protects my Kinesis data when no other key is defined'; + streamName = streamName[streamName.length - 1]; + // find the location of the Kinesis Stream needing to be remediated + var streamLocation = streamNameArr[3]; + var err; + if (!streamLocation) { + err = 'Unable to get stream location'; + return callback(err, null); + } + // add the location of the Kinesis Stream to the config + config.region = streamLocation; + var params = {}; + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforKinesis) { + params = { + EncryptionType: 'KMS', /* required */ + KeyId: settings.input.kmsKeyIdforKinesis, /* required */ + StreamName: streamName /* required */ + }; + } else { + var defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default Kinesis key for the region ${config.region}`); + params = { + EncryptionType: 'KMS', /* required */ + KeyId: defaultKmsKeyId, /* required */ + StreamName: streamName /* required */ + }; + + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'KinesisStream': streamName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'KinesisStream': streamName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + + rollback: function(config, cache, settings, resource, callback) { + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); + } +}; \ No newline at end of file diff --git a/plugins/aws/kinesis/kinesisEncrypted.spec.js b/plugins/aws/kinesis/kinesisEncrypted.spec.js new file mode 100644 index 000000000..aedc4d48d --- /dev/null +++ b/plugins/aws/kinesis/kinesisEncrypted.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var kinesisEncrypted = require('./kinesisEncrypted'); + +const listStreams = [ + "mine2" +]; + +const describeStream = [ + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625248393108116739567127156011434883828455224553504770" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:111122223333:stream/mine2", + "StreamName": "mine2", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "KMS", + "KeyId": "alias/aws/kinesis", + "StreamCreationTimestamp": "2021-12-27T19:27:41+05:00" + } + }, + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625248607195270645461109314754330301252766913651539970" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:111122223333:stream/mine1", + "StreamName": "mine1", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "NONE", + "KeyId": null, + "StreamCreationTimestamp": "2021-12-27T19:37:41+05:00" + } + }, + { + "StreamDescription": { + "Shards": [ + { + "ShardId": "shardId-000000000000", + "HashKeyRange": { + "StartingHashKey": "0", + "EndingHashKey": "340282366920938463463374607431768211455" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "49625248607195270645461109314754330301252766913651539970" + } + } + ], + "StreamARN": "arn:aws:kinesis:us-east-1:111122223333:stream/mine1", + "StreamName": "mine1", + "StreamStatus": "ACTIVE", + "RetentionPeriodHours": 24, + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "EncryptionType": "KMS", + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "StreamCreationTimestamp": "2021-12-27T19:37:41+05:00" + } + } +]; + +const createCache = (streams, describeStream, streamsErr, describeStreamErr) => { + var stream = (streams && streams.length) ? streams[0]: null; + return { + kinesis: { + listStreams: { + 'us-east-1': { + err: streamsErr, + data: streams + }, + }, + describeStream: { + 'us-east-1': { + [stream]: { + data: describeStream, + err: describeStreamErr + } + } + } + }, + }; +}; + +describe('kinesisEncrypted', function () { + describe('run', function () { + it('should PASS if Kinesis stream uses a KMS key for SSE', function (done) { + const cache = createCache([listStreams[0]], describeStream[2]); + kinesisEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Kinesis stream uses a KMS key for SSE'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if The Kinesis stream does not use a KMS key for SSE', function (done) { + const cache = createCache([listStreams[0]], describeStream[1]); + kinesisEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The Kinesis stream does not use a KMS key for SSE'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should WARN if Kinesis stream uses the default KMS key', function (done) { + const cache = createCache([listStreams[0]], describeStream[0]); + kinesisEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('The Kinesis stream uses the default KMS key'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if No Kinesis streams found', function (done) { + const cache = createCache([]); + kinesisEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kinesis streams found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Kinesis streams', function (done) { + const cache = createCache(null, null, { message: "Unable to list Kinesis streams" }); + kinesisEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Kinesis streams'); + done(); + }); + }); + + + }); +}) \ No newline at end of file diff --git a/plugins/aws/kinesisvideo/videostreamDataEncrypted.js b/plugins/aws/kinesisvideo/videostreamDataEncrypted.js new file mode 100644 index 000000000..940cefd8c --- /dev/null +++ b/plugins/aws/kinesisvideo/videostreamDataEncrypted.js @@ -0,0 +1,125 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Video Stream Data Encrypted', + category: 'Kinesis Video Streams', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Kinesis Video Streams is using desired encryption level for Data at-rest.', + more_info: 'Server-side encryption is always enabled on Kinesis video streams data. If a user-provided key is not specified when the stream is created, the default key (provided by Kinesis Video Streams) is used. ' + + 'It is recommended to use customer-managed keys (CMKs) for encryption in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt Kinesis Video Streams data with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/kinesisvideostreams/latest/dg/how-kms.html', + apis: ['KinesisVideo:listStreams', 'KMS:describeKey', 'KMS:listKeys', 'KMS:listAliases'], + settings: { + video_stream_data_desired_encryption_level: { + name: 'Kinesis Video Streams Data Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['kinesisvideo:CreateStream', 'kinesisvideo:DeleteStream'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.video_stream_data_desired_encryption_level || this.settings.video_stream_data_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.kinesisvideo, function(region, rcb){ + var listStreams = helpers.addSource(cache, source, + ['kinesisvideo', 'listStreams', region]); + + if (!listStreams) return rcb(); + + if (listStreams.err || !listStreams.data) { + helpers.addResult(results, 3, `Unable to query Kinesis Video Streams: ${helpers.addError(listStreams)}`, region); + return rcb(); + } + + if (!listStreams.data.length) { + helpers.addResult(results, 0, 'No Kinesis Video Streams found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + 'Unable to query for KMS aliases: ' + helpers.addError(listAliases), + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias) { + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasName] = keyArn; + }); + + for (let streamData of listStreams.data) { + if (!streamData.StreamARN) continue; + + let resource = streamData.StreamARN; + + if (streamData.KmsKeyId) { + + let aliasKey = streamData.KmsKeyId.includes('alias/') ? streamData.KmsKeyId.split(':').pop() : streamData.KmsKeyId; + let kmsKeyArn = (aliasKey.startsWith('alias/')) + ? (kmsAliasArnMap[aliasKey] ? kmsAliasArnMap[aliasKey] : streamData.KmsKeyId) + : streamData.KmsKeyId; + var kmsKeyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, streamData.KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Kinesis Video Streams data is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Kinesis Video Streams data is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js b/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js new file mode 100644 index 000000000..0c7177eec --- /dev/null +++ b/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var videostreamDataEncrypted = require('./videostreamDataEncrypted'); + + +const listStreams = [ + { + "StreamName": "test1", + "StreamARN": "arn:aws:kinesisvideo:us-east-1:000111222333:stream/test1/1639473904764", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "Version": "vsbqxi7p9kIu1AjRWBiv", + "Status": "ACTIVE", + "CreationTime": "2021-12-14T14:25:04.764000+05:00", + "DataRetentionInHours": 24 + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const listAliases = [ + { + "AliasName": "alias/my-kinesis-key", + "AliasArn": "arn:aws:kms:us-east-1:000011112222:alias/my-kinesis-key", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my kinesis video data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (streamData, keys, aliases, describeKey, streamDataErr, keysErr, aliasesErr, describeKeyErr) => { + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + return { + kinesisvideo: { + listStreams: { + 'us-east-1': { + err: streamDataErr, + data: streamData + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + listAliases: { + 'us-east-1': { + data: aliases, + err: aliasesErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + + +describe('videostreamDataEncrypted', function () { + describe('run', function () { + it('should PASS if Kinesis Video Streams data is using customer-managed encryption (awscmk)', function (done) { + const cache = createCache(listStreams, listKeys, listAliases, describeKey[0]); + videostreamDataEncrypted.run(cache, { video_stream_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Kinesis Video Streams data is using awscmk'); + done(); + }); + }); + + + it('should FAIL if Kinesis Video Streams data is using AWS managed encryption (awskms)', function (done) { + const cache = createCache(listStreams, listKeys, listAliases, describeKey[1]); + videostreamDataEncrypted.run(cache, { video_stream_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Kinesis Video Streams data is using awskms'); + done(); + }); + }); + + + it('should PASS if no Kinesis Video Streams are found', function (done) { + const cache = createCache([]); + videostreamDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kinesis Video Streams found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Kinesis Video Streams', function (done) { + const cache = createCache(null, null, null, null, { message: "Unable to list Kinesis Video Streams" }); + videostreamDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, null, null, { message: "Unable to list KMS keys" }); + videostreamDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to retrieve KMS alias data', function (done) { + const cache = createCache(listStreams, listKeys, null, describeKey[0], null, null, { message: "Unable to list KMS aliases" }); + videostreamDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/kms/kmsAppTierCmk.js b/plugins/aws/kms/kmsAppTierCmk.js new file mode 100644 index 000000000..ae79ea4df --- /dev/null +++ b/plugins/aws/kms/kmsAppTierCmk.js @@ -0,0 +1,115 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'App-Tier KMS Customer Master Key (CMK)', + category: 'KMS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that there is one Amazon KMS Customer Master Key (CMK) present in the account for App-Tier resources.', + more_info: 'Amazon KMS should have Customer Master Key (CMK) for App-Tier to protect data in transit.', + recommended_action: 'Create a Customer Master Key (CMK) with App-Tier tag', + link: 'https://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html', + apis: ['KMS:listKeys', 'ResourceGroupsTaggingAPI:getTagKeys', 'KMS:listResourceTags'], + settings: { + kms_cmk_tag_key: { + name: 'KMS CMK Tag Key', + description: 'When this tag key exists in the AWS account, there should also exist a KMS CMK with the same tag key', + regex: '^.*$', + default: '' + }, + }, + realtime_triggers: ['kms:CreateKey'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var config = { + kms_cmk_tag_key: settings.kms_cmk_tag_key || this.settings.kms_cmk_tag_key.default + }; + + if (!config.kms_cmk_tag_key.length) return callback(null, results, source); + + async.each(regions.kms, function(region, rcb){ + var getTagKeys = helpers.addSource(cache, source, + ['resourcegroupstaggingapi', 'getTagKeys', region]); + + if (!getTagKeys) return rcb(); + + if (getTagKeys.err || !getTagKeys.data) { + helpers.addResult(results, 3, + `Unable to get tag keys: ${helpers.addError(getTagKeys)}`, region); + return rcb(); + } + + if (!getTagKeys.data.length) { + helpers.addResult(results, 0, 'No tag keys found', region); + return rcb(); + } + + if (!getTagKeys.data.includes(config.kms_cmk_tag_key)) { + helpers.addResult(results, 2, + `No key with "${config.kms_cmk_tag_key}" tag found`, region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys) return rcb(); + + if (listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, + region); + return rcb(); + } + + if (!listKeys.data.length) { + helpers.addResult(results, 2, 'No KMS keys found', region); + return rcb(); + } + + var appTierKmsKey = false; + + async.each(listKeys.data, function(kmsKey, kcb){ + var listResourceTags = helpers.addSource(cache, source, + ['kms', 'listResourceTags', region, kmsKey.KeyId]); + + if (!listResourceTags || listResourceTags.err || !listResourceTags.data || !listResourceTags.data.Tags) { + helpers.addResult(results, 3, + `Unable to describe resource tags: ${helpers.addError(listResourceTags)}`, + region, kmsKey.KeyArn); + return kcb(); + } + + if (listResourceTags.data.Tags.length) { + for (var i in listResourceTags.data.Tags) { + var kmsTag = listResourceTags.data.Tags[i]; + if (kmsTag.TagKey && kmsTag.TagKey === config.kms_cmk_tag_key) { + appTierKmsKey = true; + return kcb(); + } + } + } + + kcb(); + }, function(){ + if (appTierKmsKey) { + helpers.addResult(results, 0, + `KMS Customer Master key with "${config.kms_cmk_tag_key}" tag is present`, + region); + } else { + helpers.addResult(results, 2, + `No KMS Customer Master key with "${config.kms_cmk_tag_key}" tag found`, + region); + } + + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/kms/kmsAppTierCmk.spec.js b/plugins/aws/kms/kmsAppTierCmk.spec.js new file mode 100644 index 000000000..5a316600f --- /dev/null +++ b/plugins/aws/kms/kmsAppTierCmk.spec.js @@ -0,0 +1,169 @@ +var expect = require('chai').expect; +const kmsAppTierCmk = require('./kmsAppTierCmk'); + +const listKeys = [ + { + KeyId: '60c4f21b-e271-4e97-86ae-6403618a9467', + KeyArn: 'arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467' + } +]; + +const listKeyResources = [ + { + "Tags": [ + { + "TagKey": "app_tier", + "TagValue": "app_tier" + } + ], + "Truncated": false + }, + { + "Tags": [ + {} + ], + "Truncated": false + } + +] + +const createCache = (keys, getTagKeys, tags) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + kms:{ + listKeys: { + 'us-east-1': { + data: keys + }, + }, + listResourceTags: { + 'us-east-1': { + [keyId]: { + data: tags + }, + }, + }, + }, + resourcegroupstaggingapi: { + getTagKeys: { + 'us-east-1': { + data: getTagKeys + } + } + } + }; +}; + +const createErrorCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': { + err: { + message: 'error listing kms keys' + }, + }, + }, + + listKeyResources: { + 'us-east-1': { + err: { + message: 'error listing kms key resources' + }, + }, + }, + }, + resourcegroupstaggingapi: { + getTagKeys: { + 'us-east-1': { + err: { + message: 'error fetching tag keys' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': null, + }, + listKeyResources: { + 'us-east-1': null + }, + }, + resourcegroupstaggingapi: { + getTagKeys: { + 'us-east-1': null + } + } + }; +}; + +describe('kmsAppTierCmk', function () { + describe('run', function () { + it('should FAIL if no KMS CMK found for tag-key', function (done) { + const cache = createCache([listKeys[0]], [ 'App_Tier' ], listKeyResources[0]); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'App_Tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if KMS CMK is present for tag-key', function (done) { + const cache = createCache([listKeys[0]], [ 'app_tier' ], listKeyResources[0]); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no KMS keys found', function (done) { + const cache = createCache([], [ 'app_tier' ], listKeyResources[0]); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no tag keys found', function (done) { + const cache = createCache([listKeys[0]], [], listKeyResources[0]); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createErrorCache(); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if list keys response is not found', function (done) { + const cache = createNullCache(); + kmsAppTierCmk.run(cache, { kms_cmk_tag_key: 'app_tier' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any result if KMS CMK tag key not provided in settings', function (done) { + const cache = createNullCache(); + kmsAppTierCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/kms/kmsDefaultKeyUsage.js b/plugins/aws/kms/kmsDefaultKeyUsage.js index 53fdec165..495613bb0 100644 --- a/plugins/aws/kms/kmsDefaultKeyUsage.js +++ b/plugins/aws/kms/kmsDefaultKeyUsage.js @@ -4,21 +4,24 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'KMS Default Key Usage', category: 'KMS', + domain: 'Application Integration', + severity: 'Medium', description: 'Checks AWS services to ensure the default KMS key is not being used', more_info: 'It is recommended not to use the default key to avoid encrypting disparate sets of data with the same key. Each application should have its own customer-managed KMS key', link: 'http://docs.aws.amazon.com/kms/latest/developerguide/concepts.html', recommended_action: 'Avoid using the default KMS key', - apis: ['KMS:listKeys', 'KMS:describeKey', 'CloudTrail:describeTrails', 'EC2:describeVolumes', - 'ElasticTranscoder:listPipelines', 'RDS:describeDBInstances', 'Redshift:describeClusters', - 'S3:listBuckets', 'S3:getBucketEncryption', 'SES:describeActiveReceiptRuleSet', - 'Workspaces:describeWorkspaces', 'Lambda:listFunctions', 'CloudWatchLogs:describeLogGroups', - 'EFS:describeFileSystems', 'STS:getCallerIdentity'], + apis: ['KMS:listKeys', 'KMS:describeKey', 'KMS:listAliases', 'CloudTrail:describeTrails', + 'EC2:describeVolumes', 'RDS:describeDBInstances', + 'Redshift:describeClusters', 'S3:listBuckets', 'S3:getBucketEncryption', + 'SES:describeActiveReceiptRuleSet', 'Workspaces:describeWorkspaces', 'Lambda:listFunctions', + 'CloudWatchLogs:describeLogGroups', 'EFS:describeFileSystems', 'STS:getCallerIdentity'], compliance: { pci: 'PCI requires vendor defaults to be changed. While KMS keys ' + 'do not fall into the same category as vendor-default ' + 'passwords, it is still strongly encouraged to use a ' + 'customer-provided CMK rather than the default KMS key.' }, + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','ec2:CreateVolume','ec2:DeleteVolume','rds:CreateDBInstance','rds:ModifyDBInstance','rds:DeleteDBInstance','redshift:CreateCluster','redshift:ModifyCluster','redshift:DeleteCluster','s3:CreateBucket','s3:DeleteBucket','s3:PutBucketEncryption','ses:CreateReceiptRule','ses:DeleteReceiptRule','ses:UpdateReceiptRule','workspaces:CreateWorkspaces','workspaces:TerminateWorkspaces','lambda:UpdateFunctionConfiguration','lambda:CreateFunction','lambda:DeleteFunction','cloudwatchlogs:CreateLogGroup','cloudwatchlogs:DeleteLogGroup','cloudwatchlogs:AssociateKmsKey','efs:CreateFileSystem',':efs:DeleteFileSystem'], run: function(cache, settings, callback) { var results = []; @@ -26,6 +29,7 @@ module.exports = { var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); async.each(regions.kms, function(region, rcb) { @@ -45,6 +49,16 @@ module.exports = { return rcb(); } + var listAliases = helpers.addSource(cache, source, ['kms', 'listAliases', region]); + + if (!listAliases) return rcb(); + + if (listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + 'Unable to list KMS key aliases: ' + helpers.addError(listAliases), region); + return rcb(); + } + // Master list of services var services = []; @@ -80,34 +94,12 @@ module.exports = { if (describeVolumes.data[j].KmsKeyId) { services.push({ serviceName: 'EBS', - resource: 'arn:aws:ec2:' + region + ':' + accountId + ':volume/' + describeVolumes.data[j].VolumeId, + resource: `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':volume/' + describeVolumes.data[j].VolumeId, KMSKey: describeVolumes.data[j].KmsKeyId }); } } } - } - - // For ElasticTranscoder - if (region in regions.elastictranscoder) { - var listPipelines = helpers.addSource(cache, source, ['elastictranscoder', 'listPipelines', region]); - - if (listPipelines) { - if (listPipelines.err || !listPipelines.data) { - helpers.addResult(results, 3, - 'Unable to query for ElasticTranscoder pipelines: ' + helpers.addError(listPipelines), region); - } else { - for (var k in listPipelines.data){ - if (listPipelines.data[k].AwsKmsKeyArn) { - services.push({ - serviceName: 'ElasticTranscoder', - resource: listPipelines.data[k].Arn, - KMSKey: listPipelines.data[k].AwsKmsKeyArn - }); - } - } - } - } } // For RDS @@ -119,7 +111,7 @@ module.exports = { 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); } else { for (var l in describeDBInstances.data){ - if(describeDBInstances.data[l].StorageEncrypted && + if (describeDBInstances.data[l].StorageEncrypted && describeDBInstances.data[l].KmsKeyId){ services.push({ serviceName: 'RDS', @@ -140,10 +132,10 @@ module.exports = { 'Unable to query for Redshift clusters: ' + helpers.addError(describeClusters), region); } else { for (var m in describeClusters.data){ - if(describeClusters.data[m].KmsKeyId){ + if (describeClusters.data[m].KmsKeyId){ services.push({ serviceName: 'Redshift', - resource: 'arn:aws:redshift:' + region + ':' + accountId + ':cluster:' + describeClusters.data[m].ClusterIdentifier, + resource: `arn:${awsOrGov}:redshift:` + region + ':' + accountId + ':cluster:' + describeClusters.data[m].ClusterIdentifier, KMSKey: describeClusters.data[m].KmsKeyId }); } @@ -159,16 +151,16 @@ module.exports = { if (describeActiveReceiptRuleSet.err) { helpers.addResult(results, 3, 'Unable to query for SES: ' + helpers.addError(describeActiveReceiptRuleSet), region); - } else if (describeActiveReceiptRuleSet.data) { - for (var n in describeActiveReceiptRuleSet.data){ - if (describeActiveReceiptRuleSet.data[n].Actions) { - for (var o in describeActiveReceiptRuleSet.data[n].Actions){ - if (describeActiveReceiptRuleSet.data[n].Actions[o].S3Action && - describeActiveReceiptRuleSet.data[n].Actions[o].S3Action.KmsKeyArn) { + } else if (describeActiveReceiptRuleSet.data && describeActiveReceiptRuleSet.data.Rules) { + for (var rule of describeActiveReceiptRuleSet.data.Rules) { + if (rule.Actions) { + for (var o in rule.Actions){ + if (rule.Actions[o].S3Action && + rule.Actions[o].S3Action.KmsKeyArn) { services.push({ serviceName: 'SES', resource: 'SES ruleset', - KMSKey: describeActiveReceiptRuleSet.data[n].Actions[o].S3Action.KmsKeyArn + KMSKey: rule.Actions[o].S3Action.KmsKeyArn }); } } @@ -191,7 +183,7 @@ module.exports = { if (describeWorkspaces.data[p].VolumeEncryptionKey) { services.push({ serviceName: 'Workspaces', - resource: 'arn:aws:workspaces:' + region + ':' + accountId + ':workspace/' + describeWorkspaces.data[p].WorkspaceId, + resource: `arn:${awsOrGov}:workspaces:` + region + ':' + accountId + ':workspace/' + describeWorkspaces.data[p].WorkspaceId, KMSKey: describeWorkspaces.data[p].VolumeEncryptionKey }); } @@ -253,7 +245,7 @@ module.exports = { if (describeFileSystems.data[s].KmsKeyId) { services.push({ serviceName: 'EFS', - resource: 'arn:aws:elasticfilesystem:' + region + ':' + accountId + ':file-system/' + describeFileSystems.data[s].FileSystemId, + resource: `arn:${awsOrGov}:elasticfilesystem:` + region + ':' + accountId + ':file-system/' + describeFileSystems.data[s].FileSystemId, KMSKey: describeFileSystems.data[s].KmsKeyId }); } @@ -265,7 +257,6 @@ module.exports = { // For S3 Buckets if (region === 'us-east-1') { var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); - if (listBuckets) { if (listBuckets.err || !listBuckets.data) { helpers.addResult(results, 3, @@ -294,7 +285,7 @@ module.exports = { getBucketEncryption.data[u].Rules[v].ApplyServerSideEncryptionByDefault.KMSMasterKeyID) { services.push({ serviceName: 'S3', - resource: 'arn:aws:s3:::' + bucket.Name, + resource: `arn:${awsOrGov}:s3:::` + bucket.Name, KMSKey: getBucketEncryption.data[u].Rules[v].ApplyServerSideEncryptionByDefault.KMSMasterKeyID }); } @@ -309,6 +300,13 @@ module.exports = { } } + var aliasIdMap = {}; + for (let keyAlias of listAliases.data) { + if (keyAlias.AliasName && keyAlias.TargetKeyId) { + aliasIdMap[keyAlias.TargetKeyId] = keyAlias.AliasName; + } + } + // Loop through KMS keys var defaultKeys = []; @@ -331,13 +329,15 @@ module.exports = { } var defSTR = 'Default master key (.*)'; - + for (var x in keysInfo){ if (keysInfo[x].Desc.match(defSTR)){ + let keyAlias = aliasIdMap[keysInfo[x].keyId]; defaultKeys.push(keysInfo[x].keyId); + defaultKeys.push(keyAlias); } } - + kcb(); }, function(){ var reg = 0; diff --git a/plugins/aws/kms/kmsDuplicateGrants.js b/plugins/aws/kms/kmsDuplicateGrants.js new file mode 100644 index 000000000..2c4342c79 --- /dev/null +++ b/plugins/aws/kms/kmsDuplicateGrants.js @@ -0,0 +1,100 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); +module.exports = { + title: 'KMS Duplicate Grants', + category: 'KMS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that AWS KMS keys does not have duplicate grants to adhere to AWS security best practices.', + more_info: 'Duplicate grants have the same key ARN, API actions, grantee principal, encryption context, and name. ' + + 'If you retire or revoke the original grant but leave the duplicates, the leftover duplicate grants constitute unintended escalations of privilege.', + recommended_action: 'Delete duplicate grants for AWS KMS keys', + link: 'https://docs.aws.amazon.com/kms/latest/developerguide/grants.html', + apis: ['KMS:listKeys', 'KMS:listGrants', 'KMS:describeKey'], + realtime_triggers: ['kms:CreateKey','kms:RevokeGrant','kms:CreateGrant'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.kms, function(region, rcb){ + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys) return rcb(); + + if (listKeys.err || !listKeys.data){ + helpers.addResult(results, 3, + 'Unable to list KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + if (!listKeys.data.length){ + helpers.addResult(results, 0, 'No KMS keys found', region); + return rcb(); + } + + listKeys.data.forEach(kmsKey => { + let resource = kmsKey.KeyArn; + let describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKey.KeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, + region, resource); + return; + } + + let keyLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + if (keyLevel == 2) { + helpers.addResult(results, 0, + 'KMS key is AWS-managed', region, resource); + return; + } + + let listGrants = helpers.addSource(cache, source, + ['kms', 'listGrants', region, kmsKey.KeyId]); + + if (!listGrants || listGrants.err || !listGrants.data || !listGrants.data.Grants) { + helpers.addResult(results, 3, + `Unable to query for KMS Key grants: ${helpers.addError(describeKey)}`, + region, resource); + return; + } + + if (!listGrants.data.Grants.length) { + helpers.addResult(results, 0, + 'No grants exist for the KMS key', + region, resource); + return; + } + + let found = false; + for (let entry of listGrants.data.Grants) { + let dupGrant = listGrants.data.Grants.filter(grant => grant.KeyId === entry.KeyId && + grant.Name === entry.Name && + grant.GranteePrincipal == entry.GranteePrincipal && + ((grant.Operations && entry.Operations) ? JSON.stringify(grant.Operations) == JSON.stringify(entry.Operations) : true) && + ((grant.Constraints && entry.Constraints) ? JSON.stringify(grant.Constraints) == JSON.stringify(entry.Constraints) : true)); + if (dupGrant && dupGrant.length > 1) found = true; + } + + + if (found) { + helpers.addResult(results, 2, + 'KMS key has duplicate grants', region, resource); + } else { + helpers.addResult(results, 0, + 'KMS key does not have duplicate grants', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/kms/kmsDuplicateGrants.spec.js b/plugins/aws/kms/kmsDuplicateGrants.spec.js new file mode 100644 index 000000000..92ca41feb --- /dev/null +++ b/plugins/aws/kms/kmsDuplicateGrants.spec.js @@ -0,0 +1,216 @@ +var expect = require('chai').expect; +var kmsDuplicateGrants = require('./kmsDuplicateGrants'); + +const listGrants = [ + [ + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "GrantId": "78c1e935fce8be9e146a049489e5e53301a88dfdf2a058e2843798b15c175251", + "Name": "", + "CreationDate": "2022-01-04T23:23:25+05:00", + "GranteePrincipal": "arn:aws:iam::560213429563:root", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt", + "Encrypt" + ] + } + ], + [ + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "GrantId": "4c3205c326f01ce73a33185c095b8d862031307c535023122a4a9f472d00aaaa", + "Name": "", + "CreationDate": "2022-01-04T22:51:33+05:00", + "GranteePrincipal": "arn:aws:iam::560213429563:root", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt", + "Encrypt" + ] + }, + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "GrantId": "78c1e935fce8be9e146a049489e5e53301a88dfdf2a058e2843798b15c175251", + "Name": "", + "CreationDate": "2022-01-04T23:23:25+05:00", + "GranteePrincipal": "arn:aws:iam::560213429563:root", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt", + "Encrypt" + ] + } + ] +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "fb8ab834-47f3-4434-810a-e9cb1634de69", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "fb8ab834-47f3-4434-810a-e9cb1634de69", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "fb8ab834-47f3-4434-810a-e9cb1634de69", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69" + } +] + +const createCache = (keys, describeKey, listGrants) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + listGrants: { + 'us-east-1': { + [keyId]: { + data: { + Grants: listGrants + }, + err: null, + }, + }, + } + }, + }; +}; + +describe('kmsDuplicateGrants', function () { + describe('run', function () { + it('should PASS if KMS key does not have duplicate grants', function (done) { + const cache = createCache([listKeys[0]], describeKey[0], listGrants[0]); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key does not have duplicate grants'); + done(); + }); + }); + + it('should FAIL if KMS key has duplicate grants', function (done) { + const cache = createCache([listKeys[0]], describeKey[0], listGrants[1]); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key has duplicate grants'); + done(); + }); + }); + + it('should PASS if no grants exist for the KMS key', function (done) { + const cache = createCache([listKeys[0]], describeKey[0], []); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No grants exist for the KMS key'); + done(); + }); + }); + + it('should PASS if KMS key is AWS-managed', function (done) { + const cache = createCache([listKeys[0]], describeKey[1], []); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key is AWS-managed'); + done(); + }); + }); + + it('should PASS if No KMS keys found', function (done) { + const cache = createCache([]); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No KMS keys found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for KMS Key grants', function (done) { + const cache = createCache([listKeys[0]], describeKey[0]); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for KMS Key grants'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for KMS Key', function (done) { + const cache = createCache([listKeys[0]]); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for KMS Key'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(); + kmsDuplicateGrants.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/kms/kmsGrantLeastPrivilege.js b/plugins/aws/kms/kmsGrantLeastPrivilege.js new file mode 100644 index 000000000..40134432b --- /dev/null +++ b/plugins/aws/kms/kmsGrantLeastPrivilege.js @@ -0,0 +1,136 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); +module.exports = { + title: 'KMS Grant Least Privilege', + category: 'KMS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that AWS KMS key grants use the principle of least privileged access.', + more_info: 'AWS KMS key grants should be created with minimum set of permissions required by grantee principal to adhere to AWS security best practices.', + recommended_action: 'Create KMS grants with minimum permission required', + link: 'https://docs.aws.amazon.com/kms/latest/developerguide/grants.html', + apis: ['KMS:listKeys', 'KMS:listGrants', 'KMS:describeKey'], + realtime_triggers: ['kms:CreateKey','kms:RevokeGrant','kms:CreateGrant'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var symmetricOperations = [ + 'Decrypt', + 'Encrypt', + 'GenerateDataKey', + 'GenerateDataKeyPair', + 'GenerateDataKeyPairWithoutPlaintext', + 'GenerateDataKeyWithoutPlaintext', + 'ReEncryptFrom', + 'ReEncryptTo', + 'CreateGrant', + 'DescribeKey', + 'RetireGrant', + ]; + + var asymmetricEDOperations = [ + 'Decrypt', + 'Encrypt', + 'ReEncryptFrom', + 'ReEncryptTo', + 'CreateGrant', + 'DescribeKey', + 'GetPublicKey', + 'RetireGrant', + ]; + + var asymmetricSVOperations = [ // eslint-disable-line + 'ReEncryptFrom', + 'ReEncryptTo', + 'Sign', + 'Verify', + 'CreateGrant', + 'DescribeKey', + 'GetPublicKey', + 'RetireGrant', + ]; + + async.each(regions.kms, function(region, rcb){ + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys) return rcb(); + + if (listKeys.err || !listKeys.data){ + helpers.addResult(results, 3, + 'Unable to list KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + if (!listKeys.data.length){ + helpers.addResult(results, 0, 'No KMS keys found', region); + return rcb(); + } + + listKeys.data.forEach(kmsKey => { + let resource = kmsKey.KeyArn; + let describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKey.KeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, + region, resource); + return; + } + + let keyLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + if (keyLevel == 2) { + helpers.addResult(results, 0, + 'KMS key is AWS-managed', region, resource); + return; + } + + let keySpec = describeKey.data.KeyMetadata.KeySpec; + let listGrants = helpers.addSource(cache, source, + ['kms', 'listGrants', region, kmsKey.KeyId]); + + if (!listGrants || listGrants.err || !listGrants.data || !listGrants.data.Grants) { + helpers.addResult(results, 3, + `Unable to query for KMS Key grants: ${helpers.addError(describeKey)}`, + region, resource); + return; + } + + if (!listGrants.data.Grants.length) { + helpers.addResult(results, 0, + 'No grants exist for the KMS key', + region, resource); + return; + } + + let privilegedGrants = []; + for (let grant of listGrants.data.Grants) { + if (keySpec && keySpec.startsWith('SYMMETRIC')) { + if (grant.Operations && grant.Operations.length && + grant.Operations.length >= symmetricOperations.length) privilegedGrants.push(grant.GrantId); + } else { + if (grant.Operations && grant.Operations.length && + grant.Operations.length >= asymmetricEDOperations.length) privilegedGrants.push(grant.GrantId); + } + } + + if (privilegedGrants.length) { + helpers.addResult(results, 2, + `KMS key provides * permission for these grants: ${privilegedGrants.join(', ')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'KMS key does not provide * permission for any grants', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/kms/kmsGrantLeastPrivilege.spec.js b/plugins/aws/kms/kmsGrantLeastPrivilege.spec.js new file mode 100644 index 000000000..118d0e89f --- /dev/null +++ b/plugins/aws/kms/kmsGrantLeastPrivilege.spec.js @@ -0,0 +1,279 @@ +var expect = require('chai').expect; +var kmsGrantLeastPrivilege = require('./kmsGrantLeastPrivilege'); + +const listGrants = [ + [ + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "GrantId": "02c191300546eb259de3e7c4c29be970bdc8b0a209af7d27581d87df37310068", + "Name": "Allperm", + "CreationDate": "2021-11-22T14:34:45+05:00", + "GranteePrincipal": "AROARSNYOUG3BVO636KMQ", + "IssuingAccount": "arn:aws:iam::108297888182:root", + "Operations": [ + "Decrypt", + "Encrypt", + "GenerateDataKey", + "GenerateDataKeyWithoutPlaintext", + "ReEncryptFrom", + "ReEncryptTo", + "CreateGrant", + "RetireGrant", + "DescribeKey", + "GenerateDataKeyPair", + "GenerateDataKeyPairWithoutPlaintext" + ] + }, + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "GrantId": "04df4a33699d9a76c3baed37ea9b95ee25328236952c6833810cfae0a751686e", + "Name": "aws:profile:domains/mine2-AppFlow", + "CreationDate": "2021-11-19T14:43:41+05:00", + "GranteePrincipal": "profile.us-east-1.amazonaws.com", + "RetiringPrincipal": "profile.us-east-1.amazonaws.com", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt" + ] + }, + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "GrantId": "0978f48c86e449fc2c64f2440313813393909e43963ec3a2e457bbfe0e38b19d", + "Name": "aws:profile:domains/mine2", + "CreationDate": "2021-11-19T14:43:41+05:00", + "GranteePrincipal": "profile.us-east-1.amazonaws.com", + "RetiringPrincipal": "profile.us-east-1.amazonaws.com", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt", + "GenerateDataKey", + "RetireGrant" + ], + "Constraints": { + "EncryptionContextEquals": { + "aws:profile:domain": "mine2" + } + } + } + ], + [ + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "GrantId": "1521390bb3dcbdf8a5754cffcd2f3ce45e5f15d724d70f11ca53ccbfa9bfda6e", + "Name": "", + "CreationDate": "2022-01-04T17:33:32+05:00", + "GranteePrincipal": "arn:aws:iam::560213429563:root", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt" + ] + }, + { + "KeyId": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "GrantId": "a752caf60c95a013f6318e0f9d1f3a8a202f14f9ff9569af4bd338eddc1f19c5", + "Name": "", + "CreationDate": "2022-01-04T17:27:13+05:00", + "GranteePrincipal": "arn:aws:iam::560213429563:root", + "IssuingAccount": "arn:aws:iam::000011112222:root", + "Operations": [ + "Decrypt" + ] + } + ] +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "fb8ab834-47f3-4434-810a-e9cb1634de69", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69", + "CreationDate": "2022-01-04T20:45:32.105000+05:00", + "Enabled": true, + "Description": "", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "RSA_2048", + "KeySpec": "RSA_2048", + "EncryptionAlgorithms": [ + "RSAES_OAEP_SHA_1", + "RSAES_OAEP_SHA_256" + ], + "MultiRegion": false + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + { + "KeyId": "fb8ab834-47f3-4434-810a-e9cb1634de69", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/fb8ab834-47f3-4434-810a-e9cb1634de69" + } +] + +const createCache = (keys, describeKey, listGrants) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + + return { + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: null + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: null, + data: describeKey + }, + }, + }, + listGrants: { + 'us-east-1': { + [keyId]: { + data: { + Grants: listGrants + }, + err: null, + }, + }, + } + }, + }; +}; + +describe('kmsGrantLeastPrivilege', function () { + describe('run', function () { + it('should PASS if KMS key does not provide * permission for any grants', function (done) { + const cache = createCache([listKeys[1]], describeKey[1], listGrants[1]); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key does not provide * permission for any grants'); + done(); + }); + }); + + it('should FAIL if KMS key provides * permission for grants', function (done) { + const cache = createCache([listKeys[0]], describeKey[0], listGrants[0]); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key provides * permission for these grants'); + done(); + }); + }); + + it('should PASS if no grants exist for the KMS key', function (done) { + const cache = createCache([listKeys[1]], describeKey[1], []); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No grants exist for the KMS key'); + done(); + }); + }); + + it('should PASS if KMS key is AWS-managed', function (done) { + const cache = createCache([listKeys[0]], describeKey[2], []); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('KMS key is AWS-managed'); + done(); + }); + }); + + it('should PASS if No KMS keys found', function (done) { + const cache = createCache([]); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No KMS keys found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for KMS Key grants', function (done) { + const cache = createCache([listKeys[1]], describeKey[1]); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for KMS Key grants'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for KMS Key', function (done) { + const cache = createCache([listKeys[1]]); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for KMS Key'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(); + kmsGrantLeastPrivilege.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/kms/kmsKeyPolicy.js b/plugins/aws/kms/kmsKeyPolicy.js index aa0ba1e75..94288c04d 100644 --- a/plugins/aws/kms/kmsKeyPolicy.js +++ b/plugins/aws/kms/kmsKeyPolicy.js @@ -3,11 +3,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'KMS Key Policy', category: 'KMS', + domain: 'Application Integration', + severity: 'High', description: 'Validates the KMS key policy to ensure least-privilege access.', more_info: 'KMS key policies should be designed to limit the number of users who can perform encrypt and decrypt operations. Each application should use its own key to avoid over exposure.', recommended_action: 'Modify the KMS key policy to remove any wildcards and limit the number of users and roles that can perform encrypt and decrypt operations using the key.', link: 'http://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html', - apis: ['KMS:listKeys', 'STS:getCallerIdentity', 'KMS:getKeyPolicy'], + apis: ['KMS:listKeys', 'STS:getCallerIdentity', 'KMS:getKeyPolicy', 'KMS:describeKey', 'Organizations:listAccounts'], settings: { kms_key_policy_max_user_count: { name: 'KMS Key Policy Max User Count', @@ -26,14 +28,47 @@ module.exports = { description: 'A comma-delimited list of known third-party AWS account IDs that should be trusted', regex: '^\\d{12}(?:,\\d{12})*$', default: '' - } + }, + kms_key_policy_whitelisted_policy_ids: { + name: 'KMS Key Policy Whitelisted Policy IDs', + description: 'A comma-delimited list of known Key Policy IDs that should be trusted', + regex: '^.{1,255}$', + default: 'khulnasoft-cspm' + }, + kms_key_policy_condition_keys: { + name: 'KMS Key Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount,kms:CallerAccount.' + + 'This setting assumes following rules:' + + '1. As a best practice, "Deny" with "StringNotLike" and "Allow" with "StringLike" are used to prevent accidental privileged access' + + '2. IAM condition keys which work with "Numeric" or "Date" operators are not used' + + '3. Bool values are set to "true" with "Allow" and "false" with "Deny"', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner,kms:CallerAccount' + }, + kms_ignore_aws_managed_keys: { + name: 'KMS Ignore AWS-managed Keys', + description: 'If set to true, ignore key policy for AWS-managed KMS keys', + regex: '^(true|false)$', + default: 'false' + }, + kms_whitelist_aws_organization_accounts: { + name: 'KMS Whitelist All AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + }, }, + realtime_triggers: ['kms:CreateKey','kms:PutKeyPolicy'], run: function(cache, settings, callback) { var config = { kms_key_policy_max_user_count: settings.kms_key_policy_max_user_count || this.settings.kms_key_policy_max_user_count.default, kms_key_policy_max_third_parties_count: settings.kms_key_policy_max_third_parties_count || this.settings.kms_key_policy_max_third_parties_count.default, - kms_key_policy_whitelisted_account_ids: settings.kms_key_policy_whitelisted_account_ids || this.settings.kms_key_policy_whitelisted_account_ids.default + kms_key_policy_whitelisted_account_ids: settings.kms_key_policy_whitelisted_account_ids || this.settings.kms_key_policy_whitelisted_account_ids.default, + kms_key_policy_whitelisted_policy_ids: settings.kms_key_policy_whitelisted_policy_ids || this.settings.kms_key_policy_whitelisted_policy_ids.default, + kms_key_policy_condition_keys: settings.kms_key_policy_condition_keys || this.settings.kms_key_policy_condition_keys.default, + kms_ignore_aws_managed_keys: settings.kms_ignore_aws_managed_keys || this.settings.kms_ignore_aws_managed_keys.default, + kms_whitelist_aws_organization_accounts: settings.kms_whitelist_aws_organization_accounts || this.settings.kms_whitelist_aws_organization_accounts.default, }; if (config.kms_key_policy_whitelisted_account_ids && config.kms_key_policy_whitelisted_account_ids.length) { @@ -42,6 +77,18 @@ module.exports = { config.kms_key_policy_whitelisted_account_ids = []; } + if (config.kms_key_policy_whitelisted_policy_ids && config.kms_key_policy_whitelisted_policy_ids.length) { + config.kms_key_policy_whitelisted_policy_ids = config.kms_key_policy_whitelisted_policy_ids.split(','); + } else { + config.kms_key_policy_whitelisted_policy_ids = []; + } + + config.kms_ignore_aws_managed_keys = (config.kms_ignore_aws_managed_keys == 'true'); + var whitelistOrganization = (config.kms_whitelist_aws_organization_accounts == 'true'); + + var allowedConditionKeys = config.kms_key_policy_condition_keys.split(','); + allowedConditionKeys.push('kms:CallerAccount', 'kms:ViaService'); + var custom = helpers.isCustom(settings, this.settings); if (config.kms_key_policy_whitelisted_account_ids.length) custom = true; @@ -52,6 +99,22 @@ module.exports = { var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', acctRegion]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, acctRegion); + return callback(null, results, source); + } + + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + + config.kms_key_policy_whitelisted_account_ids = config.kms_key_policy_whitelisted_account_ids.concat(organizationAccounts); + async.each(regions.kms, function(region, rcb){ var listKeys = helpers.addSource(cache, source, ['kms', 'listKeys', region]); @@ -70,6 +133,23 @@ module.exports = { } async.each(listKeys.data, function(kmsKey, kcb){ + if (config.kms_ignore_aws_managed_keys) { + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKey.KeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, region); + return kcb(); + } + + let keyLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + if (keyLevel == 2) { + helpers.addResult(results, 0, + 'KMS key is AWS-managed', region, kmsKey.KeyArn); + return kcb(); + } + } var getKeyPolicy = helpers.addSource(cache, source, ['kms', 'getKeyPolicy', region, kmsKey.KeyId]); @@ -80,7 +160,16 @@ module.exports = { region, kmsKey.KeyArn); return kcb(); } - + + // Auq-CSPM keys for Remediations should be skipped. + // The only way to distinguish these keys is the Policy Id. + if (getKeyPolicy.data.Id && + config.kms_key_policy_whitelisted_policy_ids.length && + config.kms_key_policy_whitelisted_policy_ids.indexOf(getKeyPolicy.data.Id)>-1) { + helpers.addResult(results, 0, 'The key ' + kmsKey.KeyArn + ' is whitelisted.', region, kmsKey.KeyArn); + return kcb(); + } + var found = false; var wildcardTrusted = 0; var thirdPartyTrusted = 0; @@ -114,19 +203,20 @@ module.exports = { var conditionalCaller = null; - if (statement.Condition && - statement.Condition.StringEquals && - statement.Condition.StringEquals['kms:CallerAccount']) { - conditionalCaller = statement.Condition.StringEquals['kms:CallerAccount']; + if (statement.Condition) { + conditionalCaller = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, true, accountId, settings); } // Check for wildcards without condition if (principal.AWS.indexOf('*') > -1 && !conditionalCaller) { wildcardTrusted += 1; - } else if (conditionalCaller && - conditionalCaller !== accountId && - config.kms_key_policy_whitelisted_account_ids.indexOf(conditionalCaller) === -1) { - thirdPartyTrusted += 1; + } else if (conditionalCaller) { + for (var caller of conditionalCaller) { + if (caller !== accountId && + config.kms_key_policy_whitelisted_account_ids.indexOf(caller) === -1) { + thirdPartyTrusted += 1; + } + } } else if (!conditionalCaller) { for (var u in principal.AWS) { if (principal.AWS[u] !== '*' && @@ -163,11 +253,11 @@ module.exports = { helpers.addResult(results, 2, 'Key trusts ' + wildcardTrusted + ' principals with wildcards', region, kmsKey.KeyArn, custom); } - + if (!found){ helpers.addResult(results, 0, 'Key policy is sufficient', region, kmsKey.KeyArn, custom); } - + kcb(); }, function(){ rcb(); @@ -176,4 +266,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/aws/kms/kmsKeyRotation.js b/plugins/aws/kms/kmsKeyRotation.js index 8601b377d..2cb94f0da 100644 --- a/plugins/aws/kms/kmsKeyRotation.js +++ b/plugins/aws/kms/kmsKeyRotation.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'KMS Key Rotation', category: 'KMS', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures KMS keys are set to rotate on a regular schedule', more_info: 'All KMS keys should have key rotation enabled. AWS will handle the rotation of the encryption key itself, as well as storage of previous keys, so previous data does not need to be re-encrypted before the rotation occurs.', recommended_action: 'Enable yearly rotation for the KMS key', link: 'http://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html', - apis: ['KMS:listKeys', 'KMS:describeKey', 'KMS:getKeyRotationStatus'], + apis: ['KMS:listKeys', 'KMS:describeKey', 'KMS:getKeyRotationStatus', 'KMS:getKeyPolicy'], compliance: { pci: 'PCI has strict requirements regarding the use of encryption keys ' + 'to protect cardholder data. These requirements include rotating ' + @@ -16,12 +18,32 @@ module.exports = { 'should be enabled.', cis2: '2.8 Ensure rotation for customer created CMKs is enabled' }, + settings: { + kms_key_policy_whitelisted_policy_ids: { + name: 'KMS Key Policy Whitelisted Policy IDs', + description: 'A comma-delimited list of known Key Policy IDs that should be trusted', + regex: '^.{1,255}$', + default: 'khulnasoft-cspm' + } + }, + realtime_triggers: ['kms:CreateKey','kms:EnableKeyRotation','kms:DisableKeyRotation','kms:ScheduleKeyDeletion','kms:CancelKeyDeletion'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var config = { + kms_key_policy_whitelisted_policy_ids: settings.kms_key_policy_whitelisted_policy_ids || this.settings.kms_key_policy_whitelisted_policy_ids.default + }; + + if (config.kms_key_policy_whitelisted_policy_ids && + config.kms_key_policy_whitelisted_policy_ids.length) { + config.kms_key_policy_whitelisted_policy_ids = config.kms_key_policy_whitelisted_policy_ids.split(','); + } else { + config.kms_key_policy_whitelisted_policy_ids = []; + } + async.each(regions.kms, function(region, rcb){ var listKeys = helpers.addSource(cache, source, @@ -40,57 +62,83 @@ module.exports = { return rcb(); } - async.each(listKeys.data, function(kmsKey, kcb){ - var describeKey = helpers.addSource(cache, source, - ['kms', 'describeKey', region, kmsKey.KeyId]); + var noCmks = true; + listKeys.data.forEach(kmsKey => { + if (!kmsKey.KeyId) return; - var getKeyRotationStatus = helpers.addSource(cache, source, - ['kms', 'getKeyRotationStatus', region, kmsKey.KeyId]); + var getKeyPolicy = helpers.addSource(cache, source, + ['kms', 'getKeyPolicy', region, kmsKey.KeyId]); + + if (!getKeyPolicy || getKeyPolicy.err || !getKeyPolicy.data){ + helpers.addResult(results, 3, + 'Unable to get key policy: ' + helpers.addError(getKeyPolicy), + region, kmsKey.KeyArn); + return; + } + + // Auq-CSPM keys for Remediations should be skipped. + // The only way to distinguish these keys is the Policy Id. + if (getKeyPolicy.data.Id && + config.kms_key_policy_whitelisted_policy_ids.length && + config.kms_key_policy_whitelisted_policy_ids.indexOf(getKeyPolicy.data.Id)>-1) { + helpers.addResult(results, 0, 'The key ' + kmsKey.KeyArn + ' is whitelisted.', region, kmsKey.KeyArn); + return; + } - if (!describeKey || describeKey.err || !describeKey.data) { + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKey.KeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { helpers.addResult(results, 3, 'Unable to describe key: ' + helpers.addError(describeKey), region, kmsKey.KeyArn); - return kcb(); + return; } var describeKeyData = describeKey.data; // AWS-generated keys for CodeCommit, ACM, etc. should be skipped. - // The only way to distinguish these keys is the default description used by AWS. - // Also skip keys that are being deleted - if (describeKeyData.KeyMetadata && - (describeKeyData.KeyMetadata.Description && describeKeyData.KeyMetadata.Description.indexOf('Default master key that protects my') === 0) || - (describeKeyData.KeyMetadata.KeyState && describeKeyData.KeyMetadata.KeyState == 'PendingDeletion')) { - return kcb(); - } + // Also skip keys that are being deleted + const currentEncryptionLevel = helpers.getEncryptionLevel(describeKeyData.KeyMetadata, helpers.ENCRYPTION_LEVELS); + if (currentEncryptionLevel <= 2 || + (describeKeyData.KeyMetadata.KeyState && + describeKeyData.KeyMetadata.KeyState.toUpperCase() === 'PENDINGDELETION')) return; // Skip keys that are imported into KMS if (describeKeyData.KeyMetadata && describeKeyData.KeyMetadata.Origin && describeKeyData.KeyMetadata.Origin !== 'AWS_KMS') { - return kcb(); + return; } - var keyRotationStatusData = getKeyRotationStatus.data; + if (describeKeyData.KeyMetadata && describeKeyData.KeyMetadata.KeySpec && describeKeyData.KeyMetadata.KeySpec !== 'SYMMETRIC_DEFAULT') { + noCmks = false; + helpers.addResult(results, 0, `Key rotation is not available for ${describeKeyData.KeyMetadata.KeySpec} key type`, region, kmsKey.KeyArn); + return; + } - if (!getKeyRotationStatus || getKeyRotationStatus.err || !getKeyRotationStatus.data) { + var getKeyRotationStatus = helpers.addSource(cache, source, + ['kms', 'getKeyRotationStatus', region, kmsKey.KeyId]); + + if (!getKeyRotationStatus || getKeyRotationStatus.err || !getKeyRotationStatus.data){ helpers.addResult(results, 3, 'Unable to get key rotation status: ' + helpers.addError(getKeyRotationStatus), region, kmsKey.KeyArn); - return kcb(); + return; } - if (keyRotationStatusData.KeyRotationEnabled) { - helpers.addResult(results, 0, 'Key rotation is enabled', region, kmsKey.KeyArn); - } else { - helpers.addResult(results, 2, 'Key rotation is not enabled', region, kmsKey.KeyArn); - } + noCmks = false; + var enabled = getKeyRotationStatus.data.KeyRotationEnabled; + var status = enabled ? 0 : 2; - kcb(); - }, function(){ - rcb(); + helpers.addResult(results, status, `Key rotation is ${enabled ? '' : 'not'} enabled`, region, kmsKey.KeyArn); }); + + if (noCmks) { + helpers.addResult(results, 0, 'No customer-managed KMS keys found', region); + } + + rcb(); }, function(){ callback(null, results, source); }); diff --git a/plugins/aws/kms/kmsKeyRotation.spec.js b/plugins/aws/kms/kmsKeyRotation.spec.js new file mode 100644 index 000000000..c2e7d2918 --- /dev/null +++ b/plugins/aws/kms/kmsKeyRotation.spec.js @@ -0,0 +1,268 @@ +var expect = require('chai').expect; +const kmsKeyRotation = require('./kmsKeyRotation'); + +const listKeys = { + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyArn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467" +}; + +const describeKey = [ + { + KeyMetadata: { + AWSAccountId: "111122223333", + Arn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467", + CreationDate: "2020-08-26T16:55:28+00:00", + CustomerMasterKeySpec: "SYMMETRIC_DEFAULT", + Description: "", + Enabled: true, + EncryptionAlgorithms: [ + "SYMMETRIC_DEFAULT" + ], + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyManager: "CUSTOMER", + KeyState: "Enabled", + KeyUsage: "ENCRYPT_DECRYPT", + MultiRegion: false, + Origin: "AWS_KMS", + KeySpec: "HMAC_512", + } + }, + { + KeyMetadata: { + AWSAccountId: "111122223333", + Arn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467", + CreationDate: "2020-08-26T16:55:28+00:00", + CustomerMasterKeySpec: "SYMMETRIC_DEFAULT", + Description: "", + Enabled: true, + EncryptionAlgorithms: [ + "SYMMETRIC_DEFAULT" + ], + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyManager: "CUSTOMER", + KeyState: "Enabled", + KeyUsage: "ENCRYPT_DECRYPT", + MultiRegion: false, + Origin: "AWS_KMS", + KeySpec: "SYMMETRIC_DEFAULT", + } + }, + { + KeyMetadata: { + AWSAccountId: "111122223333", + Arn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467", + CreationDate: "2020-08-26T16:55:28+00:00", + CustomerMasterKeySpec: "SYMMETRIC_DEFAULT", + Description: "", + Enabled: true, + EncryptionAlgorithms: [ + "SYMMETRIC_DEFAULT" + ], + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyManager: "AWS", + KeyState: "PendingDeletion", + KeyUsage: "ENCRYPT_DECRYPT", + MultiRegion: false, + Origin: "AWS_KMS", + KeySpec: "SYMMETRIC_DEFAULT", + } + } +] + +const keyPolicy = [ + { + "Version" : "2012-10-17", + "Id" : "key-default-1", + "Statement" : [ + { + "Sid" : "Enable IAM User Permissions", + "Effect" : "Allow", + "Principal" : { + "AWS" : "arn:aws:iam::111122223333:root" + }, + "Action" : "kms:*", + "Resource" : "*" + } + ] + }, + { + "Version" : "2012-10-17", + "Id" : "khulnasoft-cspm", + "Statement" : [ + { + "Sid" : "Enable IAM User Permissions", + "Effect" : "Allow", + "Principal" : { + "AWS" : "arn:aws:iam::111122223333:root" + }, + "Action" : "kms:*", + "Resource" : "*" + } + ] + } + +] + +const keyRotationStatus = [ + { + KeyRotationEnabled: true + }, + { + KeyRotationEnabled: false + } +] + +const createCache = (keys, describeKey, keyPolicy, keyRotation) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + kms:{ + listKeys: { + 'us-east-1': { + data: keys + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + } + } + }, + getKeyPolicy: { + 'us-east-1': { + [keyId]: { + data: keyPolicy + } + } + }, + getKeyRotationStatus: { + 'us-east-1': { + [keyId]: { + data: keyRotation + } + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': { + err: 'Unable to list keys ' + } + }, + describeKey: { + 'us-east-1': { + ['id']: { + err: 'Unable to describe key' + } + } + }, + getKeyPolicy: { + 'us-east-1': { + ['id']: { + err: 'Unable to get key policy' + } + } + }, + getKeyRotationStatus: { + 'us-east-1': { + ['id']: { + err: 'Unable to get key rotation status' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': null + }, + describeKey: { + 'us-east-1': null + }, + getKeyPolicy: { + 'us-east-1': null + }, + getKeyRotationStatus: { + 'us-east-1': null + } + } + }; +}; + +describe('kmsKeyRotation', function () { + describe('run', function () { + it('should PASS if KMS has encryption greater than awskms and has rotation enabled', function (done) { + const cache = createCache([listKeys], describeKey[1], keyPolicy[0], keyRotationStatus[0]); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if KMS has encryption greater than awskms and has rotation disabled', function (done) { + const cache = createCache([listKeys], describeKey[1], keyPolicy[0], keyRotationStatus[1]); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + + it('should PASS if no KMS keys found', function (done) { + const cache = createCache([], {}, {}, {}); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createErrorCache(); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should pass if KMS encryption level is lower than or equal to awskms', function (done) { + const cache = createCache([listKeys], describeKey[2], keyPolicy[0], keyRotationStatus[1]); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any result if list keys response is not found', function (done) { + const cache = createNullCache(); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not pass if key rotation is not avaible for KMS ', function (done) { + const cache = createCache([listKeys], describeKey[0], keyPolicy[0], keyRotationStatus[0]); + kmsKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Key rotation is not available for HMAC_512 key type'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/kms/kmsScheduledDeletion.js b/plugins/aws/kms/kmsScheduledDeletion.js index 6c0f0fa0b..f2b394985 100644 --- a/plugins/aws/kms/kmsScheduledDeletion.js +++ b/plugins/aws/kms/kmsScheduledDeletion.js @@ -4,11 +4,26 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'KMS Scheduled Deletion', category: 'KMS', + domain: 'Application Integration', + severity: 'Critical', description: 'Detects KMS keys that are scheduled for deletion', more_info: 'Deleting a KMS key will permanently prevent all data encrypted using that key from being decrypted. Avoid deleting keys unless no encrypted data is in use.', recommended_action: 'Disable the key deletion before the scheduled deletion time.', link: 'http://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html', apis: ['KMS:listKeys', 'KMS:describeKey'], + asl: { + conditions: [ + { + service: 'kms', + api: 'describeKey', + property: 'KeyMetadata.KeyState', + transform: 'STRING', + op: 'EQ', + value: 'PendingDeletion' + } + ] + }, + realtime_triggers: ['kms:CreateKey','kms:ScheduleKeyDeletion','kms:CancelKeyDeletion'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/kms/kmsScheduledDeletion.spec.js b/plugins/aws/kms/kmsScheduledDeletion.spec.js new file mode 100644 index 000000000..c82dcf733 --- /dev/null +++ b/plugins/aws/kms/kmsScheduledDeletion.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +const kmsScheduledDeletion = require('./kmsScheduledDeletion'); + +const listKeys = [ + { + KeyId: '60c4f21b-e271-4e97-86ae-6403618a9467', + KeyArn: 'arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467' + } +]; + +const describeKey = [ + { + KeyMetadata: { + AWSAccountId: "111122223333", + Arn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467", + CreationDate: "2020-08-26T16:55:28+00:00", + CustomerMasterKeySpec: "SYMMETRIC_DEFAULT", + Description: "", + Enabled: true, + EncryptionAlgorithms: [ + "SYMMETRIC_DEFAULT" + ], + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyManager: "CUSTOMER", + KeyState: "PendingDeletion", + KeyUsage: "ENCRYPT_DECRYPT", + MultiRegion: false, + Origin: "AWS_KMS" + } + }, + { + KeyMetadata: { + AWSAccountId: "111122223333", + Arn: "arn:aws:kms:us-east-1:111122223333:key/60c4f21b-e271-4e97-86ae-6403618a9467", + CreationDate: "2020-08-26T16:55:28+00:00", + CustomerMasterKeySpec: "SYMMETRIC_DEFAULT", + Description: "", + Enabled: true, + EncryptionAlgorithms: [ + "SYMMETRIC_DEFAULT" + ], + KeyId: "60c4f21b-e271-4e97-86ae-6403618a9467", + KeyManager: "CUSTOMER", + KeyState: "Enabled", + KeyUsage: "ENCRYPT_DECRYPT", + MultiRegion: false, + Origin: "AWS_KMS" + } + }, +] + +const createCache = (keys, describeKeys) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + return { + kms:{ + listKeys: { + 'us-east-1': { + data: keys + }, + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKeys + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': { + err: { + message: 'error listing kms keys' + }, + }, + }, + describeKey: { + 'us-east-1': { + err: { + message: 'error listing kms key resources' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + kms: { + listKeys: { + 'us-east-1': null, + }, + describeKey: { + 'us-east-1': null + }, + }, + }; +}; + +describe('kmsScheduledDeletion', function () { + describe('run', function () { + it('should WARN if Key is scheduled for deletion', function (done) { + const cache = createCache([listKeys[0]], describeKey[0]); + kmsScheduledDeletion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('Key is scheduled for deletion'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Key is not scheduled for deletion', function (done) { + const cache = createCache([listKeys[0]], describeKey[1]); + kmsScheduledDeletion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key is not scheduled for deletion'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no KMS keys found', function (done) { + const cache = createCache([], describeKey[0]); + kmsScheduledDeletion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No KMS keys found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createErrorCache(); + kmsScheduledDeletion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list KMS keys'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return any result if list keys response is not found', function (done) { + const cache = createNullCache(); + kmsScheduledDeletion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lambda/envVarsClientSideEncryption.js b/plugins/aws/lambda/envVarsClientSideEncryption.js new file mode 100644 index 000000000..df13b072e --- /dev/null +++ b/plugins/aws/lambda/envVarsClientSideEncryption.js @@ -0,0 +1,93 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Environment Variables Client Side Encryption', + category: 'Lambda', + domain: 'Serverless', + severity: 'High', + description: 'Ensure that all sensitive AWS Lambda environment variable values are client side encrypted.', + more_info: 'AWS Lambda lets you encrypt environment variable values prior to sending them to Lambda. ' + + 'Environment variables are often used to store sensitive information such as passwords. Such variable valuesshould be ' + + 'encrypted for security best practices.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html', + recommended_action: 'Encrypt environment variables that store sensitive information', + apis: ['Lambda:listFunctions'], + settings: { + lambda_sensitive_env_vars: { + name: 'Lambda Sensitive Environment Varibales', + description: 'A comma-delimited list of known lambda function Environment Variables that should be encrypted', + regex: '^.{1,255}$', + default: '' + } + }, + realtime_triggers: ['lambda:CreateFunction', 'lambda:UpdateFunctionConfiguration', 'lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + lambda_sensitive_env_vars: settings.lambda_sensitive_env_vars || this.settings.lambda_sensitive_env_vars.default + }; + + if (!config.lambda_sensitive_env_vars.length) return callback(null, results, source); + + config.lambda_sensitive_env_vars = config.lambda_sensitive_env_vars.split(','); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + for (var lambdaFunc of listFunctions.data) { + if (!lambdaFunc.FunctionArn) continue; + var resource = lambdaFunc.FunctionArn; + + if (!lambdaFunc.Environment || !lambdaFunc.Environment.Variables) { + helpers.addResult(results, 0, + 'No environment variables found', region, resource); + continue; + } + + var unencryptedVars = []; + var encryptedVars = []; + for (var envVar of config.lambda_sensitive_env_vars) { + if (lambdaFunc.Environment.Variables[envVar]) { + if (lambdaFunc.Environment.Variables[envVar].length !== 216) unencryptedVars.push(envVar); + else encryptedVars.push(envVar); + } + } + + if (unencryptedVars.length) { + helpers.addResult(results, 2, + `Encryption not enabled for these sensitive environment variable values: ${unencryptedVars}`, + region, resource); + } else if (encryptedVars.length){ + helpers.addResult(results, 0, + 'Encryption is enabled for sensitive environment variable values', + region, resource); + } else { + helpers.addResult(results, 0, 'No sensitive environment variables found', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/envVarsClientSideEncryption.spec.js b/plugins/aws/lambda/envVarsClientSideEncryption.spec.js new file mode 100644 index 000000000..c1ea544a6 --- /dev/null +++ b/plugins/aws/lambda/envVarsClientSideEncryption.spec.js @@ -0,0 +1,149 @@ +const expect = require('chai').expect; +var envVarsClientSideEncryption = require('./envVarsClientSideEncryption'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "Environment": { + "Variables": { + "password": "AQICAHgYvhTIHqe+Awrx6K5feBosORFD4FbhQ/XEyM9ERVB+yAF+yJdS1QYRvl9adezS+RnyAAAAaDBmBgkqhkiG9w0BBwagWTBXAgEAMFIGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMw+VYDIrQVRJZeqO7AgEQgCWCe+TPNAr1VjxRifoBTXwu0YPZZJKLC7yMvnbpC7IqPQemWlTD", + "key": "AQICAHgYvhTIHqe+Awrx6K5feBosORFD4FbhQ/XEyM9ERVB+yAF6IL+r1bQLUR/zYcJYODM2AAAAZzBlBgkqhkiG9w0BBwagWDBWAgEAMFEGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQM5aerBTI5DqTnEsVWAgEQgCR72Vdqcvzq/k5fON7IfxDMEEh4FAfcxhq0FKyvJXi8Pc0B7ds=" + } + } + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + "Environment": { + "Variables": { + "password": "fastabc123", + } + } + }, + { + "FunctionName": "testing-124", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-124", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler" + } +]; + + +const createCache = (listFunctions) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('envVarsClientSideEncryption', function () { + describe('run', function () { + + it('should PASS if sensitive environment variable values are encrypted', function (done) { + const cache = createCache([listFunctions[0]]); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password,key' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if sensitive environment variable values are not encrypted', function (done) { + const cache = createCache([listFunctions[1]]); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password,key' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no environment variable found', function (done) { + const cache = createCache([listFunctions[2]]); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password,key' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no sensitive environment variable found', function (done) { + const cache = createCache([listFunctions[0]]); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'access_keys' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password,key' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + envVarsClientSideEncryption.run(cache, { lambda_sensitive_env_vars: 'password,key' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return anything if settings for senstibe lambda enironment variable not given', function (done) { + const cache = createNullCache(); + envVarsClientSideEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lambda/lambdaAdminPrivileges.js b/plugins/aws/lambda/lambdaAdminPrivileges.js new file mode 100644 index 000000000..5c08a4263 --- /dev/null +++ b/plugins/aws/lambda/lambdaAdminPrivileges.js @@ -0,0 +1,166 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Admin Privileges', + category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensures no Lambda function available in your AWS account has admin privileges.', + more_info: 'AWS Lambda Function should have most-restrictive IAM permissions for Lambda security best practices.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-permissions.html', + recommended_action: 'Modify IAM role attached with Lambda function to provide the minimal amount of access required to perform its tasks', + apis: ['Lambda:listFunctions', 'IAM:listRoles', 'IAM:listAttachedRolePolicies', 'IAM:listRolePolicies', + 'IAM:listPolicies', 'IAM:getPolicy', 'IAM:getPolicyVersion', 'IAM:getRolePolicy'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration', 'lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + async.each(listFunctions.data, function(lambdaFunc, fcb){ + if (!lambdaFunc.FunctionArn) return fcb(); + + var resource = lambdaFunc.FunctionArn; + var adminPrivileged; + + if (!lambdaFunc.Role) { + helpers.addResult(results, 2, + 'Function does not have a role attached', region, resource); + return fcb(); + } + + var roleNameArr = lambdaFunc.Role.split('/'); + var roleName = roleNameArr[roleNameArr.length - 1]; + + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', defaultRegion, roleName]); + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', defaultRegion, roleName]); + var getRolePolicy = helpers.addSource(cache, source, + ['iam', 'getRolePolicy', defaultRegion, roleName]); + + if (!listAttachedRolePolicies || !listRolePolicies ) { + helpers.addResult(results, 0, + 'No IAM Attached Role Found', + region, resource); + return fcb(); + } + + if (listAttachedRolePolicies.err || + !listAttachedRolePolicies.data || + !listAttachedRolePolicies.data.AttachedPolicies) { + helpers.addResult(results, 3, + `Unable to query for IAM attached policy for role "${roleName}": ${helpers.addError(listAttachedRolePolicies)}`, + region, resource); + return fcb(); + } + + if (listRolePolicies.err || !listRolePolicies.data || !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}": ${helpers.addError(listRolePolicies)}`, + region, resource); + return fcb(); + } + + for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { + if (!policy.PolicyArn) continue; + + if (policy.PolicyArn === managedAdminPolicy) { + helpers.addResult(results, 2, + 'Function has admin privileges', region, resource); + return fcb(); + } + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', defaultRegion, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', defaultRegion, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (adminPrivileged) break; + } + + for (var policyName of listRolePolicies.data.PolicyNames) { + if (getRolePolicy && + getRolePolicy[policyName] && + getRolePolicy[policyName].data && + getRolePolicy[policyName].data.PolicyDocument) { + let statements = getRolePolicy[policyName].data.PolicyDocument; + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (!adminPrivileged) { + helpers.addResult(results, 0, + 'Function does not have admin privileges', region, resource); + } else { + helpers.addResult(results, 2, + 'Function has admin privileges', region, resource); + } + + fcb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaAdminPrivileges.spec.js b/plugins/aws/lambda/lambdaAdminPrivileges.spec.js new file mode 100644 index 000000000..4a2295607 --- /dev/null +++ b/plugins/aws/lambda/lambdaAdminPrivileges.spec.js @@ -0,0 +1,278 @@ +const expect = require('chai').expect; +var lambdaAdminPrivileges = require('./lambdaAdminPrivileges'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role" + }, + { + "FunctionName": "testing-1", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-1", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123" + } +]; + +const listRoles = [ + { + "Path": "/", + "RoleName": "lambda-role", + "RoleId": "AROAYE32SRU55L7TD7HQ7", + "Arn": "arn:aws:iam::000011112222:role/lambda-role", + "CreateDate": "2020-12-22T08:47:57Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Description": "Allows Lambda functions to call AWS services on your behalf.", + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "testing-123", + "RoleId": "AROAYE32SRU5UELB2F76P", + "Arn": "arn:aws:iam::000011112222:role/testing-123", + "CreateDate": "2020-12-25T09:09:48Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Description": "Allows Lambda functions to call AWS services on your behalf.", + "MaxSessionDuration": 3600 + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "EFS-Full" + ] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f7d427cc-970b-47af-9b7d-3e06121f83da' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'Allow_Admin_Role_Policy', + "PolicyArn": 'arn:aws:iam::000011112222:policy/Allow_Admin_Role_Policy' + } + ], + "IsTruncated": false + } +]; + +const getRolePolicy = [ + { + "RoleName": 'lambda-role-2', + "PolicyName": 'EFS-Full', + "PolicyDocument": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22elasticfilesystem%3A%2A%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D' + } +]; + +const getPolicy = [ + { + "Policy": { + "PolicyName": 'Allow_Manager_Role_Policy', + "PolicyId": 'ANPAYE32SRU57UHNCIGCT', + "Arn": 'arn:aws:iam::000011112222:policy/Allow_Manager_Role_Policy', + "Path": '/', + "DefaultVersionId": 'v5', + "AttachmentCount": 2, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true + } + } +]; + +const getPolicyVersion = [ + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor1%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPolicies%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPoliciesGrantingServiceAccess%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListRoles%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListUsers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListGroups%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor2%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Deny%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22iam%3ACreateGroup%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22arn%3Aaws%3Aiam%3A%3A000011112222%3Agroup%2F%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v5', + } + } +]; + + +const createCache = (listFunctions, listRoles, listAttachedRolePolicies, listRolePolicies, getRolePolicy, getPolicy, getPolicyVersion) => { + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + var policyArn = (listAttachedRolePolicies && listAttachedRolePolicies.AttachedPolicies) ? listAttachedRolePolicies.AttachedPolicies.PolicyArn : null; + var policyName = (listRolePolicies) ? listRolePolicies.PolicyNames[0] : null; + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + }, + getPolicy: { + 'us-east-1': { + [policyArn]: { + data: getPolicy + } + } + }, + getRolePolicy: { + 'us-east-1': { + [policyName]: { + data: getRolePolicy + } + } + }, + getPolicyVersion: { + 'us-east-1': { + [policyArn]: { + data: getPolicyVersion + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('lambdaAdminPrivileges', function () { + describe('run', function () { + + it('should PASS if fcuntion does not have amdin priveleges', function (done) { + const cache = createCache([listFunctions[0]], [listRoles[0]], listAttachedRolePolicies[1], listRolePolicies[0], getRolePolicy[0]); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if function had admin priveleges', function (done) { + const cache = createCache([listFunctions[1]], [listRoles[1]], listAttachedRolePolicies[0], listRolePolicies[0], {}, getPolicy[0], getPolicyVersion[0]); + lambdaAdminPrivileges.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache([listFunctions[1]], [listRoles[1]], null); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache([listFunctions[1]], [listRoles[1]], listAttachedRolePolicies[0], null); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + lambdaAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lambda/lambdaCodeSigningEnabled.js b/plugins/aws/lambda/lambdaCodeSigningEnabled.js new file mode 100644 index 000000000..e6dd9bfcb --- /dev/null +++ b/plugins/aws/lambda/lambdaCodeSigningEnabled.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Code Signing Enabled', + category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that AWS Lambda functions are configured to use the code signing feature.', + more_info: 'Code signing for AWS Lambda helps to ensure that only trusted code runs in Lambda functions. Enabling code signing feature ensures that lambda checks every code deployment and verifies that the code package is signed by a trusted source.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/configuration-codesigning.html', + recommended_action: 'Modify Lambda function configurations and enable code signing.', + apis: ['Lambda:listFunctions', 'Lambda:getFunctionCodeSigningConfig'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + for (var lambdaFunc of listFunctions.data) { + + if (!lambdaFunc.FunctionArn || !lambdaFunc.FunctionName) continue; + var resource = lambdaFunc.FunctionArn; + + var codeSigningConfig = helpers.addSource(cache, source, + ['lambda', 'getFunctionCodeSigningConfig', region, lambdaFunc.FunctionName]); + + if (!codeSigningConfig || codeSigningConfig.err || !codeSigningConfig.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda function code signing config: ${helpers.addError(codeSigningConfig)}`, region, resource); + continue; + } + + if (codeSigningConfig.data.CodeSigningConfigArn) { + helpers.addResult(results, 0, 'Lambda function has code signing feature enabled', region, resource); + } else { + helpers.addResult(results, 2, 'Lambda function does not have code signing feature enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaCodeSigningEnabled.spec.js b/plugins/aws/lambda/lambdaCodeSigningEnabled.spec.js new file mode 100644 index 000000000..cee3ca15d --- /dev/null +++ b/plugins/aws/lambda/lambdaCodeSigningEnabled.spec.js @@ -0,0 +1,119 @@ +const expect = require('chai').expect; +const lambdaCodeSigningEnabled = require('./lambdaCodeSigningEnabled'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + } +]; + +const codeSigningEnabledResponse = { + "CodeSigningConfigArn": "arn:aws:lambda:us-east-1:000011112222:function-code-signing-config:test-lambda" +}; + +const codeSigningDisabledResponse = {}; + +const createCache = (listFunctions, codeSigningConfig) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + }, + getFunctionCodeSigningConfig: { + 'us-east-1': { + 'test-lambda': { + data: codeSigningConfig + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'Error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data:{ } + } + } + } + }; +}; + +describe('lambdaCodeSigningEnabled', function () { + describe('run', function () { + + it('should PASS if code signing is enabled for Lambda function', function (done) { + const cache = createCache(listFunctions, codeSigningEnabledResponse); + lambdaCodeSigningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Lambda function has code signing feature enabled') + done(); + }); + }); + + it('should FAIL if code signing is not enabled for Lambda function', function (done) { + const cache = createCache(listFunctions, codeSigningDisabledResponse); + lambdaCodeSigningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Lambda function does not have code signing feature enabled') + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + lambdaCodeSigningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Lambda functions') + done(); + }); + }); + it('should UNKNOWN if unable to list Lambda functions code signing config', function (done) { + const cache = createCache(listFunctions, null); + lambdaCodeSigningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Lambda function code signing config') + done(); + }); + }); + + it('should PASS if no Lambda functions found', function (done) { + const cache = createNullCache(); + lambdaCodeSigningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Lambda functions found') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/lambda/lambdaDeadLetterQueue.js b/plugins/aws/lambda/lambdaDeadLetterQueue.js new file mode 100644 index 000000000..e903da011 --- /dev/null +++ b/plugins/aws/lambda/lambdaDeadLetterQueue.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Dead Letter Queue', + category: 'Lambda', + domain: 'Serverless', + severity: 'Low', + description: 'Ensure that AWS Lambda functions are configured to use a Dead Letter Queue.', + more_info: 'Configuring Dead Letter Queues (DLQs) with lambda functions helps to store discarded events and isolate messages that can\'t be processed correctly. It helps to configure an alarm for any messages moved to a dead-letter queue. They are essential in preserving failed events,debugging and determining why their processing didn\'t succeed.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html#invocation-dlq', + recommended_action: 'Modify Lambda function configurations and enable dead letter queue.', + apis: ['Lambda:listFunctions', 'Lambda:getFunctionConfiguration'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + for (var lambdaFunc of listFunctions.data) { + + if (!lambdaFunc.FunctionArn || !lambdaFunc.FunctionName) continue; + + var resource = lambdaFunc.FunctionArn; + + var functionConfig = helpers.addSource(cache, source, ['lambda', 'getFunctionConfiguration', region, lambdaFunc.FunctionName]); + + if (!functionConfig || functionConfig.err || !functionConfig.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda function config: ${helpers.addError(functionConfig)}`, region, resource); + continue; + } + + if (functionConfig.data && + functionConfig.data.DeadLetterConfig && + functionConfig.data.DeadLetterConfig.TargetArn) { + helpers.addResult(results, 0, 'Lambda function has dead letter queue configured', region, resource); + } else { + helpers.addResult(results, 2, 'Lambda function does not have dead letter queue configured', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaDeadLetterQueue.spec.js b/plugins/aws/lambda/lambdaDeadLetterQueue.spec.js new file mode 100644 index 000000000..869ad0d1b --- /dev/null +++ b/plugins/aws/lambda/lambdaDeadLetterQueue.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var lambdaDeadLetterQueue = require('./lambdaDeadLetterQueue'); + +const createCache = (lambdaData, functionConfigData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + }, + getFunctionConfiguration: functionConfigData + } + }; +}; + +describe('Lambda Dead Letter Queue', function () { + describe('run', function () { + it('should return unknown result if unable to list the lambda functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Lambda functions'); + done(); + }; + + const cache = createCache(null, {}); + + lambdaDeadLetterQueue.run(cache, {}, callback); + }); + + it('should return unknown result if unable to list the lambda function config', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Lambda function config'); + done(); + }; + + const cache = createCache(lambdaData, {}); + + lambdaDeadLetterQueue.run(cache, {}, callback); + }); + + it('should return passing result if no lambda function found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Lambda functions found'); + done(); + }; + + const cache = createCache([], {}); + + lambdaDeadLetterQueue.run(cache, {}, callback); + }); + + it('should return passing result if lambda function has Dead Letter Queue configured', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + + const functionConfigData = { + "us-east-1": { + "test-lambda": { + "err": null, + "data": { + "DeadLetterConfig": { + "TargetArn": "arn:aws:sqs:us-east-1:000011112222:test-queue" + } + } + } + } + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Lambda function has dead letter queue configured'); + done(); + }; + + const cache = createCache(lambdaData, functionConfigData); + + lambdaDeadLetterQueue.run(cache, {}, callback); + }); + + it('should return failing result if lambda function does not have Dead Letter Queue configured', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + + const functionConfigData = { + "us-east-1": { + "test-lambda": { + "err": null, + "data": {} + } + } + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Lambda function does not have dead letter queue configured'); + done(); + }; + + const cache = createCache(lambdaData, functionConfigData); + + lambdaDeadLetterQueue.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/aws/lambda/lambdaEnhancedMonitoring.js b/plugins/aws/lambda/lambdaEnhancedMonitoring.js new file mode 100644 index 000000000..c13b64d5d --- /dev/null +++ b/plugins/aws/lambda/lambdaEnhancedMonitoring.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Enhanced Monitoring Enabled', + category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that AWS Lambda functions have enhanced monitoring enabled.', + more_info: 'Enabling enhanced monitoring on lambda functions adds lambda insights extension as a layer to your functions, which helps to collect, aggregate, and summarizes system-level metrics including CPU, memory, disk, and network usage. It helps you to monitor, troubleshoot, and optimize your functions.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/monitoring-insights.html#monitoring-insights-enabling-console', + recommended_action: 'Modify Lambda function configurations and enable enhanced monitoring.', + apis: ['Lambda:listFunctions', 'Lambda:getFunction'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + for (var lambdaFunc of listFunctions.data) { + + if (!lambdaFunc.FunctionArn || !lambdaFunc.FunctionName) continue; + var resource = lambdaFunc.FunctionArn; + + var functionInfo = helpers.addSource(cache, source, + ['lambda', 'getFunction', region, lambdaFunc.FunctionName]); + + if (!functionInfo || functionInfo.err || !functionInfo.data) { + helpers.addResult(results, 3, + `Unable to get Lambda function details: ${helpers.addError(functionInfo)}`, region, resource); + continue; + } + + if (functionInfo.data && + functionInfo.data.Configuration && + functionInfo.data.Configuration.Layers && + functionInfo.data.Configuration.Layers[0] && + functionInfo.data.Configuration.Layers[0].Arn) { + helpers.addResult(results, 0, 'Lambda function has enhanced monitoring enabled', region, resource); + } else { + helpers.addResult(results, 2, 'Lambda function does not have enhanced monitoring enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaEnhancedMonitoring.spec.js b/plugins/aws/lambda/lambdaEnhancedMonitoring.spec.js new file mode 100644 index 000000000..c6273116f --- /dev/null +++ b/plugins/aws/lambda/lambdaEnhancedMonitoring.spec.js @@ -0,0 +1,142 @@ +var expect = require('chai').expect; +var lambdaEnableEnhancedMonitoring = require('./lambdaEnhancedMonitoring'); + +const createCache = (lambdaData,functionInfoData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + }, + getFunction: functionInfoData + + } + }; +}; + +describe('Lambda Enhanced Monitoring Enabled', function () { + describe('run', function () { + it('should return unknown result if unable to list the lambda functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Lambda functions'); + done(); + }; + + const cache = createCache(null); + + lambdaEnableEnhancedMonitoring.run(cache, {}, callback); + }); + + it('should return passing result if no lambda function found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Lambda functions found'); + done(); + }; + + const cache = createCache([]); + + lambdaEnableEnhancedMonitoring.run(cache, {}, callback); + }); + + it('should return passing result if lambda function has enhanced monitoring enabled', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + + const functionInfoData = { + "us-east-1": { + "test-lambda": { + "err": null, + "data": { + "Configuration": { + "Layers": [ + { + "Arn": "arn:aws:lambda:us-east-1:000011112222:layer:LambdaInsightsExtension:1" + } + ] + } + } + } + } + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Lambda function has enhanced monitoring enabled'); + done(); + }; + + const cache = createCache(lambdaData,functionInfoData); + + lambdaEnableEnhancedMonitoring.run(cache, {}, callback); + }); + + it('should return failing result if lambda function does not have enhanced monitoring enabled', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + + const functionInfoData = { + "us-east-1": { + "test-lambda": { + "err": null, + "data": { + "Configuration": { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + } + } + } + }; + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Lambda function does not have enhanced monitoring enabled'); + done(); + }; + + const cache = createCache(lambdaData,functionInfoData); + + lambdaEnableEnhancedMonitoring.run(cache, {}, callback); + }); + + it('should return unknown result if unable to list the lambda function information', function (done) { + const lambdaData = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda" + } + ]; + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to get Lambda function details:'); + done(); + }; + + const cache = createCache(lambdaData, null); + + lambdaEnableEnhancedMonitoring.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/aws/lambda/lambdaHasTags.js b/plugins/aws/lambda/lambdaHasTags.js new file mode 100644 index 000000000..a8f0abfb0 --- /dev/null +++ b/plugins/aws/lambda/lambdaHasTags.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Has Tags', + category: 'Lambda', + domain: 'Serverless', + severity: 'Low', + description: 'Ensure that AWS Lambda functions have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/configuration-tags.html', + recommended_action: 'Modify Lambda function configurations and add new tags', + apis: ['Lambda:listFunctions', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + let existingLambdaARNList = []; + for (var lambdaFunc of listFunctions.data) { + if (!lambdaFunc.FunctionArn) continue; + existingLambdaARNList.push(lambdaFunc.FunctionArn); + } + if (existingLambdaARNList.length){ + helpers.checkTags(cache, 'Lambda function', existingLambdaARNList, region, results, settings); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaHasTags.spec.js b/plugins/aws/lambda/lambdaHasTags.spec.js new file mode 100644 index 000000000..9a90aea36 --- /dev/null +++ b/plugins/aws/lambda/lambdaHasTags.spec.js @@ -0,0 +1,120 @@ +var expect = require('chai').expect; +var lambda = require('./lambdaHasTags'); + +const createCache = (lambdaData, rgData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + } + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + } + } +}; + +describe('lambdaHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list the lambda functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Lambda functions'); + done(); + }; + + const cache = createCache( + null, [] + ); + + lambda.run(cache, {}, callback); + }); + + it('should give passing result if no lambda function found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Lambda functions found'); + done(); + }; + + const cache = createCache( + [], null + ); + + lambda.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query all resources from group'); + done(); + }; + + const cache = createCache( + [{ + "FunctionName": "mb-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + null + ); + + lambda.run(cache, {}, callback); + }); + + it('should give passing result if lambda function has tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Lambda function has tags'); + done(); + }; + + const cache = createCache( + [{ + "FunctionName": "mb-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + [{ + "ResourceARN": "arn:aws:lambda:us-east-1:666", + "Tags": [{key:"key1", value:"value"}], + }] + ); + lambda.run(cache, {}, callback); + }) + + it('should give failing result if lambda function does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Lambda function does not have any tags'); + done(); + }; + + const cache = createCache( + [{ + "FunctionName": "mb-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + [{ + "ResourceARN": "arn:aws:lambda:us-east-1:666", + "Tags": [], + }] + ); + + lambda.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/aws/lambda/lambdaLogGroups.js b/plugins/aws/lambda/lambdaLogGroups.js index 5931203c7..4122ef768 100644 --- a/plugins/aws/lambda/lambdaLogGroups.js +++ b/plugins/aws/lambda/lambdaLogGroups.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Lambda Log Groups', category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', description: 'Ensures each Lambda function has a valid log group attached to it', more_info: 'Every Lambda function created should automatically have a CloudWatch log group generated to handle its log streams.', link: 'https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs.html', recommended_action: 'Update the Lambda function permissions to allow CloudWatch logging.', apis: ['Lambda:listFunctions', 'CloudWatchLogs:describeLogGroups'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], run: function(cache, settings, callback) { var results = []; @@ -51,7 +54,26 @@ module.exports = { if (found) { result = [0, 'Function has log group: ' + found.logGroupName]; } else { - result = [2, 'Function has no log group']; + // check for lambda@edge log groups + var lambdaEdgeLogGroupName = '/aws/lambda/' + region + '.' + func.FunctionName; + for (var cloudwatchRegion of regions.cloudwatchlogs) { + var regionLogGroups = helpers.addSource(cache, source, + ['cloudwatchlogs', 'describeLogGroups', cloudwatchRegion]) || {}; + + if (regionLogGroups.err || !regionLogGroups.data) { + continue; + } + + var foundLogGroup = regionLogGroups.data.find(logGroup => logGroup.logGroupName === lambdaEdgeLogGroupName); + if (foundLogGroup) { + result = [0, 'Function has lambda@edge log group: ' + foundLogGroup.logGroupName + ' in region: ' + cloudwatchRegion]; + break; + } + } + + if (result[1] === '') { + result = [2, 'Function has no log group']; + } } } else { result = [3, 'Unable to obtain log groups for Lambda']; diff --git a/plugins/aws/lambda/lambdaLogGroups.spec.js b/plugins/aws/lambda/lambdaLogGroups.spec.js index e4f79e64b..c108a520d 100644 --- a/plugins/aws/lambda/lambdaLogGroups.spec.js +++ b/plugins/aws/lambda/lambdaLogGroups.spec.js @@ -51,7 +51,7 @@ describe('lambdaLogGroups', function () { const cache = createCache( [{ "FunctionName": "mb-ngsc-test", - "FunctionArn": "arn:aws:lambda:us-east-1:555", + "FunctionArn": "arn:aws:lambda:us-east-1:555", }], null ); @@ -69,11 +69,11 @@ describe('lambdaLogGroups', function () { const cache = createCache( [{ "FunctionName": "mb-ngsc-test", - "FunctionArn": "arn:aws:lambda:us-east-1:666", - }], + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], [{ "logGroupName": "/aws/lambda/mb-ngsc-test", - "arn": "arn:aws:lambda:us-east-1:555", + "arn": "arn:aws:lambda:us-east-1:555", }] ); @@ -91,16 +91,130 @@ describe('lambdaLogGroups', function () { const cache = createCache( [{ "FunctionName": "mb-ngsc-test", - "FunctionArn": "arn:aws:lambda:us-east-1:666", - }], + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], [{ "logGroupName": "/aws/lambda/test", - "arn": "arn:aws:logs:us-east-1:555", + "arn": "arn:aws:logs:us-east-1:555", }] ); + lambda.run(cache, {}, callback); + }) + + it('should give a passing result if a log group names match with the function@edge in the same region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('/aws/lambda/us-east-1.fp-ngsc-test') + done() + }; + + const cache = createCache( + [{ + "FunctionName": "fp-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + [{ + "logGroupName": "/aws/lambda/us-east-1.fp-ngsc-test", + "arn": "arn:aws:logs:us-east-1:555", + }] + ); lambda.run(cache, {}, callback); }) + it('should give a passing result if a log group names match with the function@edge in a different region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('/aws/lambda/us-east-1.fp-ngsc-test') + done() + }; + + const customCache = (lambdaData, cwData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + } + }, + cloudwatchlogs: { + describeLogGroups: { + 'us-east-1':{ + err: null, + data:[] + }, + 'us-east-2': { + err: null, + data: cwData + } + } + } + } + }; + + const cache = customCache( + [{ + "FunctionName": "fp-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + [{ + "logGroupName": "/aws/lambda/us-east-1.fp-ngsc-test", + "arn": "arn:aws:logs:us-east-1:555", + }] + ); + + lambda.run(cache, {}, callback); + }) + + it('should give a failing result if a log group names matches with the function name without the region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('unction has no log group') + done() + }; + + const customCache = (lambdaData, cwData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + } + }, + cloudwatchlogs: { + describeLogGroups: { + 'us-east-1':{ + err: null, + data:[] + }, + 'us-east-2': { + err: null, + data: cwData + } + } + } + } + }; + + const cache = customCache( + [{ + "FunctionName": "fp-ngsc-test", + "FunctionArn": "arn:aws:lambda:us-east-1:666", + }], + [{ + "logGroupName": "/aws/lambda/fp-ngsc-test", + "arn": "arn:aws:logs:us-east-1:555", + }] + ); + + lambda.run(cache, {}, callback); + }) }) -}) \ No newline at end of file +}) diff --git a/plugins/aws/lambda/lambdaNetworkExposure.js b/plugins/aws/lambda/lambdaNetworkExposure.js new file mode 100644 index 000000000..891b16e8d --- /dev/null +++ b/plugins/aws/lambda/lambdaNetworkExposure.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Exposure', + category: 'Lambda', + domain: 'Serverless', + severity: 'Info', + description: 'Check if Lambda functions are exposed to the internet.', + more_info: 'Lambda functions can be exposed to the internet through Function URLs with public access policies or through API Gateway integrations. It\'s important to ensure these endpoints are properly secured.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html', + recommended_action: 'Ensure Lambda Function URLs have proper authorization configured and API Gateway integrations use appropriate security measures.', + apis: ['Lambda:listFunctions', 'Lambda:getFunctionUrlConfig', 'Lambda:getPolicy', + 'APIGateway:getRestApis','APIGateway:getResources', 'APIGateway:getStages', 'APIGateway:getIntegration', 'ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', + 'ELBv2:describeTargetHealth', 'ELBv2:describeListeners', 'EC2:describeSecurityGroups'], + realtime_triggers: ['lambda:CreateFunctionUrlConfig', 'lambda:UpdateFunctionUrlConfig', 'lambda:DeleteFunctionUrlConfig', + 'lambda:AddPermission', 'lambda:RemovePermission', + 'apigateway:CreateRestApi', 'apigateway:DeleteRestApi', 'apigateway:UpdateRestApi', + 'apigateway:CreateStage', 'apigateway:DeleteStage', 'apigateway:UpdateStage', + 'apigateway:PutIntegration', 'apigateway:DeleteIntegration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb) { + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + 'Unable to query for Lambda functions: ' + helpers.addError(listFunctions), region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + let lambdaELBMap = helpers.getLambdaTargetELBs(cache, source, region); + + for (var lambda of listFunctions.data) { + if (!lambda.FunctionArn) continue; + + // Get function URL config and policy for Lambda-specific checks + var getFunctionUrlConfig = helpers.addSource(cache, source, + ['lambda', 'getFunctionUrlConfig', region, lambda.FunctionName]); + + var getPolicy = helpers.addSource(cache, source, + ['lambda', 'getPolicy', region, lambda.FunctionName]); + + let lambdaResource = { + functionUrlConfig: getFunctionUrlConfig, + functionPolicy: getPolicy, + functionArn: lambda.FunctionArn + }; + + let targetingELBs = lambdaELBMap[lambda.FunctionArn] || []; + + let internetExposed = helpers.checkNetworkExposure(cache, source, [], [], targetingELBs, region, results, lambdaResource); + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, + `Lambda function is exposed to the internet through: ${internetExposed}`, + region, lambda.FunctionArn); + } else { + helpers.addResult(results, 0, + 'Lambda function is not exposed to the internet', + region, lambda.FunctionArn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaOldRuntimes.js b/plugins/aws/lambda/lambdaOldRuntimes.js index 7ccde299f..994360ce2 100644 --- a/plugins/aws/lambda/lambdaOldRuntimes.js +++ b/plugins/aws/lambda/lambdaOldRuntimes.js @@ -4,22 +4,57 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Lambda Old Runtimes', category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', description: 'Ensures Lambda functions are not using out-of-date runtime environments.', more_info: 'Lambda runtimes should be kept current with recent versions of the underlying codebase. Deprecated runtimes should not be used.', link: 'http://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html', recommended_action: 'Upgrade the Lambda function runtime to use a more current version.', apis: ['Lambda:listFunctions'], + settings: { + lambda_runtime_fail: { + name: 'Lambda Runtime Fail', + description: 'Return a failing result for lambda runtime before this number of days for their end of life date.', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 0 + } + }, + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var config = { + lambda_runtime_fail: parseInt(settings.lambda_runtime_fail || this.settings.lambda_runtime_fail.default) + }; + var deprecatedRuntimes = [ { 'id':'nodejs', 'name': 'Node.js 0.10', 'endOfLifeDate': '2016-10-31' }, - { 'id':'nodejs4.3', 'name': 'Node.js 4.3', 'endOfLifeDate': '2018-04-30' }, - { 'id':'nodejs4.3-edge', 'name': 'Node.js 4.3', 'endOfLifeDate': '2018-04-30' }, - { 'id':'dotnetcore2.0', 'name': '.Net Core 2.0', 'endOfLifeDate': '2018-10-01' } + { 'id':'nodejs4.3', 'name': 'Node.js 4.3', 'endOfLifeDate': '2020-03-05' }, + { 'id':'nodejs4.3-edge', 'name': 'Node.js 4.3', 'endOfLifeDate': '2020-03-05' }, + { 'id':'nodejs6.10', 'name': 'Node.js 6.10', 'endOfLifeDate': '2019-08-12' }, + { 'id':'nodejs8.10', 'name': 'Node.js 8.10', 'endOfLifeDate': '2020-03-06' }, + { 'id':'nodejs10.x', 'name': 'Node.js 10.x', 'endOfLifeDate': '2021-07-30' }, + { 'id':'nodejs12.x', 'name': 'Node.js 12', 'endOfLifeDate': '2023-03-31'}, + { 'id':'nodejs14.x', 'name': 'Node.js 14', 'endOfLifeDate': '2023-12-04'}, + { 'id':'nodejs16.x', 'name': 'Node.js 16', 'endOfLifeDate': '2024-06-12'}, + { 'id':'nodejs18.x', 'name': 'Node.js 18', 'endOfLifeDate': '2025-04-30'}, + { 'id':'dotnetcore3.1', 'name': '.Net Core 3.1', 'endOfLifeDate': '2023-04-03' }, + { 'id':'dotnetcore2.1', 'name': '.Net Core 2.1', 'endOfLifeDate': '2022-01-05' }, + { 'id':'dotnetcore2.0', 'name': '.Net Core 2.0', 'endOfLifeDate': '2019-05-30' }, + { 'id':'dotnetcore1.0', 'name': '.Net Core 1.0', 'endOfLifeDate': '2019-06-27' }, + { 'id':'dotnet6', 'name': '.Net 6', 'endOfLifeDate': '2024-11-12' }, + { 'id':'dotnet7', 'name': '.Net 7', 'endOfLifeDate': '2024-05-14' }, + { 'id':'python2.7', 'name': 'Python 2.7', 'endOfLifeDate': '2021-07-15' }, + { 'id':'ruby2.5', 'name': 'Ruby 2.5', 'endOfLifeDate': '2021-07-30' }, + { 'id':'ruby2.7', 'name': 'Ruby 2.7', 'endOfLifeDate': '2023-12-07' }, + { 'id':'python3.6', 'name': 'Python 3.6', 'endOfLifeDate': '2022-07-18'}, + { 'id':'python3.7', 'name': 'Python 3.7', 'endOfLifeDate': '2023-12-04'}, + { 'id':'python3.8', 'name': 'Python 3.8', 'endOfLifeDate': '2024-10-14'}, + { 'id':'go1.x', 'name': 'Go 1', 'endOfLifeDate': '2024-01-08'}, + { 'id':'java8', 'name': 'Java 8', 'endOfLifeDate': '2024-01-08'}, ]; async.each(regions.lambda, function(region, rcb){ @@ -39,33 +74,34 @@ module.exports = { return rcb(); } - var found = false; - for (var f in listFunctions.data) { // For resource, attempt to use the endpoint address (more specific) but fallback to the instance identifier var lambdaFunction = listFunctions.data[f]; if (!lambdaFunction.Runtime) continue; - var deprecatedRunTime = deprecatedRuntimes.filter((d) => { + var deprecatedRuntime = deprecatedRuntimes.filter((d) => { return d.id == lambdaFunction.Runtime; }); - - if (deprecatedRunTime && deprecatedRunTime.length>0){ - found = true; - + var version = lambdaFunction.Runtime; + var runtimeDeprecationDate = (deprecatedRuntime && deprecatedRuntime.length && deprecatedRuntime[0].endOfLifeDate) ? Date.parse(deprecatedRuntime[0].endOfLifeDate) : null; + let today = new Date(); + today = Date.parse(`${today.getFullYear()}-${today.getMonth()+1}-${today.getDate()}`); + var difference = runtimeDeprecationDate? Math.round((runtimeDeprecationDate - today)/(1000 * 3600 * 24)): null; + if (runtimeDeprecationDate && today > runtimeDeprecationDate) { helpers.addResult(results, 2, - 'Function is using out-of-date runtime: ' + deprecatedRunTime[0].name + ' end of life: ' + deprecatedRunTime[0].endOfLifeDate, + 'Lambda is using runtime: ' + deprecatedRuntime[0].name + ' which was deprecated on: ' + deprecatedRuntime[0].endOfLifeDate, region, lambdaFunction.FunctionArn); - } - } - - if (!found) { - helpers.addResult(results, 0, - 'No functions using out-of-date runtimes', - region); + } else if (difference && config.lambda_runtime_fail >= difference) { + helpers.addResult(results, 2, + 'Lambda is using runtime: ' + version + ' which is deprecating in ' + Math.abs(difference) + ' days', + region, lambdaFunction.FunctionArn); + } else { + helpers.addResult(results, 0, + 'Lambda is running the current version: ' + version, + region, lambdaFunction.FunctionArn); + } } - rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/lambda/lambdaOldRuntimes.spec.js b/plugins/aws/lambda/lambdaOldRuntimes.spec.js new file mode 100644 index 000000000..6087987a5 --- /dev/null +++ b/plugins/aws/lambda/lambdaOldRuntimes.spec.js @@ -0,0 +1,113 @@ +const expect = require('chai').expect; +var lambdaOldRuntimes = require('./lambdaOldRuntimes'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs22.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "TracingConfig": { "Mode": "PassThrough" } + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs4.3", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + "TracingConfig": { "Mode": "Active" } + } +]; + + +const createCache = (listFunctions) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('lambdaOldRuntimes', function () { + describe('run', function () { + + it('should PASS if functions is using current version', function (done) { + const cache = createCache([listFunctions[0]]); + lambdaOldRuntimes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Lambda is running the current version'); + done(); + }); + }); + + it('should FAIL if function is using out-of-date runtime', function (done) { + const cache = createCache([listFunctions[1]]); + lambdaOldRuntimes.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('which was deprecated on'); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + lambdaOldRuntimes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Lambda functions found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + lambdaOldRuntimes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Lambda functions'); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + lambdaOldRuntimes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/lambda/lambdaPrivilegeAnalysis.js b/plugins/aws/lambda/lambdaPrivilegeAnalysis.js new file mode 100644 index 000000000..783ff52f6 --- /dev/null +++ b/plugins/aws/lambda/lambdaPrivilegeAnalysis.js @@ -0,0 +1,19 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Lambda', + domain: 'Serverless', + severity: 'Info', + description: 'Ensures no Lambda function available in your AWS account is overly-permissive.', + more_info: 'AWS Lambda Function should have most-restrictive IAM permissions for Lambda security best practices.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-permissions.html', + recommended_action: 'Modify IAM role attached with Lambda function to provide the minimal amount of access required to perform its tasks', + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration', 'lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + + } +}; diff --git a/plugins/aws/lambda/lambdaPublicAccess.js b/plugins/aws/lambda/lambdaPublicAccess.js index 9096aa786..a60e99c67 100644 --- a/plugins/aws/lambda/lambdaPublicAccess.js +++ b/plugins/aws/lambda/lambdaPublicAccess.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Lambda Public Access', category: 'Lambda', + domain: 'Serverless', + severity: 'High', description: 'Ensures Lambda functions are not accessible globally', more_info: 'The Lambda function execution policy should not allow public invocation of the function.', link: 'https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html', recommended_action: 'Update the Lambda policy to prevent access from the public.', apis: ['Lambda:listFunctions', 'Lambda:getPolicy'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:AddPermission', 'lambda:RemovePermission','lambda:DeleteFunction'], run: function(cache, settings, callback) { var results = []; @@ -56,7 +59,7 @@ module.exports = { for (var n in normalized) { var statement = normalized[n]; if (statement.Principal) { - var isGlobal = helpers.globalPrincipal(statement.Principal); + var isGlobal = helpers.globalPrincipal(statement.Principal, settings); if (isGlobal) { for (var s in statement.Action) { if (found.indexOf(statement.Action[s]) == -1) { diff --git a/plugins/aws/lambda/lambdaPublicAccess.spec.js b/plugins/aws/lambda/lambdaPublicAccess.spec.js new file mode 100644 index 000000000..3784127a0 --- /dev/null +++ b/plugins/aws/lambda/lambdaPublicAccess.spec.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +var lambdaPublicAccess = require('./lambdaPublicAccess'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler" + } +]; + +const getPolicy = [ + { + "Policy": "{\"Version\":\"2012-10-17\",\"Id\":\"default\",\"Statement\":[{\"Sid\":\"sns\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"sns.amazonaws.com\"},\"Action\":\"lambda:*\",\"Resource\":\"arn:aws:lambda:us-east-1:000011112222:function:test-lambda\"}]}", + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952" + }, + { + "Policy": "{\"Version\":\"2012-10-17\",\"Id\":\"default\",\"Statement\":[{\"Sid\":\"sns\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"sns.amazonaws.com\"},\"Action\":\"lambda:*\",\"Resource\":\"arn:aws:lambda:us-east-1:000011112222:function:test-lambda\"},{\"Sid\":\"global\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"lambda:*\",\"Resource\":\"arn:aws:lambda:us-east-1:000011112222:function:test-lambda\"}]}", + "RevisionId": "3ed6bad6-8315-4aee-804a-ba9d332a8952" + } +]; + + + +const createCache = (listFunctions, getPolicy, listFunctionsErr, getPolicyErr) => { + var functionName = (listFunctions && listFunctions.length) ? listFunctions[0].FunctionName : null; + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: listFunctionsErr, + data: listFunctions + } + }, + getPolicy: { + 'us-east-1': { + [functionName]: { + err: getPolicyErr, + data: getPolicy + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('lambdaPublicAccess', function () { + describe('run', function () { + + it('should PASS if function policy does not allow global access', function (done) { + const cache = createCache([listFunctions[0]], getPolicy[0], null, null); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if function policy allows global access', function (done) { + const cache = createCache([listFunctions[0]], getPolicy[1], null, null); + lambdaPublicAccess.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if function does not have an access policy', function (done) { + const cache = createCache([listFunctions[0]], getPolicy[1], null, { code: "ResourceNotFoundException" }); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createCache(null, null, { message: "Unable to list functions" }, null); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get function role policy', function (done) { + const cache = createCache([listFunctions[0]], getPolicy[1], null, { message: "Unable to get function role policy" }); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + lambdaPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lambda/lambdaTracingEnabled.js b/plugins/aws/lambda/lambdaTracingEnabled.js new file mode 100644 index 000000000..b8561ff0a --- /dev/null +++ b/plugins/aws/lambda/lambdaTracingEnabled.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Tracing Enabled', + category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensures AWS Lambda functions have active tracing for X-Ray.', + more_info: 'AWS Lambda functions should have active tracing in order to gain visibility into the functions execution and performance.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/services-xray.html', + recommended_action: 'Modify Lambda functions to activate tracing', + apis: ['Lambda:listFunctions'], + settings: { + lambda_whitelist: { + name: 'Lambda Functions Whitelisted', + description: 'A comma-delimited list of known lambda function Function Names that should be whitelisted', + regex: '^.{1,255}$', + default: 'Khulnasoft-CSPM-Token-Rotator-Function,-CreateCSPMKeyFunction-,-TriggerDiscoveryFunction-,-GenerateVolumeScanningEx-,-GenerateCSPMExternalIdFu-' + } + }, + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + lambda_whitelist: settings.lambda_whitelist || this.settings.lambda_whitelist.default + }; + + if (config.lambda_whitelist && + config.lambda_whitelist.length) { + config.lambda_whitelist = config.lambda_whitelist.split(','); + } else { + config.lambda_whitelist = []; + } + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + for (var lambdaFunc of listFunctions.data) { + if (!lambdaFunc.FunctionArn) continue; + var resource = lambdaFunc.FunctionArn; + + let whitelisted = false; + if (config.lambda_whitelist.length) { + config.lambda_whitelist.forEach(whitelist => { + if (lambdaFunc.FunctionName.indexOf(whitelist) > -1) { + whitelisted = true; + } + }); + } + + if (whitelisted) { + helpers.addResult(results, 0, + 'The function ' + lambdaFunc.FunctionName + ' is whitelisted.', + region, lambdaFunc.FunctionArn); + } else { + if (lambdaFunc.TracingConfig && + lambdaFunc.TracingConfig.Mode && + lambdaFunc.TracingConfig.Mode.toUpperCase() === 'ACTIVE') { + helpers.addResult(results, 0, + 'Function has active tracing', region, resource); + } else { + helpers.addResult(results, 2, + 'Function does not have active tracing', region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaTracingEnabled.spec.js b/plugins/aws/lambda/lambdaTracingEnabled.spec.js new file mode 100644 index 000000000..805a124d2 --- /dev/null +++ b/plugins/aws/lambda/lambdaTracingEnabled.spec.js @@ -0,0 +1,107 @@ +const expect = require('chai').expect; +var lambdaTracingEnabled = require('./lambdaTracingEnabled'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "TracingConfig": { "Mode": "PassThrough" } + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + "TracingConfig": { "Mode": "Active" } + } +]; + + +const createCache = (listFunctions) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('lambdaTracingEnabled', function () { + describe('run', function () { + + it('should PASS if fcuntion has active tracing', function (done) { + const cache = createCache([listFunctions[1]]); + lambdaTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if function does not have active tracing', function (done) { + const cache = createCache([listFunctions[0]]); + lambdaTracingEnabled.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + lambdaTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + lambdaTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + lambdaTracingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lambda/lambdaUniqueExecutionRole.js b/plugins/aws/lambda/lambdaUniqueExecutionRole.js new file mode 100644 index 000000000..14e698ba9 --- /dev/null +++ b/plugins/aws/lambda/lambdaUniqueExecutionRole.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Lambda Unique Execution Role', + category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that AWS Lambda functions do not share the same execution role.', + more_info: 'An execution role grants required permission to Lambda function to access AWS services and resources. It is recommended to associate the unique IAM role for each Lambda function to follow the principle of least privilege access.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-intro-execution-role.html', + recommended_action: 'Modify Lambda function and add new execution role.', + apis: ['Lambda:listFunctions'], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration', 'lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb){ + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + `Unable to query for Lambda functions: ${helpers.addError(listFunctions)}`, region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + let roleMap = {}; + for (let lambdaFunc of listFunctions.data) { + if (!lambdaFunc.Role) continue; + + if (roleMap[lambdaFunc.Role]){ + roleMap[lambdaFunc.Role].push(lambdaFunc.FunctionArn); + } else { + roleMap[lambdaFunc.Role] = [lambdaFunc.FunctionArn]; + } + } + + for (let lambdaFunc of listFunctions.data) { + if (!lambdaFunc.FunctionArn) continue; + + if (roleMap[lambdaFunc.Role] && roleMap[lambdaFunc.Role].length > 1) { + helpers.addResult(results, 2, 'Lambda function does not have unique execution role', region, lambdaFunc.FunctionArn); + } else { + helpers.addResult(results, 0, 'Lambda function have unique execution role', region, lambdaFunc.FunctionArn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaUniqueExecutionRole.spec.js b/plugins/aws/lambda/lambdaUniqueExecutionRole.spec.js new file mode 100644 index 000000000..8f0ab556e --- /dev/null +++ b/plugins/aws/lambda/lambdaUniqueExecutionRole.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var lambda = require('./lambdaUniqueExecutionRole'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "TracingConfig": { "Mode": "PassThrough" } + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs4.3", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "TracingConfig": { "Mode": "Active" } + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs4.3", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + "TracingConfig": { "Mode": "Active" } + } +]; + +const createCache = (lambdaData) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: null, + data: lambdaData + } + } + }, + } +}; + +describe('lambdaHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list the lambda functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Lambda functions'); + done(); + }; + + const cache = createCache(null, []); + lambda.run(cache, {}, callback); + }); + + it('should give passing result if no lambda function found in region', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Lambda functions found'); + done(); + }; + + const cache = createCache([], null); + lambda.run(cache, {}, callback); + }); + + it('should give passing result if Lambda function have unique execution role', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Lambda function have unique execution role'); + done(); + }; + const cache = createCache([listFunctions[0], listFunctions[2]]); + lambda.run(cache, {}, callback); + }) + + it('should give failing result if lambda function does not have unique execution role', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Lambda function does not have unique execution role'); + done(); + }; + const cache = createCache([listFunctions[0], listFunctions[1]]); + lambda.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/aws/lambda/lambdaVpcConfig.js b/plugins/aws/lambda/lambdaVpcConfig.js index 10ef658da..271eb9238 100644 --- a/plugins/aws/lambda/lambdaVpcConfig.js +++ b/plugins/aws/lambda/lambdaVpcConfig.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Lambda VPC Config', category: 'Lambda', + domain: 'Serverless', + severity: 'Medium', description: 'Ensures Lambda functions are created in a VPC.', more_info: 'Lambda functions should be created in an AWS VPC to avoid exposure to the Internet and to enable communication with VPC resources through NACLs and security groups.', link: 'https://docs.aws.amazon.com/lambda/latest/dg/vpc.html', @@ -14,9 +16,10 @@ module.exports = { name: 'Lambda Functions Whitelisted', description: 'A comma-delimited list of known lambda function Function Names that should be whitelisted', regex: '^.{1,255}$', - default: 'Khulnasoft-CSPM-Token-Rotator-Function' + default: 'Khulnasoft-CSPM-Token-Rotator-Function,-CreateCSPMKeyFunction-,-TriggerDiscoveryFunction-,-GenerateVolumeScanningEx-,-GenerateCSPMExternalIdFu-' } }, + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration','lambda:DeleteFunction'], run: function(cache, settings, callback) { var results = []; @@ -54,9 +57,16 @@ module.exports = { for (var f in listFunctions.data) { // For resource, attempt to use the endpoint address (more specific) but fallback to the instance identifier var lambdaFunction = listFunctions.data[f]; + let whitelisted = false; + if (config.lambda_whitelist.length) { + config.lambda_whitelist.forEach(whitelist => { + if (lambdaFunction.FunctionName.indexOf(whitelist) > -1) { + whitelisted = true; + } + }); + } - if (config.lambda_whitelist.length && - config.lambda_whitelist.indexOf(lambdaFunction.FunctionName)>-1) { + if (whitelisted) { helpers.addResult(results, 0, 'The function ' + lambdaFunction.FunctionName + ' is whitelisted.', region, lambdaFunction.FunctionArn); diff --git a/plugins/aws/lambda/lambdaVpcConfig.spec.js b/plugins/aws/lambda/lambdaVpcConfig.spec.js new file mode 100644 index 000000000..8de461c29 --- /dev/null +++ b/plugins/aws/lambda/lambdaVpcConfig.spec.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +var lambdaVpcConfig = require('./lambdaVpcConfig'); + +const listFunctions = [ + { + "FunctionName": "test-lambda", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", + "Runtime": "nodejs12.x", + "Role": "arn:aws:iam::000011112222:role/lambda-role", + "Handler": "index.handler", + "VpcConfig": { + "SubnetIds": [ + "subnet-6a8b635b", + "subnet-c21b84cc" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ], + "VpcId": "vpc-99de2fe4" + }, + }, + { + "FunctionName": "testing-123", + "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:testing-123", + "Runtime": "nodejs4.3", + "Role": "arn:aws:iam::000011112222:role/service-role/testing-123-role-7t7oo29b", + "Handler": "index.handler", + } +]; + + +const createCache = (listFunctions) => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + data: listFunctions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': { + err: { + message: 'error listing Lambda functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + lambda: { + listFunctions: { + 'us-east-1': null + } + } + }; +}; + +describe('lambdaVpcConfig', function () { + describe('run', function () { + + it('should PASS if the function is whitelisted', function (done) { + const cache = createCache([listFunctions[1]]); + lambdaVpcConfig.run(cache, { lambda_whitelist: 'testing-123' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if function is being launched into a VPC', function (done) { + const cache = createCache([listFunctions[0]]); + lambdaVpcConfig.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if function is not being launched into a VPC', function (done) { + const cache = createCache([listFunctions[1]]); + lambdaVpcConfig.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Lambda functons found', function (done) { + const cache = createCache([]); + lambdaVpcConfig.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lambda functions', function (done) { + const cache = createErrorCache(); + lambdaVpcConfig.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Lambda functions response not found', function (done) { + const cache = createNullCache(); + lambdaVpcConfig.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/lex/lexAudioLogsEncrypted.js b/plugins/aws/lex/lexAudioLogsEncrypted.js new file mode 100644 index 000000000..03e0f5971 --- /dev/null +++ b/plugins/aws/lex/lexAudioLogsEncrypted.js @@ -0,0 +1,166 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Audio Logs Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Lex audio logs are encrypted using desired KMS encryption level', + more_info: 'For audio logs you use default encryption on your S3 bucket or specify an AWS KMS key to encrypt your audio objects. Even if your S3 bucket uses default encryption you can still specify a different AWS KMS key to encrypt your audio objects for enhanced security.', + link: 'https://docs.aws.amazon.com/lex/latest/dg/conversation-logs-encrypting.html', + recommended_action: 'Encrypt Lex audio logs with customer-manager keys (CMKs) present in your account', + apis: ['LexModelsV2:listBots', 'LexModelsV2:listBotAliases', 'LexModelsV2:describeBotAlias', + 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + audio_logs_desired_encryption_level: { + name: 'Lex Audio Logs Target Encryption Level', + description: 'In order (lowest to highest) sse=S3-SSE; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awscmk|externalcmk|cloudhsm)$', + default: 'awscmk' + } + }, + realtime_triggers: ['lexmodelsV2:CreateBotAlias', 'lexmodelsV2:UpdateBotAlias', 'lexmodelsV2:DeleteBotAlias'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.audio_logs_desired_encryption_level || this.settings.audio_logs_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(region.lexmodelsv2, function(region, rcb){ + var listBots = helpers.addSource(cache, source, + ['lexmodelsv2', 'listBots', region]); + + if (!listBots) return rcb(); + + if (listBots.err || !listBots.data) { + helpers.addResult(results, 3, + 'Unable to query for Lex bots: ' + helpers.addError(listBots),region); + return rcb(); + } + + if (!listBots.data.length) { + helpers.addResult(results, 0, 'No Lex bots found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + 'Unable to list KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + for (let bot of listBots.data){ + if (!bot.botId) continue; + + var resource = `arn:${awsOrGov}:lex:${region}:${accountId}:bot/${bot.botId}`; + + var listBotAliases = helpers.addSource(cache, source, + ['lexmodelsv2', 'listBotAliases', region, bot.botId]); + + if (!listBotAliases || listBotAliases.err || !listBotAliases.data) { + helpers.addResult(results, 3, + 'Unable to query for Lex bot aliases: ' + bot.botId + ': ' + helpers.addError(listBotAliases), + region, resource); + continue; + } + + if (!listBotAliases.data.botAliasSummaries || !listBotAliases.data.botAliasSummaries.length) { + helpers.addResult(results, 0, + 'No bot aliases found', region, resource); + continue; + } + + for (let alias of listBotAliases.data.botAliasSummaries) { + if (!alias.botAliasId) continue; + + var aliasArn = `arn:${awsOrGov}:lex:${region}:${accountId}:bot-alias/${bot.botId}/${alias.botAliasId}`; + + var describeBotAlias = helpers.addSource(cache, source, + ['lexmodelsv2', 'describeBotAlias', region, alias.botAliasId]); + + if (!describeBotAlias || + describeBotAlias.err || + !describeBotAlias.data) { + helpers.addResult(results, 3, + 'Unable to query Lex bot alias: ' + helpers.addError(describeBotAlias), region, aliasArn); + continue; + } + + if (!describeBotAlias.data.conversationLogSettings || + !describeBotAlias.data.conversationLogSettings.audioLogSettings) { + helpers.addResult(results, 0, + 'Bot alias does not have any audio logs configured', region, aliasArn); + continue; + } + + let found = false; + + for (let audioLog of describeBotAlias.data.conversationLogSettings.audioLogSettings){ + if (audioLog.destination && + audioLog.destination.s3Bucket) { + found = true; + } + + if (audioLog.destination.s3Bucket.kmsKeyArn) { + var KmsKey = audioLog.destination.s3Bucket.kmsKeyArn; + var keyId = KmsKey.split('/')[1] ? KmsKey.split('/')[1] : KmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, KmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else currentEncryptionLevel = 1; //sse + + } + + if (!found) { + helpers.addResult(results, 2, + 'Bot alias is not saving audio logs on S3', region, aliasArn); + continue; + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Bot audio logs are encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, aliasArn); + } else { + helpers.addResult(results, 2, + `Bot audio logs are encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, aliasArn); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/lex/lexAudioLogsEncrypted.spec.js b/plugins/aws/lex/lexAudioLogsEncrypted.spec.js new file mode 100644 index 000000000..9c8ee136d --- /dev/null +++ b/plugins/aws/lex/lexAudioLogsEncrypted.spec.js @@ -0,0 +1,248 @@ +const expect = require('chai').expect; +var lexAudioLogsEncrypted = require('./lexAudioLogsEncrypted'); + + +const listBots = [ + { + "botId": "ESPNGEMBCT", + "botName": "Siri", + "botStatus": "Available", + "lastUpdatedDateTime": "2021-12-15T12:35:22.123000+05:00" + } +]; + +const listBotAliases = [ + { + "botAliasId": "TSTALIASID", + "botAliasName": "TestBotAlias", + "description": "test bot alias", + "botVersion": "DRAFT", + "botAliasStatus": "Available", + "creationDateTime": "2021-12-15T12:35:22.616000+05:00", + "lastUpdatedDateTime": "2021-12-15T12:35:55.138000+05:00" + }, + { + "botAliasId": "TSTALIASAD", + "botAliasName": "mine1", + "description": "test bot alias", + "botVersion": "DRAFT", + "botAliasStatus": "Available", + "creationDateTime": "2021-12-15T12:35:22.616000+05:00", + "lastUpdatedDateTime": "2021-12-15T12:35:55.138000+05:00" + } +]; + +const describeBotAlias = [ + { + "botAliasId": "TSTALIASID", + "botAliasName": "TestBotAlias", + "description": "test bot alias", + "botVersion": "DRAFT", + "botAliasLocaleSettings": { + "en_US": { + "enabled": true + } + }, + "conversationLogSettings": { + "textLogSettings": [ + { + "enabled": true, + "destination": { + "cloudWatch": { + "cloudWatchLogGroupArn": "arn:aws:logs:us-east-1:000011112222:log-group:mine1", + "logPrefix": "aws/lex/ESPNGEMBCT/TSTALIASID/DRAFT/" + } + } + } + ], + "audioLogSettings": [ + { + "enabled": true, + "destination": { + "s3Bucket": { + "kmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "s3BucketArn": "arn:aws:s3:::viteace-data-bucket", + "logPrefix": "aws/lex/ESPNGEMBCT/TSTALIASID/DRAFT/" + } + } + } + ] + }, + }, +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (listBots, listBotAliases, describeBotAlias, keys, describeKey, listBotsErr, keysErr, listBotAliasesErr, describeKeyErr, describeBotAliasErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var botId = (listBots && listBots.length) ? listBots[0].botId : null; + var botAliasId = (listBotAliases && listBotAliases.length) ? listBotAliases[0].botAliasId : null; + return { + lexmodelsv2: { + listBots: { + 'us-east-1': { + err: listBotsErr, + data: listBots + } + }, + listBotAliases: { + 'us-east-1': { + [botId]: { + err: listBotAliasesErr, + data: { + "botAliasSummaries":listBotAliases + } + } + } + }, + describeBotAlias: { + 'us-east-1': { + [botAliasId]: { + err: describeBotAliasErr, + data: describeBotAlias + } + } + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + lexmodelsv2: { + listBots: { + 'us-east-1': null + } + } + }; +}; + +describe('lexAudioLogsEncrypted', function () { + describe('run', function () { + + it('should PASS if Lex audio logs are encrypted with desired level', function (done) { + const cache = createCache([listBots[0]], [listBotAliases[0]], describeBotAlias[0], listKeys, describeKey[0]); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Lex audio logs are not encrypted with desired level ', function (done) { + const cache = createCache([listBots[0]], [listBotAliases[1]], describeBotAlias[0], listKeys, describeKey[1]); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Lex conversation log settings not enabled', function (done) { + const cache = createCache([]); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lex bots', function (done) { + const cache = createCache(null, null, null, { message: 'Unable to list LookoutVision model'}); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list Lex bot aliases', function (done) { + const cache = createCache([listBots[0]], {}, describeBotAlias[0], null, null, { message: 'Unable to query LookoutVision models'}); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list bots response not found', function (done) { + const cache = createNullCache(); + lexAudioLogsEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache([listBots[0]], null, null, null, null, { message: "Unable to list KMS keys" }); + lexAudioLogsEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/location/geoCollectionDataEncrypted.js b/plugins/aws/location/geoCollectionDataEncrypted.js new file mode 100644 index 000000000..4eb4c8a68 --- /dev/null +++ b/plugins/aws/location/geoCollectionDataEncrypted.js @@ -0,0 +1,119 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Geofence Collection Data Encrypted', + category: 'Location', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Amazon Location geoference collection data is encrypted using desired KMS encryption level.', + more_info: 'Amazon Location Service provides encryption by default to protect sensitive customer data at rest using AWS owned encryption keys. ' + + 'It is recommended to use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt Amazon Location geoference collection with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/location/latest/developerguide/encryption-at-rest.html', + apis: ['Location:listGeofenceCollections','Location:describeGeofenceCollection', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + geoference_collectiondata_desired_encryption_level: { + name: 'Location Geofence Collection Data Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['location:CreateGeofenceCollection', 'location:DeleteGeofenceCollection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.geoference_collectiondata_desired_encryption_level || this.settings.geoference_collectiondata_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.location, function(region, rcb){ + var listGeofenceCollections = helpers.addSource(cache, source, + ['location', 'listGeofenceCollections', region]); + + if (!listGeofenceCollections) return rcb(); + + if (listGeofenceCollections.err || !listGeofenceCollections.data) { + helpers.addResult(results, 3, + 'Unable to query Location geoference collection: ' + helpers.addError(listGeofenceCollections), region); + return rcb(); + } + + if (!listGeofenceCollections.data.length) { + helpers.addResult(results, 0, 'No Location geoference collections found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let collection of listGeofenceCollections.data) { + var resource = `arn:${awsOrGov}:geo:${region}:${accountId}:geofence-collection/${collection.CollectionName}`; + + var describeGeofenceCollection = helpers.addSource(cache, source, + ['location', 'describeGeofenceCollection', region, collection.CollectionName]); + + + if (!describeGeofenceCollection || describeGeofenceCollection.err || !describeGeofenceCollection.data) { + helpers.addResult(results, 3, + `Unable to get Location geoference collection: ${helpers.addError(describeGeofenceCollection)}`, + region, resource); + continue; + } + + if (describeGeofenceCollection.data.KmsKeyId) { + var kmsKey = describeGeofenceCollection.data.KmsKeyId; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Geofence collection data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Geofence collection data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/location/geoCollectionDataEncrypted.spec.js b/plugins/aws/location/geoCollectionDataEncrypted.spec.js new file mode 100644 index 000000000..358dfddd1 --- /dev/null +++ b/plugins/aws/location/geoCollectionDataEncrypted.spec.js @@ -0,0 +1,183 @@ +var expect = require('chai').expect; +var geoCollectionDataEncrypted = require('./geoCollectionDataEncrypted'); + +const listGeofenceCollections = [ + { + "CollectionName": "sadeed1", + "CreateTime": "2021-12-15T12:39:55.016000+00:00", + "Description": "", + "PricingPlan": "MobileAssetTracking", + "PricingPlanDataSource": "Here", + "UpdateTime": "2021-12-15T12:39:55.016000+00:00" + }, + { + "CollectionName": "explore.geofence-collection", + "CreateTime": "2021-12-14T14:15:06.967000+00:00", + "Description": "Created by Amazon Location Service explore", + "PricingPlan": "RequestBasedUsage", + "UpdateTime": "2021-12-14T14:15:06.967000+00:00" + } + +]; + +const describeGeofenceCollection = [ + { + "CollectionArn": "arn:aws:geo:us-east-1:000111222333:geofence-collection/sadeed1", + "CollectionName": "sadeed1", + "CreateTime": "2021-12-15T12:39:55.016000+00:00", + "Description": "", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "PricingPlan": "MobileAssetTracking", + "PricingPlanDataSource": "Here", + "Tags": {}, + "UpdateTime": "2021-12-15T12:39:55.016000+00:00" + }, + { + "CollectionArn": "arn:aws:geo:us-east-1:000111222333:geofence-collection/explore.geofence-collection", + "CollectionName": "explore.geofence-collection", + "CreateTime": "2021-12-14T14:15:06.967000+00:00", + "Description": "Created by Amazon Location Service explore", + "PricingPlan": "RequestBasedUsage", + "Tags": {}, + "UpdateTime": "2021-12-14T14:15:06.967000+00:00" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (collection, keys, describeGeofenceCollection, describeKey, collectionErr, keysErr, describeKeyErr, describeGeofenceCollectionErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var collectionName = (collection && collection.length) ? collection[0].CollectionName: null; + return { + location: { + listGeofenceCollections: { + 'us-east-1': { + err: collectionErr, + data: collection + }, + }, + describeGeofenceCollection: { + 'us-east-1': { + [collectionName]: { + data: describeGeofenceCollection, + err: describeGeofenceCollectionErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('geoCollectionDataEncrypted', function () { + describe('run', function () { + it('should PASS if Location geoference collection data is encrypted with desired encryption level', function (done) { + const cache = createCache([listGeofenceCollections[0]], listKeys, describeGeofenceCollection[0], describeKey[0]); + geoCollectionDataEncrypted.run(cache, { geoference_collectiondata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Location geoference collection data is not encrypted with desired encryption level', function (done) { + const cache = createCache([listGeofenceCollections[1]], listKeys, describeGeofenceCollection[1], describeKey[1]); + geoCollectionDataEncrypted.run(cache, { geoference_collectiondata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Location geoference collections found', function (done) { + const cache = createCache([]); + geoCollectionDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Location geoference collections', function (done) { + const cache = createCache(null, null, null, null, { message: "Unable to list Location geoference collections" }); + geoCollectionDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listGeofenceCollections, null, null, null, null, { message: "Unable to list KMS keys" }); + geoCollectionDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/location/trackerDataEncrypted.js b/plugins/aws/location/trackerDataEncrypted.js new file mode 100644 index 000000000..dfd529aeb --- /dev/null +++ b/plugins/aws/location/trackerDataEncrypted.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Tracker Data Encrypted', + category: 'Location', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Amazon Location tracker data is encrypted using desired KMS encryption level', + more_info: 'Amazon Location Service provides encryption by default to protect sensitive customer data at rest using AWS owned encryption keys. ' + + 'It is recommended to use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt Amazon Location tracker with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/location/latest/developerguide/encryption-at-rest.html', + apis: ['Location:listTrackers','Location:describeTracker', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + location_trackerdata_desired_encryption_level: { + name: 'Location Tracker Data Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['location:CreateTracker', 'location:UpdateTracker', 'location:DeleteTracker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.location_trackerdata_desired_encryption_level || this.settings.location_trackerdata_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.location, function(region, rcb){ + var listTrackers = helpers.addSource(cache, source, + ['location', 'listTrackers', region]); + + if (!listTrackers) return rcb(); + + if (listTrackers.err || !listTrackers.data) { + helpers.addResult(results, 3, + 'Unable to query Location trackers: ' + helpers.addError(listTrackers), region); + return rcb(); + } + + if (!listTrackers.data.length) { + helpers.addResult(results, 0, 'No Location trackers found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let tracker of listTrackers.data) { + var resource = `arn:${awsOrGov}:geo:${region}:${accountId}:tracker/${tracker.TrackerName}`; + + var describeTracker = helpers.addSource(cache, source, + ['location', 'describeTracker', region, tracker.TrackerName]); + + if (!describeTracker || describeTracker.err || !describeTracker.data) { + helpers.addResult(results, 3, + `Unable to get Location Tracker: ${helpers.addError(describeTracker)}`, + region, resource); + continue; + } + + if (describeTracker.data.KmsKeyId) { + var kmsKey = describeTracker.data.KmsKeyId; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Tracker data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Tracker data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/location/trackerDataEncrypted.spec.js b/plugins/aws/location/trackerDataEncrypted.spec.js new file mode 100644 index 000000000..ac1cf5bc3 --- /dev/null +++ b/plugins/aws/location/trackerDataEncrypted.spec.js @@ -0,0 +1,182 @@ +var expect = require('chai').expect; +var trackerDataEncrypted = require('./trackerDataEncrypted'); + +const listTrackers = [ + { + "CreateTime": "2021-12-14T14:15:06.971000+00:00", + "Description": "Created by Amazon Location Service explore", + "PricingPlan": "RequestBasedUsage", + "TrackerName": "explore.tracker", + "UpdateTime": "2021-12-14T14:15:06.971000+00:00" + }, + { + "CreateTime": "2021-12-15T11:58:23.483000+00:00", + "Description": "", + "PricingPlan": "MobileAssetTracking", + "PricingPlanDataSource": "Here", + "TrackerName": "mytracker1", + "UpdateTime": "2021-12-15T11:58:23.483000+00:00" + } +]; + +const describeTracker = [ + { + "CreateTime": "2021-12-15T11:58:23.483000+00:00", + "Description": "", + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "PricingPlan": "MobileAssetTracking", + "PricingPlanDataSource": "Here", + "Tags": {}, + "TrackerArn": "arn:aws:geo:us-east-1:000111222333:tracker/mytracker1", + "TrackerName": "mytracker1", + "UpdateTime": "2021-12-15T11:58:23.483000+00:00" + }, + { + "CreateTime": "2021-12-14T14:15:06.971000+00:00", + "Description": "Created by Amazon Location Service explore", + "PricingPlan": "RequestBasedUsage", + "Tags": {}, + "TrackerArn": "arn:aws:geo:us-east-1:000111222333:tracker/explore.tracker", + "TrackerName": "explore.tracker", + "UpdateTime": "2021-12-14T14:15:06.971000+00:00" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (trackers, keys, describeTracker, describeKey, trackersErr, keysErr, describeKeyErr, describeTrackerErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var tracker = (trackers && trackers.length) ? trackers[0].TrackerName: null; + return { + location: { + listTrackers: { + 'us-east-1': { + err: trackersErr, + data: trackers + }, + }, + describeTracker: { + 'us-east-1': { + [tracker]: { + data: describeTracker, + err: describeTrackerErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('trackerDataEncrypted', function () { + describe('run', function () { + it('should PASS if Location tracker data is encrypted with desired encryption level', function (done) { + const cache = createCache([listTrackers[0]], listKeys, describeTracker[0], describeKey[0]); + trackerDataEncrypted.run(cache, { location_trackerdata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Location tracker data is not encrypted with desired encryption level', function (done) { + const cache = createCache([listTrackers[1]], listKeys, describeTracker[1], describeKey[1]); + trackerDataEncrypted.run(cache, { location_trackerdata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Location tracker found', function (done) { + const cache = createCache([]); + trackerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Location tracker', function (done) { + const cache = createCache(null, null, null, null, { message: "Unable to list Location tracker" }); + trackerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listTrackers, null, null, null, null, { message: "Unable to list KMS keys" }); + trackerDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/lookout/anomalyDetectorEncrypted.js b/plugins/aws/lookout/anomalyDetectorEncrypted.js new file mode 100644 index 000000000..054a2fdfe --- /dev/null +++ b/plugins/aws/lookout/anomalyDetectorEncrypted.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'LookoutMetrics Anomaly Detector Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon LookoutMetrics Anomaly Detector is encrypted using desired KMS encryption level', + more_info: 'Amazon Lookout for Metrics encrypts your data at rest with your choice of an encryption key. If you do not specify an encryption key, your data is encrypted with AWS owned key by default. ' + + 'So use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt Amazon LookoutMetrics Anomaly Detector with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/lookoutmetrics/latest/dev/security-dataprotection.html#security-privacy-atrest', + apis: ['LookoutMetrics:listAnomalyDetectors','LookoutMetrics:describeAnomalyDetector', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + lookoutmetrics_anomalydetectors_desired_encryption_level: { + name: 'LookoutMetrics Anomaly Detector Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['lookoutmetrics:CreateAnomalyDetector', 'lookoutmetrics:UpdateAnomalyDetector', 'lookoutmetrics:DeleteAnomalyDetector'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.lookoutmetrics_anomalydetectors_desired_encryption_level || this.settings.lookoutmetrics_anomalydetectors_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.lookoutmetrics, function(region, rcb){ + var listAnomalyDetectors = helpers.addSource(cache, source, + ['lookoutmetrics', 'listAnomalyDetectors', region]); + + if (!listAnomalyDetectors) return rcb(); + + if (listAnomalyDetectors.err || !listAnomalyDetectors.data) { + helpers.addResult(results, 3, + 'Unable to query LookoutMetrics Anomaly Detector: ' + helpers.addError(listAnomalyDetectors), region); + return rcb(); + } + + if (!listAnomalyDetectors.data.length) { + helpers.addResult(results, 0, 'No LookoutMetrics Anomaly Detectors found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let detector of listAnomalyDetectors.data) { + if (!detector.AnomalyDetectorArn) continue; + + let resource = detector.AnomalyDetectorArn; + + var describeAnomalyDetector = helpers.addSource(cache, source, + ['lookoutmetrics', 'describeAnomalyDetector', region, detector.AnomalyDetectorArn]); + + if (!describeAnomalyDetector || describeAnomalyDetector.err || !describeAnomalyDetector.data) { + helpers.addResult(results, 3, + `Unable to get LookoutMetrics Anomaly Detector: ${helpers.addError(describeAnomalyDetector)}`, + region, resource); + continue; + } + + if (describeAnomalyDetector.data.KmsKeyArn) { + var KmsKey = describeAnomalyDetector.data.KmsKeyArn; + var keyId = KmsKey.split('/')[1] ? KmsKey.split('/')[1] : KmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, KmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `LookoutMetrics Anomaly Detector is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `LookoutMetrics Anomaly Detector is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lookout/anomalyDetectorEncrypted.spec.js b/plugins/aws/lookout/anomalyDetectorEncrypted.spec.js new file mode 100644 index 000000000..c928fe843 --- /dev/null +++ b/plugins/aws/lookout/anomalyDetectorEncrypted.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +var anomalyDetectorEncrypted = require('./anomalyDetectorEncrypted'); + +const listAnomalyDetectors = [ + { + "AnomalyDetectorArn": "arn:aws:lookoutmetrics:us-east-1:000011112222:AnomalyDetector:sadeed1", + "AnomalyDetectorName": "sadeed1", + "CreationTime": "2021-12-16T14:55:07.608000+05:00", + "LastModificationTime": "2021-12-16T14:55:07.609000+05:00", + "Status": "INACTIVE", + "Tags": {} + }, + { + "AnomalyDetectorArn": "arn:aws:lookoutmetrics:us-east-1:000011112222:AnomalyDetector:sadeed2", + "AnomalyDetectorName": "sadeed2", + "CreationTime": "2021-12-16T14:55:07.608000+05:00", + "LastModificationTime": "2021-12-16T14:55:07.609000+05:00", + "Status": "INACTIVE", + "Tags": {} + } +]; + +const describeAnomalyDetector = [ + { + "AnomalyDetectorArn": "arn:aws:lookoutmetrics:us-east-1:000011112222:AnomalyDetector:sadeed1", + "AnomalyDetectorName": "sadeed1", + "AnomalyDetectorConfig": { + "AnomalyDetectorFrequency": "PT5M" + }, + "CreationTime": "2021-12-16T14:55:07.608000+05:00", + "LastModificationTime": "2021-12-16T14:55:07.609000+05:00", + "Status": "INACTIVE", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + { + "AnomalyDetectorArn": "arn:aws:lookoutmetrics:us-east-1:000011112222:AnomalyDetector:sadeed2", + "AnomalyDetectorName": "sadeed2", + "AnomalyDetectorConfig": { + "AnomalyDetectorFrequency": "PT5M" + }, + "CreationTime": "2021-12-16T14:55:07.608000+05:00", + "LastModificationTime": "2021-12-16T14:55:07.609000+05:00", + "Status": "INACTIVE", + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (detectors, keys, describeAnomalyDetector, describeKey, detectorsErr, keysErr, describeKeyErr, describeAnomalyDetectorErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var detectorArn = (detectors && detectors.length) ? detectors[0].AnomalyDetectorArn: null; + return { + lookoutmetrics: { + listAnomalyDetectors: { + 'us-east-1': { + err: detectorsErr, + data: detectors + }, + }, + describeAnomalyDetector: { + 'us-east-1': { + [detectorArn]: { + data: describeAnomalyDetector, + err: describeAnomalyDetectorErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('anomalyDetectorEncrypted', function () { + describe('run', function () { + it('should PASS if LookoutMetrics Anomaly Detector is encrypted with desired encryption level', function (done) { + const cache = createCache([listAnomalyDetectors[0]], listKeys, describeAnomalyDetector[0], describeKey[0]); + anomalyDetectorEncrypted.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if LookoutMetrics Anomaly Detector is not encrypted with desired encryption level', function (done) { + const cache = createCache([listAnomalyDetectors[1]], listKeys, describeAnomalyDetector[1], describeKey[1]); + anomalyDetectorEncrypted.run(cache, {lookoutmetrics_anomalydetectors_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no LookoutMetrics Anomaly Detectors found', function (done) { + const cache = createCache([]); + anomalyDetectorEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list LookoutMetrics Anomaly Detectors', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list LookoutMetrics Anomaly Detectors" }); + anomalyDetectorEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listAnomalyDetectors, null, null, null, { message: "Unable to list KMS keys" }); + anomalyDetectorEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/lookout/equipmentdatasetEncrypted.js b/plugins/aws/lookout/equipmentdatasetEncrypted.js new file mode 100644 index 000000000..ba873bed3 --- /dev/null +++ b/plugins/aws/lookout/equipmentdatasetEncrypted.js @@ -0,0 +1,116 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'LookoutEquipment Dataset Encrypted', + category: 'AI & ML', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon Lookout for Equipment datasets are encrypted using desired KMS encryption level', + more_info: 'Amazon Lookout for Equipment encrypts your data at rest with AWS owned KMS key by default. ' + + 'It is recommended to use customer-managed keys instead you will gain more granular control over encryption/decryption process.', + recommended_action: 'Encrypt Amazon LookoutEquipment Dataset with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/lookout-for-equipment/latest/ug/encryption-at-rest.html', + apis: ['LookoutEquipment:listDatasets','LookoutEquipment:describeDataset', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + equipment_dataset_desired_encryption_level: { + name: 'Equipement Dataset Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['lookoutequipment:CreateDataset', 'lookoutequipment:DeleteDataset'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.equipment_dataset_desired_encryption_level || this.settings.equipment_dataset_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.lookoutequipment, function(region, rcb){ + var listDatasets = helpers.addSource(cache, source, + ['lookoutequipment', 'listDatasets', region]); + + if (!listDatasets) return rcb(); + + if (listDatasets.err || !listDatasets.data) { + helpers.addResult(results, 3, + 'Unable to query Lookout for Equipment Dataset: ' + helpers.addError(listDatasets), region); + return rcb(); + } + + if (!listDatasets.data.length) { + helpers.addResult(results, 0, 'No Lookout for Equipment Datasets found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let dataset of listDatasets.data) { + if (!dataset.DatasetArn) continue; + + let resource = dataset.DatasetArn; + + var describeDataset = helpers.addSource(cache, source, + ['lookoutequipment', 'describeDataset', region, dataset.DatasetName]); + + + if (!describeDataset || describeDataset.err || !describeDataset.data) { + helpers.addResult(results, 3, + `Unable to get Lookout for Equipment dataset: ${helpers.addError(describeDataset)}`, + region, resource); + continue; + } + + if (describeDataset.data.ServerSideKmsKeyId) { + var kmsKey = describeDataset.data.ServerSideKmsKeyId; + var keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else currentEncryptionLevel = 2; //awskms + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Datasets is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Datasets is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/lookout/equipmentdatasetEncrypted.spec.js b/plugins/aws/lookout/equipmentdatasetEncrypted.spec.js new file mode 100644 index 000000000..0c4f40218 --- /dev/null +++ b/plugins/aws/lookout/equipmentdatasetEncrypted.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var equipmentdatasetEncrypted = require('./equipmentdatasetEncrypted'); + +const listDatasets = [ + { + "DatasetName": "sadeed1", + "DatasetArn": "arn:aws:lookoutequipment:us-east-1:000011112222:dataset/sadeed1/7e416b3a-c317-44ed-9f51-1430d2191319", + "Status": "CREATED", + "CreatedAt": "2021-12-15T20:25:41.181000+05:00" + }, + { + "DatasetName": "sadeed2", + "DatasetArn": "arn:aws:lookoutequipment:us-east-1:000011112222:dataset/sadeed2/7e416b3a-c317-44ed-9f51-1430d2191319", + "Status": "CREATED", + "CreatedAt": "2021-12-15T20:25:41.181000+05:00" + } +]; + +const describeDataset = [ + { + "DatasetName": "sadeed1", + "DatasetArn": "arn:aws:lookoutequipment:us-east-1:000011112222:dataset/sadeed1/7e416b3a-c317-44ed-9f51-1430d2191319", + "CreatedAt": "2021-12-15T20:25:41.181000+05:00", + "LastUpdatedAt": "2021-12-15T20:25:41.181000+05:00", + "Status": "CREATED", + "Schema": "{\"Components\":[{\"ComponentName\":\"test1\",\"Columns\":[{\"Name\":\"Timestamp\",\"Type\":\"DATETIME\"},{\"Name\":\"Attr1\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr2\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr3\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr4\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr5\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr6\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr7\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr8\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr9\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr10\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr11\",\"Type\":\"DOUBLE\"}]}]}", + "ServerSideKmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + { + "DatasetName": "sadeed1", + "DatasetArn": "arn:aws:lookoutequipment:us-east-1:000011112222:dataset/sadeed1/7e416b3a-c317-44ed-9f51-1430d2191319", + "CreatedAt": "2021-12-15T20:25:41.181000+05:00", + "LastUpdatedAt": "2021-12-15T20:25:41.181000+05:00", + "Status": "CREATED", + "Schema": "{\"Components\":[{\"ComponentName\":\"test1\",\"Columns\":[{\"Name\":\"Timestamp\",\"Type\":\"DATETIME\"},{\"Name\":\"Attr1\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr2\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr3\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr4\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr5\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr6\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr7\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr8\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr9\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr10\",\"Type\":\"DOUBLE\"},{\"Name\":\"Attr11\",\"Type\":\"DOUBLE\"}]}]}", + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (datasets, keys, describeDataset, describeKey, datasetsErr, keysErr, describeKeyErr, describeDatasetErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var datasetName = (datasets && datasets.length) ? datasets[0].DatasetName: null; + return { + lookoutequipment: { + listDatasets: { + 'us-east-1': { + err: datasetsErr, + data: datasets + }, + }, + describeDataset: { + 'us-east-1': { + [datasetName]: { + data: describeDataset, + err: describeDatasetErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('equipmentdatasetEncrypted', function () { + describe('run', function () { + it('should PASS if LookoutEquipment Dataset is encrypted with desired encryption level', function (done) { + const cache = createCache([listDatasets[0]], listKeys, describeDataset[0], describeKey[0]); + equipmentdatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if LookoutEquipment Dataset is not encrypted with desired encryption level', function (done) { + const cache = createCache([listDatasets[1]], listKeys, describeDataset[1], describeKey[1]); + equipmentdatasetEncrypted.run(cache, {equipment_dataset_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no LookoutEquipment Datasets found', function (done) { + const cache = createCache([]); + equipmentdatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list LookoutEquipment Datasets', function (done) { + const cache = createCache([listDatasets[1]], listKeys, describeDataset[1], describeKey[1], { message: "Unable to list LookoutEquipment Datasets" }); + equipmentdatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listDatasets, null, null, null, null, { message: "Unable to list KMS keys" }); + equipmentdatasetEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/lookout/modelDataEncrypted.js b/plugins/aws/lookout/modelDataEncrypted.js new file mode 100644 index 000000000..d956d2f8e --- /dev/null +++ b/plugins/aws/lookout/modelDataEncrypted.js @@ -0,0 +1,142 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Model Data Encrypted', + category: 'AI & ML', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that Lookout for Vision model data is encrypted using desired KMS encryption level', + more_info: 'By default, trained models and manifest files are encrypted in Amazon S3 using server-side encryption with KMS keys stored in AWS Key Management Service (SSE-KMS). ' + + 'You can also use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + link: 'https://docs.aws.amazon.com/lookout-for-vision/latest/developer-guide/security-data-encryption.html', + recommended_action: 'Encrypt LookoutVision model with customer-manager keys (CMKs) present in your account', + apis: ['LookoutVision:listProjects', 'LookoutVision:listModels', 'LookoutVision:describeModel', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + model_data_desired_encryption_level: { + name: 'Vision Data Target Encryption Level', + description: 'In order (lowest to highest) sse=S3-SSE; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['lookoutvision:CreateModel', 'lookoutvision:DeleteModel'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.model_data_desired_encryption_level || this.settings.model_data_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(region.lookoutvision, function(region, rcb){ + var listProjects = helpers.addSource(cache, source, + ['lookoutvision', 'listProjects', region]); + + if (!listProjects) return rcb(); + + if (listProjects.err || !listProjects.data) { + helpers.addResult(results, 3, + 'Unable to query for Lookout for Vision projects: ' + helpers.addError(listProjects), region); + return rcb(); + } + + if (!listProjects.data.length) { + helpers.addResult(results, 0, 'No Lookout for Vision projects found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + 'Unable to list KMS keys: ' + helpers.addError(listKeys), region); + return rcb(); + } + + for (let project of listProjects.data){ + if (!project.ProjectName) continue; + + let projectArn = project.ProjectArn; + + var listModels = helpers.addSource(cache, source, + ['lookoutvision', 'listModels', region, project.ProjectName]); + + if (!listModels || listModels.err || !listModels.data) { + helpers.addResult(results, 3, + 'Unable to query for Lookout for Vision models: ' + project.ProjectName + ': ' + helpers.addError(listModels), + region, projectArn); + continue; + } + + if (!listModels.data.Models || !listModels.data.Models.length) { + helpers.addResult(results, 0, + 'No models found for Lookout for Vision project', + region, projectArn); + continue; + } + + for (let model of listModels.data.Models) { + if (!model.ModelArn) continue; + + let resource = model.ModelArn; + + var describeModel = helpers.addSource(cache, source, + ['lookoutvision', 'describeModel', region, model.ModelArn]); + + if (!describeModel || + describeModel.err || + !describeModel.data || !describeModel.data.ModelDescription) { + helpers.addResult(results, 3, + 'Unable to get Lookout for Vision models: ' + helpers.addError(describeModel), region, resource); + continue; + } + + if (describeModel.data.ModelDescription.KmsKeyId) { + let kmsKey = describeModel.data.ModelDescription.KmsKeyId; + let keyId = kmsKey.split('/')[1] ? kmsKey.split('/')[1] : kmsKey; + + let describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else { + currentEncryptionLevel = 1; //sse + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Model data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Model data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lookout/modelDataEncrypted.spec.js b/plugins/aws/lookout/modelDataEncrypted.spec.js new file mode 100644 index 000000000..86ae41a5e --- /dev/null +++ b/plugins/aws/lookout/modelDataEncrypted.spec.js @@ -0,0 +1,238 @@ +const expect = require('chai').expect; +var modelDataEncrypted = require('./modelDataEncrypted'); + + +const listProjects = [ + { + "ProjectArn": "arn:aws:lookoutvision:us-east-1:000011112222:project/hgvhg", + "ProjectName": "hgvhg", + "CreationTimestamp": "2021-12-17T15:48:01.894000+05:00" + } +]; + +const listModels = [ + { + "CreationTimestamp": "2021-12-17T16:36:51.745000+05:00", + "ModelVersion": "2", + "ModelArn": "arn:aws:lookoutvision:us-east-1:000011112222:model/hgvhg/2", + "Status": "TRAINING_FAILED", + "StatusMessage": "Images in the dataset must have the same dimensions." + }, + { + "CreationTimestamp": "2021-12-17T16:30:44.844000+05:00", + "ModelVersion": "1", + "ModelArn": "arn:aws:lookoutvision:us-east-1:000011112222:model/hgvhg/1", + "Status": "TRAINING_FAILED", + "StatusMessage": "Images in the dataset must have the same dimensions." + } +]; + +const describeModel = [ + { + "ModelDescription": { + "ModelVersion": "2", + "ModelArn": "arn:aws:lookoutvision:us-east-1:000011112222:model/hgvhg/2", + "CreationTimestamp": "2021-12-17T16:36:51.745000+05:00", + "Status": "TRAINING_FAILED", + "StatusMessage": "Images in the dataset must have the same dimensions.", + "OutputConfig": { + "S3Location": { + "Bucket": "lookoutvision-us-east-1-7a197cd243", + "Prefix": "projects/hgvhg/models/" + } + } + } + }, + { + "ModelDescription": { + "ModelVersion": "1", + "ModelArn": "arn:aws:lookoutvision:us-east-1:000011112222:model/hgvhg/1", + "CreationTimestamp": "2021-12-17T16:30:44.844000+05:00", + "Status": "TRAINING_FAILED", + "StatusMessage": "Images in the dataset must have the same dimensions.", + "OutputConfig": { + "S3Location": { + "Bucket": "lookoutvision-us-east-1-7a197cd243", + "Prefix": "projects/hgvhg/models/" + } + }, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (listProjects, listModels, describeModel, keys, describeKey, listProjectsErr, keysErr, listModelsErr, describeKeyErr, describeModelErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var projectName = (listProjects && listProjects.length) ? listProjects[0].ProjectName : null; + var modelArn = (listModels && listModels.length) ? listModels[0].ModelArn : null; + return { + lookoutvision: { + listProjects: { + 'us-east-1': { + err: listProjectsErr, + data: listProjects + } + }, + listModels: { + 'us-east-1': { + [projectName]: { + err: listModelsErr, + data: { + "Models": listModels + } + } + } + }, + describeModel: { + 'us-east-1': { + [modelArn]: { + err: describeModelErr, + data: describeModel + } + } + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + lookoutvision: { + listProjects: { + 'us-east-1': null + } + } + }; +}; + +describe('modelDataEncrypted', function () { + describe('run', function () { + + it('should PASS if LookoutVision model data is encrypted with desired level', function (done) { + const cache = createCache([listProjects[0]], [listModels[1]], describeModel[1], listKeys, describeKey[0]); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if LookoutVision model data is not encrypted with desired level ', function (done) { + const cache = createCache([listProjects[0]], [listModels[0]], describeModel[0], listKeys, describeKey[1]); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no projects found', function (done) { + const cache = createCache([]); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list LookoutVision projects', function (done) { + const cache = createCache(null, null, null, null, { message: 'Unable to list LookoutVision model'}); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list LookoutVision models', function (done) { + const cache = createCache([listProjects[0]], null, describeModel[0], listKeys, null, null, null, { message: 'Unable to query LookoutVision models'}); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list projects response not found', function (done) { + const cache = createNullCache(); + modelDataEncrypted.run(cache, { model_data_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache([listProjects[0]], null, null, null, null, null, { message: "Unable to list KMS keys" }); + modelDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.js b/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.js new file mode 100644 index 000000000..4790a09e4 --- /dev/null +++ b/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Managed Blockchain Network Member CloudWatch Logs', + category: 'Managed Blockchain', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Amazon Managed Blockchain members have CloudWatch logs enabled.', + more_info: 'Enabling CloudWatch Logs for Amazon Managed Blockchain members is essential for monitoring certificate authority (CA) activity, ensuring proper identity management, and troubleshooting any access-related issues by publishing CA logs.', + link: 'https://docs.aws.amazon.com/managed-blockchain/latest/hyperledger-fabric-dev/monitoring-cloudwatch-logs.html', + recommended_action: 'Modify Managed Blockchain members to enable CloudWatch Logs', + apis: ['ManagedBlockchain:listMembers', 'ManagedBlockchain:listNetworks', 'ManagedBlockchain:getMember'], + realtime_triggers: ['managedblockchain:CreateNetwork', 'managedblockchain:DeleteMember'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.managedblockchain, function(region, rcb){ + var listNetworks = helpers.addSource(cache, source, + ['managedblockchain', 'listNetworks', region]); + + if (!listNetworks) return rcb(); + + if (listNetworks.err || !listNetworks.data) { + helpers.addResult(results, 3, + `Unable to query for Managed Blockchain networks: ${helpers.addError(listNetworks)}`, region); + return rcb(); + } + + if (!listNetworks.data.length) { + helpers.addResult(results, 0, 'No Managed Blockchain networks found', region); + return rcb(); + } + + for (let network of listNetworks.data) { + if (!network.Id || !network.Arn) continue; + + let listMembers = helpers.addSource(cache, source, + ['managedblockchain', 'listMembers', region, network.Id]); + + if (!listMembers || listMembers.err || !listMembers.data || !listMembers.data.Members) { + helpers.addResult(results, 3, + `Unable to query network members: ${helpers.addError(listMembers)}`, + region, network.Arn); + continue; + } + + if (!listMembers.data.Members.length) { + helpers.addResult(results, 0, 'No network members found', region, network.Arn); + continue; + } + + for (let member of listMembers.data.Members) { + if (!member.Id || !member.Arn) continue; + + let resource = member.Arn; + let getMember = helpers.addSource(cache, source, + ['managedblockchain', 'getMember', region, member.Id]); + + if (!getMember || getMember.err || !getMember.data || !getMember.data.Member) { + helpers.addResult(results, 3, + `Unable to query network member: ${helpers.addError(getMember)}`, + region, member.Arn); + continue; + } + const getmember = getMember.data.Member; + + if (getmember.LogPublishingConfiguration && getmember.LogPublishingConfiguration.Fabric && + getmember.LogPublishingConfiguration.Fabric.CaLogs && getmember.LogPublishingConfiguration.Fabric.CaLogs.Cloudwatch + && getmember.LogPublishingConfiguration.Fabric.CaLogs.Cloudwatch.Enabled) { + helpers.addResult(results, 0, + 'Network member has CloudWatch logs enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'Network member does not have CloudWatch logs enabled', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.spec.js b/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.spec.js new file mode 100644 index 000000000..3feb3d3a6 --- /dev/null +++ b/plugins/aws/managedblockchain/networkMemberCloudwatchLogs.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var networkMemberCloudwatchLogs = require('./networkMemberCloudwatchLogs'); + +const listNetworks = [ + { + "Id": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Name": "akhtar-net", + "Description": null, + "Framework": "HYPERLEDGER_FABRIC", + "FrameworkVersion": "1.4", + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.158Z", + "Arn": "arn:aws:managedblockchain:us-east-1::networks/n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ" + } +]; + +const listMembers = [ + { + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "IsOwned": true, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA" + } +]; + +const getMember = [ + { + "NetworkId": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "FrameworkAttributes": { + "Fabric": { + "AdminUsername": "cloudexploit", + "CaEndpoint": "ca.m-3wdfhockpzfpxoxp5sviyebtya.n-z7ytj3ehsbenrki7um6xw2xwfq.managedblockchain.us-east-1.amazonaws.com:30002" + } + }, + "LogPublishingConfiguration": { + "Fabric": { + "CaLogs": { + "Cloudwatch": { + "Enabled": true + } + } + } + }, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "Tags": {}, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + { + "NetworkId": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "FrameworkAttributes": { + "Fabric": { + "AdminUsername": "cloudexploit", + "CaEndpoint": "ca.m-3wdfhockpzfpxoxp5sviyebtya.n-z7ytj3ehsbenrki7um6xw2xwfq.managedblockchain.us-east-1.amazonaws.com:30002" + } + }, + "LogPublishingConfiguration": { + "Fabric": { + "CaLogs": { + "Cloudwatch": { + "Enabled": false + } + } + } + }, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "Tags": {}, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "KmsKeyArn": "AWS_OWNED_KMS_KEY" + } +]; + + +const createCache = (networks, members, getMember, networksErr) => { + var networkId = (networks && networks.length) ? networks[0].Id : null; + var memberId = (members && members.length) ? members[0].Id : null; + return { + managedblockchain: { + listNetworks: { + 'us-east-1': { + err: networksErr, + data: networks + }, + }, + listMembers: { + 'us-east-1': { + [networkId]: { + data: { + "Members": members + } + } + } + }, + getMember: { + 'us-east-1': { + [memberId]: { + data: { + "Member": getMember + } + } + } + } + }, + }; +}; + +describe('networkMemberCloudwatchLogs', function () { + describe('run', function () { + it('should PASS if Network member has cloudwatch logs enabled', function (done) { + const cache = createCache(listNetworks ,listMembers, getMember[0]); + networkMemberCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Network member has CloudWatch logs enabled'); + done(); + }); + }); + + it('should FAIL if Network member does not have cloudwatch logs enabled', function (done) { + const cache = createCache(listNetworks ,listMembers, getMember[1]); + networkMemberCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Network member does not have CloudWatch logs enabled'); + done(); + }); + }); + + it('should PASS if no Managed Blockchain networks found', function (done) { + const cache = createCache([]); + networkMemberCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Managed Blockchain networks found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query Managed Blockchain networks', function (done) { + const cache = createCache(null, null, null, { message: "unable to obtain data" }); + networkMemberCloudwatchLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Managed Blockchain networks:'); + done(); + }); + }); + + }); +}) \ No newline at end of file diff --git a/plugins/aws/managedblockchain/networkMemberDataEncrypted.js b/plugins/aws/managedblockchain/networkMemberDataEncrypted.js new file mode 100644 index 000000000..56948232e --- /dev/null +++ b/plugins/aws/managedblockchain/networkMemberDataEncrypted.js @@ -0,0 +1,138 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Managed Blockchain Network Member Data Encrypted', + category: 'Managed Blockchain', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that members created in Amazon Managed Blockchain are encrypted using desired encryption level.', + more_info: 'Amazon Managed Blockchain encrypts the network member data at-rest by default with AWS-managed keys. ' + + 'Use your own key (CMK) to encrypt this data to meet regulatory compliance requirements within your organization', + link: 'https://docs.aws.amazon.com/managed-blockchain/latest/hyperledger-fabric-dev/managed-blockchain-encryption-at-rest.html', + recommended_action: 'Ensure members in Managed Blockchain are using desired encryption level for encryption', + apis: ['ManagedBlockchain:listMembers', 'ManagedBlockchain:listNetworks', 'ManagedBlockchain:getMember', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + blockchain_member_encryption_level: { + name: 'Managed Blockchain Member Target Encryption Level', + description: 'In order (lowest to highest) sse=S3-SSE; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['managedblockchain:CreateNetwork', 'managedblockchain:DeleteMember'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.blockchain_member_encryption_level || this.settings.blockchain_member_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.managedblockchain, function(region, rcb){ + var listNetworks = helpers.addSource(cache, source, + ['managedblockchain', 'listNetworks', region]); + + if (!listNetworks) return rcb(); + + if (listNetworks.err || !listNetworks.data) { + helpers.addResult(results, 3, + `Unable to query for Managed Blockchain networks: ${helpers.addError(listNetworks)}`, region); + return rcb(); + } + + if (!listNetworks.data.length) { + helpers.addResult(results, 0, 'No Managed Blockchain networks found', region); + return rcb(); + } + + for (let network of listNetworks.data) { + if (!network.Id || !network.Arn) continue; + + let listMembers = helpers.addSource(cache, source, + ['managedblockchain', 'listMembers', region, network.Id]); + + if (!listMembers || listMembers.err || !listMembers.data || !listMembers.data.Members) { + helpers.addResult(results, 3, + `Unable to query network members: ${helpers.addError(listMembers)}`, + region, network.Arn); + continue; + } + + if (!listMembers.data.Members.length) { + helpers.addResult(results, 0, 'No network members found', region, network.Arn); + continue; + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let member of listMembers.data.Members) { + if (!member.Id || !member.Arn) continue; + + let resource = member.Arn; + let getMember = helpers.addSource(cache, source, + ['managedblockchain', 'getMember', region, member.Id]); + + if (!getMember || getMember.err || !getMember.data || !getMember.data.Member) { + helpers.addResult(results, 3, + `Unable to query network member: ${helpers.addError(getMember)}`, + region, member.Arn); + continue; + } + + if (getMember.data.Member.KmsKeyArn) { + if (getMember.data.Member.KmsKeyArn === 'AWS_OWNED_KMS_KEY') { + currentEncryptionLevel = 2; + } else { + var kmsKeyId = getMember.data.Member.KmsKeyArn.split('/')[1] ? getMember.data.Member.KmsKeyArn.split('/')[1] : getMember.data.Member.KmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Network member is using ${currentEncryptionLevelString} for encryption \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Network member is using ${currentEncryptionLevelString} for encryption \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/managedblockchain/networkMemberDataEncrypted.spec.js b/plugins/aws/managedblockchain/networkMemberDataEncrypted.spec.js new file mode 100644 index 000000000..f983e4ece --- /dev/null +++ b/plugins/aws/managedblockchain/networkMemberDataEncrypted.spec.js @@ -0,0 +1,213 @@ +var expect = require('chai').expect; +var networkMemberDataEncrypted = require('./networkMemberDataEncrypted'); + +const listNetworks = [ + { + "Id": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Name": "akhtar-net", + "Description": null, + "Framework": "HYPERLEDGER_FABRIC", + "FrameworkVersion": "1.4", + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.158Z", + "Arn": "arn:aws:managedblockchain:us-east-1::networks/n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ" + } +]; + +const listMembers = [ + { + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "IsOwned": true, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA" + } +]; + +const getMember = [ + { + "NetworkId": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "FrameworkAttributes": { + "Fabric": { + "AdminUsername": "cloudexploit", + "CaEndpoint": "ca.m-3wdfhockpzfpxoxp5sviyebtya.n-z7ytj3ehsbenrki7um6xw2xwfq.managedblockchain.us-east-1.amazonaws.com:30002" + } + }, + "LogPublishingConfiguration": { + "Fabric": { + "CaLogs": { + "Cloudwatch": { + "Enabled": false + } + } + } + }, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "Tags": {}, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + { + "NetworkId": "n-Z7YTJ3EHSBENRKI7UM6XW2XWFQ", + "Id": "m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "Name": "akhtar", + "Description": null, + "FrameworkAttributes": { + "Fabric": { + "AdminUsername": "cloudexploit", + "CaEndpoint": "ca.m-3wdfhockpzfpxoxp5sviyebtya.n-z7ytj3ehsbenrki7um6xw2xwfq.managedblockchain.us-east-1.amazonaws.com:30002" + } + }, + "LogPublishingConfiguration": { + "Fabric": { + "CaLogs": { + "Cloudwatch": { + "Enabled": false + } + } + } + }, + "Status": "AVAILABLE", + "CreationDate": "2021-11-16T07:46:51.146Z", + "Tags": {}, + "Arn": "arn:aws:managedblockchain:us-east-1:000011112222:members/m-3WDFHOCKPZFPXOXP5SVIYEBTYA", + "KmsKeyArn": "AWS_OWNED_KMS_KEY" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (networks, members, getMember, keys, describeKey, networksErr, keysErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + var networkId = (networks && networks.length) ? networks[0].Id : null; + var memberId = (members && members.length) ? members[0].Id : null; + return { + managedblockchain: { + listNetworks: { + 'us-east-1': { + err: networksErr, + data: networks + }, + }, + listMembers: { + 'us-east-1': { + [networkId]: { + data: { + "Members": members + } + } + } + }, + getMember: { + 'us-east-1': { + [memberId]: { + data: { + "Member": getMember + } + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('networkMemberDataEncrypted', function () { + describe('run', function () { + it('should PASS if Network member is using desired encryption level', function (done) { + const cache = createCache(listNetworks ,listMembers, getMember[0], listKeys, describeKey[0]); + networkMemberDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Network member is not using desired encryption level', function (done) { + const cache = createCache(listNetworks ,listMembers, getMember[1], listKeys, describeKey[0]); + networkMemberDataEncrypted.run(cache, {blockchain_member_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Managed Blockchain networks found', function (done) { + const cache = createCache([]); + networkMemberDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query Managed Blockchain networks', function (done) { + const cache = createCache(null, null, null, null, null, { message: "unable to obtain data" }); + networkMemberDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, null, null, null, { message: "Unable to list KMS keys" }); + networkMemberDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/memorydb/memorydbClusterEncrypted.js b/plugins/aws/memorydb/memorydbClusterEncrypted.js new file mode 100644 index 000000000..e242a0564 --- /dev/null +++ b/plugins/aws/memorydb/memorydbClusterEncrypted.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MemoryDB Cluster Encrypted', + category: 'MemoryDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that your Amazon MemoryDB cluster is encrypted with desired encryption level.', + more_info: 'To help keep your data secure, MemoryDB at-rest encryption is always enabled to increase data security by encrypting persistent data using AWS-managed KMS keys. ' + + 'Use AWS customer-managed Keys (CMKs) instead in order to have a fine-grained control over data-at-rest encryption/decryption process and meet compliance requirements.', + recommended_action: 'Modify MemoryDB cluster encryption configuration to use desired encryption key', + link: 'https://docs.aws.amazon.com/memorydb/latest/devguide/at-rest-encryption.html', + apis: ['MemoryDB:describeClusters', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + memorydb_cluster_target_encryption_level: { + name: 'MemoryDB Cluster Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['MemoryDB:CreateCluster', 'MemoryDB:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.memorydb_cluster_target_encryption_level || this.settings.memorydb_cluster_target_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.memorydb, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['memorydb', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to list MemoryDB clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, + 'No MemoryDB clusters found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let cluster of describeClusters.data) { + if (!cluster.ARN) continue; + + let resource = cluster.ARN; + + if (!cluster.KmsKeyId) { + currentEncryptionLevel = 2; //awskms + } else { + var kmsKeyId = cluster.KmsKeyId.split('/')[1] ? cluster.KmsKeyId.split('/')[1] : cluster.KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, cluster.KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `MemoryDB cluster is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `MemoryDB cluster is encrypted with ${currentEncryptionLevelString} \ + which is less than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/memorydb/memorydbClusterEncrypted.spec.js b/plugins/aws/memorydb/memorydbClusterEncrypted.spec.js new file mode 100644 index 000000000..3bdd59887 --- /dev/null +++ b/plugins/aws/memorydb/memorydbClusterEncrypted.spec.js @@ -0,0 +1,195 @@ +var expect = require('chai').expect; +var memorydbClusterEncrypted = require('./memorydbClusterEncrypted'); + +const describeClusters = [ + { + "Name": "khulnasoftcluster", + "Status": "creating", + "NumberOfShards": 1, + "ClusterEndpoint": { + "Port": 6379 + }, + "NodeType": "db.r6g.large", + "EngineVersion": "6.2", + "EnginePatchVersion": "6.2.4", + "ParameterGroupName": "default.memorydb-redis6", + "ParameterGroupStatus": "in-sync", + "SecurityGroups": [ + { + "SecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "SubnetGroupName": "subnet1", + "TLSEnabled": true, + "ARN": "arn:aws:memorydb:us-east-1:000111222333:cluster/khulnasoftcluster", + "SnapshotRetentionLimit": 1, + "MaintenanceWindow": "wed:08:00-wed:09:00", + "SnapshotWindow": "06:30-07:30", + "ACLName": "open-access", + "AutoMinorVersionUpgrade": true + }, + { + "Name": "sadeed-cl1", + "Status": "available", + "NumberOfShards": 1, + "ClusterEndpoint": { + "Address": "clustercfg.sadeed-cl1.zvodgj.memorydb.us-east-1.amazonaws.com", + "Port": 6379 + }, + "NodeType": "db.r6g.large", + "EngineVersion": "6.2", + "EnginePatchVersion": "6.2.4", + "ParameterGroupName": "default.memorydb-redis6", + "ParameterGroupStatus": "in-sync", + "SecurityGroups": [ + { + "SecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "SubnetGroupName": "subnet1", + "TLSEnabled": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "ARN": "arn:aws:memorydb:us-east-1:000111222333:cluster/sadeed-cl1", + "SnapshotRetentionLimit": 1, + "MaintenanceWindow": "tue:06:00-tue:07:00", + "SnapshotWindow": "04:00-05:00", + "ACLName": "open-access", + "AutoMinorVersionUpgrade": true + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (clusters, keys, describeKey, logGroupErr, keysErr, describeKeyErr) => { + var keyId = (clusters && clusters.length && clusters[0].KmsKeyId) ? clusters[0].KmsKeyId.split('/')[1] : null; + return { + memorydb: { + describeClusters: { + 'us-east-1': { + err: logGroupErr, + data: clusters + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + +describe('memorydbClusterEncrypted', function () { + describe('run', function () { + it('should PASS if MemoryDB Cluster for Redis is encrypted with desired encryption level', function (done) { + const cache = createCache([describeClusters[1]], listKeys, describeKey[0]); + memorydbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MemoryDB cluster is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MemoryDB Cluster for Redis is not encrypted with desired encyption level', function (done) { + const cache = createCache([describeClusters[0]], listKeys, describeKey[1]); + memorydbClusterEncrypted.run(cache, {memorydb_cluster_target_encryption_level: 'awscmk'} , (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MemoryDB cluster is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no No MemoryDB Cluster found', function (done) { + const cache = createCache([]); + memorydbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MemoryDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MemoryDB Clusters', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list MemoryDB clusters" }); + memorydbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + memorydbClusterEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/mq/mqAutoMinorVersionUpgrade.js b/plugins/aws/mq/mqAutoMinorVersionUpgrade.js new file mode 100644 index 000000000..d4dc19803 --- /dev/null +++ b/plugins/aws/mq/mqAutoMinorVersionUpgrade.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Auto Minor Version Upgrade', + category: 'MQ', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Amazon MQ brokers have the Auto Minor Version Upgrade feature enabled.', + more_info: 'As AWS MQ deprecates minor engine version periodically and provides new versions for upgrade, it is highly recommended that Auto Minor Version Upgrade feature is enabled to apply latest upgrades.', + recommended_action: 'Enabled Auto Minor Version Upgrade feature for MQ brokers', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/broker.html', + apis: ['MQ:listBrokers', 'MQ:describeBroker'], + realtime_triggers: ['mq:CreateBrocker', 'mq:UpdateBrocker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + var describeBroker = helpers.addSource(cache, source, + ['mq', 'describeBroker', region, broker.BrokerId]); + + if (!describeBroker || describeBroker.err || !describeBroker.data) { + helpers.addResult(results, 3, + `Unable to get brokers description: ${helpers.addError(describeBroker)}`, + region, resource); + } else { + if (describeBroker.data.AutoMinorVersionUpgrade) { + helpers.addResult(results, 0, 'Broker has auto minor version upgrade enabled', + region, resource); + } else { + helpers.addResult(results, 2, 'Broker does not have auto minor version upgrade enabled', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqAutoMinorVersionUpgrade.spec.js b/plugins/aws/mq/mqAutoMinorVersionUpgrade.spec.js new file mode 100644 index 000000000..5c672922d --- /dev/null +++ b/plugins/aws/mq/mqAutoMinorVersionUpgrade.spec.js @@ -0,0 +1,134 @@ +const expect = require('chai').expect; +var mqAutoMinorVersionUpgrade = require('./mqAutoMinorVersionUpgrade'); + +const listBrokers = [ + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr1:b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerId: 'b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerName: 'myBr1', + BrokerState: 'RUNNING', + Created: "2021-10-11T09:23:08.234Z", + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.t3.micro' + } +]; + +const describeBroker = [ + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": true, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": [Array], + "IpAddress": '54.161.226.30' + } + ], + "BrokerName": 'myBr1', + "BrokerState": 'RUNNING', + }, + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": false, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": [Array], + "IpAddress": '54.161.226.30' + } + ], + "BrokerName": 'myBr1', + "BrokerState": 'RUNNING', + } +]; + +const createCache = (listBrokers, describeBroker, listErr, getErr) => { + var broker = (listBrokers && listBrokers.length) ? listBrokers[0].BrokerId : null; + return { + mq: { + listBrokers: { + 'us-east-1': { + err: listErr, + data: listBrokers + } + }, + describeBroker: { + 'us-east-1': { + [broker]: { + err: getErr, + data: describeBroker + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': null + } + } + }; +}; + + +describe('mqAutoMinorVersionUpgrade', function () { + describe('run', function () { + + it('should PASS if MQ auto version upgrade enabled', function (done) { + const cache = createCache(listBrokers, describeBroker[0]); + mqAutoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ auto version upgrade not enabled', function (done) { + const cache = createCache(listBrokers, describeBroker[1]); + mqAutoMinorVersionUpgrade.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ brokers found', function (done) { + const cache = createCache([]); + mqAutoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Brokers', function (done) { + const cache = createCache(listBrokers, describeBroker[0], { message: 'error listing MQ brokers'}); + mqAutoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list of MQ brokers not found', function (done) { + const cache = createNullCache(); + mqAutoMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/mq/mqBrokerEncrypted.js b/plugins/aws/mq/mqBrokerEncrypted.js new file mode 100644 index 000000000..2e311a443 --- /dev/null +++ b/plugins/aws/mq/mqBrokerEncrypted.js @@ -0,0 +1,126 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Broker Encrypted', + category: 'MQ', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Amazon MQ brokers have data encrypted at-rest feature enabled.', + more_info: 'Amazon MQ encryption at rest provides enhanced security by encrypting your data using encryption keys stored in the AWS Key Management Service (KMS).', + recommended_action: 'Enabled data at-rest encryption feature for MQ brokers', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/data-protection.html#data-protection-encryption-at-rest', + apis: ['MQ:listBrokers', 'MQ:describeBroker', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + mq_broker_desired_encryption_level: { + name: 'MQ Broker Target Encryption Level', + description: 'In order (lowest to highest) sse=AWS-owned CMK awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['mq:CreateBrocker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.mq_broker_desired_encryption_level || this.settings.mq_broker_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + + if (broker.EngineType && broker.EngineType.toUpperCase() == 'RABBITMQ') { + helpers.addResult(results, 0, `AWS itself controls encryption for ${broker.EngineType.toUpperCase()} broker type`, region); + continue; + } + + var describeBroker = helpers.addSource(cache, source, + ['mq', 'describeBroker', region, broker.BrokerId]); + + if (!describeBroker || describeBroker.err || !describeBroker.data) { + helpers.addResult(results, 3, + `Unable to describe MQ broker: ${helpers.addError(describeBroker)}`, + region, resource); + continue; + } + + if (describeBroker.data.EncryptionOptions && + describeBroker.data.EncryptionOptions.KmsKeyId) { + + let KmsKeyId = describeBroker.data.EncryptionOptions.KmsKeyId; + var keyId = KmsKeyId.split('/')[1] ? KmsKeyId.split('/')[1] : KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else if (describeBroker.data.EncryptionOptions && + describeBroker.data.EncryptionOptions.UseAwsOwnedKey) { + currentEncryptionLevel = 1; + } else { + currentEncryptionLevel = 2; + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `MQ Broker data at-rest is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `MQ Broker data at-rest is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqBrokerEncrypted.spec.js b/plugins/aws/mq/mqBrokerEncrypted.spec.js new file mode 100644 index 000000000..7cdf537c8 --- /dev/null +++ b/plugins/aws/mq/mqBrokerEncrypted.spec.js @@ -0,0 +1,273 @@ +var expect = require('chai').expect; +var mqBrokerEncrypted = require('./mqBrokerEncrypted'); + +const listBrokers = [ + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br:b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerId": "b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerName": "sadeed-br", + "BrokerState": "RUNNING", + "Created": "2021-11-15T08:21:57.182000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro" + }, + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br3:b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerId": "b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerName": "sadeed-br3", + "BrokerState": "RUNNING", + "Created": "2021-11-15T09:58:29.997000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro" + }, + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:mybr1:b-043833c7-190c-4ebf-bbe7-8d930f9f9124", + "BrokerId": "b-043833c7-190c-4ebf-bbe7-8d930f9f9124", + "BrokerName": "mybr1", + "BrokerState": "CREATION_IN_PROGRESS", + "Created": "2021-11-25T12:40:59.605000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro" + } +]; + +const describeBroker = [ + { + "AuthenticationStrategy": "simple", + "AutoMinorVersionUpgrade": true, + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br3:b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerId": "b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerInstances": [], + "BrokerName": "sadeed-br3", + "BrokerState": "CREATION_IN_PROGRESS", + "Configurations": { + "History": [], + "Pending": { + "Id": "c-7bee92e4-afc7-41aa-ad97-22a48d3ef090", + "Revision": 1 + } + }, + "Created": "2021-11-15T09:58:29.997000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EncryptionOptions": { + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/26fb32cb-1abc-4096-93eb-1fa0c6e6efb4", + "UseAwsOwnedKey": false + }, + "EngineType": "ActiveMQ", + "EngineVersion": "5.16.3", + "HostInstanceType": "mq.t3.micro", + }, + { + "AuthenticationStrategy": "simple", + "AutoMinorVersionUpgrade": true, + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br:b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerId": "b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerInstances": [ + { + "ConsoleURL": "https://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:8162", + "Endpoints": [ + "ssl://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:61617", + "amqp+ssl://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:5671", + "stomp+ssl://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:61614", + "mqtt+ssl://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:8883", + "wss://b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4-1.mq.us-east-1.amazonaws.com:61619" + ], + "IpAddress": "172.31.90.204" + } + ], + "BrokerName": "sadeed-br", + "BrokerState": "RUNNING", + "Configurations": { + "Current": { + "Id": "c-d76dd81d-cdba-4533-a71c-feeaf2e2606a", + "Revision": 1 + }, + "History": [] + }, + "Created": "2021-11-15T08:21:57.182000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EncryptionOptions": { + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "UseAwsOwnedKey": false + }, + "EngineType": "ActiveMQ", + "EngineVersion": "5.16.3", + "HostInstanceType": "mq.t3.micro", + }, + { + "AuthenticationStrategy": "simple", + "AutoMinorVersionUpgrade": true, + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:mybr1:b-043833c7-190c-4ebf-bbe7-8d930f9f9124", + "BrokerId": "b-043833c7-190c-4ebf-bbe7-8d930f9f9124", + "BrokerInstances": [], + "BrokerName": "mybr1", + "BrokerState": "CREATION_IN_PROGRESS", + "Configurations": { + "History": [], + "Pending": { + "Id": "c-babd5721-d60a-41b3-815a-d30b6fbfc0a3", + "Revision": 1 + } + }, + "Created": "2021-11-25T12:40:59.605000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EncryptionOptions": { + "UseAwsOwnedKey": true + }, + "EngineType": "ActiveMQ", + "EngineVersion": "5.16.3", + "HostInstanceType": "mq.t3.micro", + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/26fb32cb-1abc-4096-93eb-1fa0c6e6efb4", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (brokers, keys, describeBroker, describeKey, brokersErr, keysErr, describeKeyErr, describeBrokerErr) => { + var keyId = (keys && keys.length && keys[0].KmsKeyId) ? keys[0].KmsKeyId : null; + var BrokerId = (brokers && brokers.length) ? brokers[0].BrokerId: null; + return { + mq: { + listBrokers: { + 'us-east-1': { + err: brokersErr, + data: brokers + }, + }, + describeBroker: { + 'us-east-1': { + [BrokerId]: { + data: describeBroker, + err: describeBrokerErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('mqBrokerEncrypted', function () { + describe('run', function () { + it('should PASS if MQ Broker data at-rest is encrypted with desired encryption level', function (done) { + const cache = createCache(listBrokers[0], listKeys, [describeBroker[1]], describeKey[0]); + mqBrokerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ Broker data at-rest is not encrypted with desired encryption level', function (done) { + const cache = createCache([listBrokers[1]],listKeys, [describeBroker[0]], describeKey[1]); + mqBrokerEncrypted.run(cache, {mq_broker_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ Broker data at-rest is encrypted with AWS owned key', function (done) { + const cache = createCache([listBrokers[2]],listKeys, [describeBroker[2]], describeKey[1]); + mqBrokerEncrypted.run(cache, {mq_broker_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ Broker found', function (done) { + const cache = createCache([]); + mqBrokerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Broker', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list MQ Broker" }); + mqBrokerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listBrokers, null, null, null, { message: "Unable to list KMS keys" }); + mqBrokerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/mq/mqBrokerPublicAccess.js b/plugins/aws/mq/mqBrokerPublicAccess.js new file mode 100644 index 000000000..3aed54d33 --- /dev/null +++ b/plugins/aws/mq/mqBrokerPublicAccess.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Broker Public Accessibility', + category: 'MQ', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Amazon MQ brokers are not publicly accessible.', + more_info: 'Public Amazon MQ brokers can be accessed directly, outside of a Virtual Private Cloud (VPC), therefore every machine on the internet can reach your brokers through their public endpoints and this can increase the opportunity for malicious activity.', + recommended_action: 'Review and update the security group settings to restrict public access to Amazon MQ brokers.', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/using-amazon-mq-securely.html', + apis: ['MQ:listBrokers', 'MQ:describeBroker', 'EC2:describeSecurityGroups'], + realtime_triggers: ['mq:CreateBrocker', 'mq:UpdateBroker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.mq, function(region, rcb) { + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + + var describeBroker = helpers.addSource(cache, source, + ['mq', 'describeBroker', region, broker.BrokerId]); + + if (!describeBroker || describeBroker.err || !describeBroker.data) { + helpers.addResult(results, 3, + `Unable to describe MQ broker: ${helpers.addError(describeBroker)}`, + region, resource); + } else { + if (describeBroker.data.PubliclyAccessible) { + helpers.addResult(results, 2, + 'MQ Broker is publicly accessible', + region, resource); + } else { + helpers.addResult(results, 0, + 'MQ Broker is not publicly accessible', + region, resource); + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/mq/mqBrokerPublicAccess.spec.js b/plugins/aws/mq/mqBrokerPublicAccess.spec.js new file mode 100644 index 000000000..a8cf8bc35 --- /dev/null +++ b/plugins/aws/mq/mqBrokerPublicAccess.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var mqBrokerPublicAccessibility = require('./mqBrokerPublicAccess'); + +const listBrokers = [ + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br:b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerId": "b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerName": "sadeed-br", + "BrokerState": "RUNNING", + "Created": "2021-11-15T08:21:57.182000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro", + "PubliclyAccessible": false + }, + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:public-br:b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerId": "b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerName": "public-br", + "BrokerState": "RUNNING", + "Created": "2021-11-15T09:58:29.997000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro", + "PubliclyAccessible": true + } +]; + +const describeBroker = [ + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:sadeed-br:b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerId": "b-a67fb4c0-2f23-46cf-98cb-7015bd36e1b4", + "BrokerName": "sadeed-br", + "BrokerState": "RUNNING", + "Created": "2021-11-15T08:21:57.182000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro", + "PubliclyAccessible": false + }, + { + "BrokerArn": "arn:aws:mq:us-east-1:000011112222:broker:public-br:b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerId": "b-b4cfface-0aa9-4922-b41d-07fab046cef3", + "BrokerName": "public-br", + "BrokerState": "RUNNING", + "Created": "2021-11-15T09:58:29.997000+00:00", + "DeploymentMode": "SINGLE_INSTANCE", + "EngineType": "ActiveMQ", + "HostInstanceType": "mq.t3.micro", + "PubliclyAccessible": true + } +]; + +const createCache = (brokers, describeBroker, brokersErr, describeBrokerErr) => { + var BrokerId = (brokers && brokers.length) ? brokers[0].BrokerId: null; + return { + mq: { + listBrokers: { + 'us-east-1': { + err: brokersErr, + data: brokers + }, + }, + describeBroker: { + 'us-east-1': { + [BrokerId]: { + data: describeBroker, + err: describeBrokerErr + } + } + } + } + }; +}; + +describe('mqBrokerPublicAccessibility', function () { + describe('run', function () { + it('should PASS if MQ Broker is not publicly accessible', function (done) { + const cache = createCache([listBrokers[0]], describeBroker[0]); + mqBrokerPublicAccessibility.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MQ Broker is not publicly accessible'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ Broker is publicly accessible', function (done) { + const cache = createCache([listBrokers[1]], describeBroker[1]); + mqBrokerPublicAccessibility.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ Broker found', function (done) { + const cache = createCache([]); + mqBrokerPublicAccessibility.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Broker', function (done) { + const cache = createCache(null, null, { message: "Unable to list MQ Broker" }, null); + mqBrokerPublicAccessibility.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe MQ broker', function (done) { + const cache = createCache([listBrokers[0]], null, null, { message: "Unable to describe MQ broker" }); + mqBrokerPublicAccessibility.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/mq/mqDeploymentMode.js b/plugins/aws/mq/mqDeploymentMode.js new file mode 100644 index 000000000..f8ee63e38 --- /dev/null +++ b/plugins/aws/mq/mqDeploymentMode.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Deployment Mode', + category: 'MQ', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that for high availability, your AWS MQ brokers are using the active/standby deployment mode instead of single-instance ', + more_info: 'With the active/standby deployment mode as opposed to the single-broker mode (enabled by default), you can achieve high availability for your Amazon MQ brokers as the service provides failure proof no risk.', + recommended_action: 'Enabled Deployment Mode feature for MQ brokers', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/active-standby-broker-deployment.html', + apis: ['MQ:listBrokers'], + realtime_triggers: ['mq:CreateBrocker', 'mq:UpdateBroker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + + if (broker.DeploymentMode && broker.DeploymentMode.toUpperCase() === 'ACTIVE_STANDBY_MULTI_AZ') { + helpers.addResult(results, 0, 'Broker has active/standby deployment mode enabled', + region, resource); + } else { + helpers.addResult(results, 2, 'Broker does not have active/standby deployment mode enabled', + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqDeploymentMode.spec.js b/plugins/aws/mq/mqDeploymentMode.spec.js new file mode 100644 index 000000000..bfbdb6138 --- /dev/null +++ b/plugins/aws/mq/mqDeploymentMode.spec.js @@ -0,0 +1,103 @@ +const expect = require('chai').expect; +var mqDeploymentMode = require('./mqDeploymentMode'); + +const listBrokers = [ + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr1:b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerId: 'b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerName: 'myBr1', + BrokerState: 'RUNNING', + Created: "2021-10-11T09:23:08.234Z", + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.t3.micro' + }, + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr12:b-b80de4cb-bc4d-4b7f-813b-8e0143927aac', + BrokerId: 'b-b80de4cb-bc4d-4b7f-813b-8e0143927aac', + BrokerName: 'myBr12', + BrokerState: 'RUNNING', + Created: '2021-10-28T07:54:35.000Z', + DeploymentMode: 'ACTIVE_STANDBY_MULTI_AZ', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.t3.micro' + } +]; + +const createCache = (listBrokers, listErr, getErr) => { + var broker = (listBrokers && listBrokers.length) ? listBrokers[0].BrokerId : null; + return { + mq: { + listBrokers: { + 'us-east-1': { + err: listErr, + data: listBrokers + } + }, + } + }; +}; + +const createNullCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': null + } + } + }; +}; + + +describe('mqDeploymentMode', function () { + describe('run', function () { + + it('should PASS if MQ Deployment Mode enabled', function (done) { + const cache = createCache([listBrokers[1]]); + mqDeploymentMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ Deployment Mode not enabled', function (done) { + const cache = createCache([listBrokers[0]]); + mqDeploymentMode.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ brokers found', function (done) { + const cache = createCache([]); + mqDeploymentMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Brokers', function (done) { + const cache = createCache(listBrokers, { message: 'error listing MQ brokers'}); + mqDeploymentMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list of MQ brokers not found', function (done) { + const cache = createNullCache(); + mqDeploymentMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/mq/mqDesiredInstanceType.js b/plugins/aws/mq/mqDesiredInstanceType.js new file mode 100644 index 000000000..cba05b9de --- /dev/null +++ b/plugins/aws/mq/mqDesiredInstanceType.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Desired Broker Instance Type', + category: 'MQ', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that the Amazon MQ broker instances are created with desired instance types.', + more_info: 'Set limits for the type of Amazon MQ broker instances created in your AWS account to address internal compliance ' + + 'requirements and prevent unexpected charges on your AWS bill.', + recommended_action: 'Create MQ broker with desired instance types', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/amazon-mq-broker-architecture.html', + apis: ['MQ:listBrokers'], + settings: { + mq_desired_instance_type: { + name: 'MQ Desired Broker Instance Types', + description: 'Comma-separated list of desired MQ broker instance types', + regex: '^.*$', + default:'' + } + }, + realtime_triggers: ['mq:CreateBrocker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + mq_desired_instance_type: settings.mq_desired_instance_type || this.settings.mq_desired_instance_type.default + }; + + if (!config.mq_desired_instance_type.length) return callback(null, results, source); + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + for (var broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + if (broker.HostInstanceType && broker.HostInstanceType.length && + config.mq_desired_instance_type.includes(broker.HostInstanceType)) { + helpers.addResult(results, 0, + `Broker has desired instance type: ${broker.HostInstanceType}`, + region, broker.BrokerArn); + } else { + helpers.addResult(results, 2, + `Broker does not have desired instance type: ${broker.HostInstanceType}`, + region, broker.BrokerArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqDesiredInstanceType.spec.js b/plugins/aws/mq/mqDesiredInstanceType.spec.js new file mode 100644 index 000000000..01e45e77c --- /dev/null +++ b/plugins/aws/mq/mqDesiredInstanceType.spec.js @@ -0,0 +1,115 @@ +const expect = require('chai').expect; +var mqDesiredInstanceType = require('./mqDesiredInstanceType'); + +const listBrokers = [ + { + BrokerArn: 'arn:aws:mq:us-east-1:000011112222:broker:Mybr1:b-64f1b066-9604-46c0-ad14-b6fe482b29a0', + BrokerId: 'b-64f1b066-9604-46c0-ad14-b6fe482b29a0', + BrokerName: 'Mybr1', + BrokerState: 'RUNNING', + Created: '2021-11-01T07:42:35.629Z', + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.t3.micro' + }, + { + BrokerArn: 'arn:aws:mq:us-east-1:000011112222:broker:MyBr2:b-ec84403c-f14b-438b-889b-e931706aaae6', + BrokerId: 'b-ec84403c-f14b-438b-889b-e931706aaae6', + BrokerName: 'MyBr2', + BrokerState: 'RUNNING', + Created: '2021-11-01T07:43:08.697Z', + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.m5.large' + } +]; + + +const createCache = (listBrokers) => { + return { + mq: { + listBrokers: { + 'us-east-1': { + data: listBrokers + } + } + } + }; +}; + +const createErrorCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': { + err: { + message: 'error listing mq functions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': null + } + } + }; +}; + +describe('mqDesiredInstanceType', function () { + describe('run', function () { + + it('should PASS if brokers have the desired instance type', function (done) { + const cache = createCache([listBrokers[0]]); + mqDesiredInstanceType.run(cache, { mq_desired_instance_type: 'mq.t3.micro' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Broker has desired instance type'); + done(); + }); + }); + + it('should FAIL if broker does not have desired instance type', function (done) { + const cache = createCache([listBrokers[1]]); + mqDesiredInstanceType.run(cache, { mq_desired_instance_type: 'mq.t3.micro' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Broker does not have desired instance type') + done(); + }); + }); + + it('should PASS if no MQ brokers found', function (done) { + const cache = createCache([]); + mqDesiredInstanceType.run(cache, { mq_desired_instance_type: 'mq.m5.large' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MQ brokers found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ brokers', function (done) { + const cache = createErrorCache(); + mqDesiredInstanceType.run(cache, { mq_desired_instance_type: 'mq.m5.large' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query MQ brokers'); + done(); + }); + }); + + it('should not return anything if list mq brokers response not found', function (done) { + const cache = createNullCache(); + mqDesiredInstanceType.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/mq/mqLatestEngineVersion.js b/plugins/aws/mq/mqLatestEngineVersion.js new file mode 100644 index 000000000..e2028e294 --- /dev/null +++ b/plugins/aws/mq/mqLatestEngineVersion.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Latest Engine Version', + category: 'MQ', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that Amazon MQ brokers are using the latest version of Apache ActiveMQ broker engine.', + more_info: 'Using the latest version of Apache ActiveMQ engine helps follow AWS best practices and benefits from the latest features, performance improvements, and security updates.', + recommended_action: 'Update Amazon MQ brokers to the latest version of Apache ActiveMQ broker engine.', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/activemq-version-management.html', + apis: ['MQ:listBrokers', 'MQ:describeBroker'], + realtime_triggers: ['mq:CreateBrocker','mq:UpdateBrocker', 'mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var latestVersion = '5.17.3'; + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + var describeBroker = helpers.addSource(cache, source, + ['mq', 'describeBroker', region, broker.BrokerId]); + + if (!describeBroker || describeBroker.err || !describeBroker.data) { + helpers.addResult(results, 3, + `Unable to get broker description: ${helpers.addError(describeBroker)}`, + region, resource); + } else { + let currentVersion = describeBroker.data.EngineVersion; + if (helpers.compareVersions(currentVersion,latestVersion) >= 0) { + helpers.addResult(results, 0, 'Broker is using the latest ActiveMQ version', + region, resource); + } else { + helpers.addResult(results, 2, 'Broker is not using the latest ActiveMQ version', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqLatestEngineVersion.spec.js b/plugins/aws/mq/mqLatestEngineVersion.spec.js new file mode 100644 index 000000000..6aea25f07 --- /dev/null +++ b/plugins/aws/mq/mqLatestEngineVersion.spec.js @@ -0,0 +1,147 @@ +const expect = require('chai').expect; +const mqLatestEngineVersion = require('./mqLatestEngineVersion'); + +const listBrokers = [ + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr1:b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerId: 'b-943d9442-2bd9-4caa-b1fb-882451bcbb39', + BrokerName: 'myBr1', + BrokerState: 'RUNNING', + Created: "2021-10-11T09:23:08.234Z", + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ACTIVEMQ', + HostInstanceType: 'mq.t3.micro', + EngineVersion: '5.17.3' + }, + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr2:b-943d9442-2bd9-4caa-b1fb-882451bcbb40', + BrokerId: 'b-943d9442-2bd9-4caa-b1fb-882451bcbb40', + BrokerName: 'myBr2', + BrokerState: 'RUNNING', + Created: "2021-09-15T08:45:20.123Z", + DeploymentMode: 'ACTIVE_STANDBY_MULTI_AZ', + EngineType: 'ACTIVEMQ', + HostInstanceType: 'mq.t3.small', + EngineVersion: '5.16.2' + }, +]; + +const describeBroker = [ + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": true, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": ['ssl://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad.mq.us-east-1.amazonaws.com:61617'], + "IpAddress": '54.161.226.30' + } + ], + "BrokerName": 'myBr1', + "BrokerState": 'RUNNING', + "EngineVersion": '5.17.3' + }, + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": false, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cae', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cae', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cae-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": ['ssl://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cae.mq.us-east-1.amazonaws.com:61617'], + "IpAddress": '54.161.226.31' + } + ], + "BrokerName": 'myBr2', + "BrokerState": 'RUNNING', + "EngineVersion": '5.16.2' + }, +]; + +const createCache = (listBrokers, describeBroker, listErr, getErr) => { + var broker = (listBrokers && listBrokers.length) ? listBrokers[0].BrokerId : null; + + return { + mq: { + listBrokers: { + 'us-east-1': { + err: listErr, + data: listBrokers + } + }, + describeBroker: { + 'us-east-1': { + [broker]: { + err: getErr, + data: describeBroker[0] + } + }, + } + } + }; +}; +const createNullCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': null + } + } + }; +}; +describe('mqLatestEngineVersion', function () { + describe('run', function () { + + it('should PASS if broker uses the latest ActiveMQ version', function (done) { + const cache = createCache([listBrokers[0]], [describeBroker[0]], null, null); + mqLatestEngineVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if broker does not use the latest ActiveMQ version', function (done) { + const cache = createCache([listBrokers[1]],[ describeBroker[1]], null, null); + mqLatestEngineVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ brokers found', function (done) { + const cache = createCache([], [], null, null); + mqLatestEngineVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Brokers', function (done) { + const cache = createCache(listBrokers, describeBroker, { message: 'error listing MQ brokers'}, null); + mqLatestEngineVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list mq brokers response not found', function (done) { + const cache = createNullCache(); + mqLatestEngineVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/mq/mqLogExports.js b/plugins/aws/mq/mqLogExports.js new file mode 100644 index 000000000..a5b917457 --- /dev/null +++ b/plugins/aws/mq/mqLogExports.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MQ Log Exports Enabled', + category: 'MQ', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Amazon MQ brokers have the Log Exports feature enabled.', + more_info: 'Amazon MQ has a feature of AWS CloudWatch Logs, a service of storing, accessing and monitoring your log files from different sources within your AWS account.', + recommended_action: 'Enable Log Exports feature for MQ brokers', + link: 'https://docs.aws.amazon.com/amazon-mq/latest/developer-guide/security-logging-monitoring.html', + apis: ['MQ:listBrokers', 'MQ:describeBroker'], + realtime_triggers: ['mq:CreateBroker', 'mq:UpdateBroker','mq:DeleteBrocker'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.mq, function(region, rcb){ + var listBrokers = helpers.addSource(cache, source, + ['mq', 'listBrokers', region]); + + if (!listBrokers) return rcb(); + + if (listBrokers.err || !listBrokers.data) { + helpers.addResult(results, 3, + 'Unable to query MQ brokers: ' + helpers.addError(listBrokers), region); + return rcb(); + } + + if (!listBrokers.data.length) { + helpers.addResult(results, 0, 'No MQ brokers found', region); + return rcb(); + } + for (let broker of listBrokers.data) { + if (!broker.BrokerArn) continue; + + let resource = broker.BrokerArn; + var describeBroker = helpers.addSource(cache, source, + ['mq', 'describeBroker', region, broker.BrokerId]); + + if (!describeBroker || describeBroker.err || !describeBroker.data) { + helpers.addResult(results, 3, + `Unable to describe MQ broker: ${helpers.addError(describeBroker)}`, + region, resource); + } else { + if (describeBroker.data.Logs && (describeBroker.data.Logs.Audit || describeBroker.data.Logs.General)) { + helpers.addResult(results, 0, 'Broker has log exports feature enabled', + region, resource); + } else { + helpers.addResult(results, 2, 'Broker does not have log exports feature enabled', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mq/mqLogExports.spec.js b/plugins/aws/mq/mqLogExports.spec.js new file mode 100644 index 000000000..665a10ccd --- /dev/null +++ b/plugins/aws/mq/mqLogExports.spec.js @@ -0,0 +1,146 @@ +const expect = require('chai').expect; +var mqLogExports = require('./mqLogExports'); + +const listBrokers = [ + { + BrokerArn: 'arn:aws:mq:us-east-1:000111222333:broker:myBr1:b-5bf97c6e-1ce8-48da-9200-ecd32b861be9', + BrokerId: 'b-5bf97c6e-1ce8-48da-9200-ecd32b861be9', + BrokerName: 'myBr1', + BrokerState: 'RUNNING', + Created: '2021-10-12T10:28:35.851Z', + DeploymentMode: 'SINGLE_INSTANCE', + EngineType: 'ActiveMQ', + HostInstanceType: 'mq.t3.micro' + } +]; + +const describeBroker = [ + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": true, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": [Array], + "IpAddress": '54.161.226.30' + } + ], + Logs: { + Audit: false, + AuditLogGroup: '/aws/amazonmq/broker/b-5bf97c6e-1ce8-48da-9200-ecd32b861be9/audit', + General: true, + GeneralLogGroup: '/aws/amazonmq/broker/b-5bf97c6e-1ce8-48da-9200-ecd32b861be9/general' + }, + "BrokerName": 'myBr1', + "BrokerState": 'RUNNING', + }, + { + "AuthenticationStrategy": 'simple', + "AutoMinorVersionUpgrade": false, + "BrokerArn": 'arn:aws:mq:us-east-1:000111222333:broker:MyBroker12:b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerId": 'b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad', + "BrokerInstances": [ + { + "ConsoleURL": 'https://b-127b45ef-fa90-40f4-bf8b-5a7c19b66cad-1.mq.us-east-1.amazonaws.com:8162', + "Endpoints": [Array], + "IpAddress": '54.161.226.30' + } + ], + Logs: { + Audit: false, + AuditLogGroup: '/aws/amazonmq/broker/b-5bf97c6e-1ce8-48da-9200-ecd32b861be9/audit', + General: false, + GeneralLogGroup: '/aws/amazonmq/broker/b-5bf97c6e-1ce8-48da-9200-ecd32b861be9/general' + }, + "BrokerName": 'myBr1', + "BrokerState": 'RUNNING', + } +]; + +const createCache = (listBrokers, describeBroker, listErr, getErr) => { + var broker = (listBrokers && listBrokers.length) ? listBrokers[0].BrokerId : null; + return { + mq: { + listBrokers: { + 'us-east-1': { + err: listErr, + data: listBrokers + } + }, + describeBroker: { + 'us-east-1': { + [broker]: { + err: getErr, + data: describeBroker + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mq: { + listBrokers: { + 'us-east-1': null + } + } + }; +}; + + +describe('mqLogExports', function () { + describe('run', function () { + + it('should PASS if MQ Log Exports Feature enabled', function (done) { + const cache = createCache(listBrokers, describeBroker[0]); + mqLogExports.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MQ Log Exports Feature not enabled', function (done) { + const cache = createCache(listBrokers, describeBroker[1]); + mqLogExports.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MQ brokers found', function (done) { + const cache = createCache([]); + mqLogExports.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MQ Brokers', function (done) { + const cache = createCache(listBrokers, describeBroker[0], { message: 'error listing MQ brokers'}); + mqLogExports.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list of MQ brokers not found', function (done) { + const cache = createNullCache(); + mqLogExports.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/msk/mskClusterCBEncryption.js b/plugins/aws/msk/mskClusterCBEncryption.js new file mode 100644 index 000000000..b90c2f68e --- /dev/null +++ b/plugins/aws/msk/mskClusterCBEncryption.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MSK Cluster Client Broker Encryption', + category: 'MSK', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that only TLS encryption between the client and broker feature is enabled for your Amazon MSK clusters.', + more_info: 'Amazon MSK in-transit encryption is an optional feature which encrypts data in transit between the client and brokers. Select the Transport Layer Security (TLS) protocol to encrypt data as it travels between brokers and clients within the cluster.', + link: 'https://docs.aws.amazon.com/msk/latest/developerguide/msk-encryption.html', + recommended_action: 'Enable only TLS encryption between the client and broker for all MSK clusters', + apis: ['Kafka:listClusters'], + realtime_triggers: ['kafka:CreateCluster','kafka:UpdateClusterConfiguration', 'kafka:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.kafka, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['kafka', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for MSK clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No MSK clusters found', region); + return rcb(); + } + + for (var cluster of listClusters.data) { + if (!cluster.ClusterArn) continue; + + var resource = cluster.ClusterArn; + + if (cluster.EncryptionInfo && + cluster.EncryptionInfo.EncryptionInTransit && + cluster.EncryptionInfo.EncryptionInTransit.ClientBroker && + cluster.EncryptionInfo.EncryptionInTransit.ClientBroker.toUpperCase() === 'TLS') { + helpers.addResult(results, 0, + 'Encryption between the client and broker is only TLS encrypted', region, resource); + } else { + helpers.addResult(results, 2, + 'Encryption between the client and broker is not only TLS encrypted', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/msk/mskClusterCBEncryption.spec.js b/plugins/aws/msk/mskClusterCBEncryption.spec.js new file mode 100644 index 000000000..33c40e596 --- /dev/null +++ b/plugins/aws/msk/mskClusterCBEncryption.spec.js @@ -0,0 +1,120 @@ +var expect = require('chai').expect; +const mskClusterCBEncryption = require('./mskClusterCBEncryption'); + +const listClusters = [ + { + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/sadeedcluster/b08122a8-7104-476a-b6ee-c59444fb04d5-25", + "ClusterName": "sadeedcluster", + "CreationTime": "2022-04-06T14:19:41.573000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + "CurrentVersion": "K3R76HOPU0Z2CB", + "EncryptionInfo": { + "EncryptionAtRest": { + "DataVolumeKMSKeyId": "arn:aws:kms:us-east-1:000011112222:key/39009d78-b364-4a0b-937f-c89a2c2b473f" + }, + "EncryptionInTransit": { + "ClientBroker": "TLS", + "InCluster": true + } + }, + }, + { + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/sadeedcluster/b08122a8-7104-476a-b6ee-c59444fb04d5-25", + "ClusterName": "sadeedcluster", + "CreationTime": "2022-04-06T14:19:41.573000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + "CurrentVersion": "K3R76HOPU0Z2CB", + "EncryptionInfo": { + "EncryptionAtRest": { + "DataVolumeKMSKeyId": "arn:aws:kms:us-east-1:000011112222:key/39009d78-b364-4a0b-937f-c89a2c2b473f" + }, + "EncryptionInTransit": { + "ClientBroker": "TLS_PLAINTEXT", + "InCluster": true + } + }, + } +]; + +const createCache = (clusters) => { + return { + kafka: { + listClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + }, + }, + }, + }, + }; +}; + + +describe('mskClusterCBEncryption', function () { + describe('run', function () { + it('should FAIL if Encryption between the client and broker is not only TLS encrypted', function (done) { + const cache = createCache([listClusters[1]]); + mskClusterCBEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Encryption between the client and broker is not only TLS encrypted'); + done(); + }); + }); + + it('should PASS if Encryption between the client and broker is only TLS encrypted', function (done) { + const cache = createCache([listClusters[0]]); + mskClusterCBEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Encryption between the client and broker is only TLS encrypted'); + done(); + }); + }); + + it('should PASS if no MSK clusters found', function (done) { + const cache = createCache([]); + mskClusterCBEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MSK clusters found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for MSK clusters', function (done) { + const cache = createCache(null); + mskClusterCBEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MSK clusters'); + done(); + }); + }); + + it('should not return any results if there was an error querying for MSK clusters', function (done) { + const cache = createErrorCache(); + mskClusterCBEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/msk/mskClusterEncryptionAtRest.js b/plugins/aws/msk/mskClusterEncryptionAtRest.js new file mode 100644 index 000000000..847fa63fe --- /dev/null +++ b/plugins/aws/msk/mskClusterEncryptionAtRest.js @@ -0,0 +1,109 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MSK Cluster Encryption At-Rest', + category: 'MSK', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Amazon Managed Streaming for Kafka (MSK) clusters are using desired encryption key for at-rest encryption.', + more_info: 'Amazon MSK encrypts all data at rest using AWS-managed KMS keys by default. Use AWS customer-managed Keys (CMKs) instead in order to have a fine-grained control over data-at-rest encryption/decryption process and meet compliance requirements.', + recommended_action: 'Modify MSK cluster encryption configuration to use desired encryption key', + link: 'https://docs.aws.amazon.com/msk/1.0/apireference/clusters-clusterarn-security.html', + apis: ['Kafka:listClusters', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + msk_cluster_desired_encryption_level: { + name: 'MSK Cluster Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['kafka:CreateCluster', 'kafka:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.msk_cluster_desired_encryption_level || this.settings.msk_cluster_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.kafka, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['kafka', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + `Unable to list MSK clusters : ${helpers.addError(listClusters)}`, region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, + 'No MSK clusters found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let cluster of listClusters.data) { + if (!cluster.ClusterArn) continue; + + let resource = cluster.ClusterArn; + + if (cluster.EncryptionInfo && + cluster.EncryptionInfo.EncryptionAtRest && + cluster.EncryptionInfo.EncryptionAtRest.DataVolumeKMSKeyId) { + let dataVolumeKMSKeyId = cluster.EncryptionInfo.EncryptionAtRest.DataVolumeKMSKeyId; + var keyId = dataVolumeKMSKeyId.split('/')[1] ? dataVolumeKMSKeyId.split('/')[1] : dataVolumeKMSKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, dataVolumeKMSKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `MSK cluster is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `MSK cluster is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/msk/mskClusterEncryptionAtRest.spec.js b/plugins/aws/msk/mskClusterEncryptionAtRest.spec.js new file mode 100644 index 000000000..11dc783d1 --- /dev/null +++ b/plugins/aws/msk/mskClusterEncryptionAtRest.spec.js @@ -0,0 +1,194 @@ +var expect = require('chai').expect; +var mskClusterEncryptionAtRest = require('./mskClusterEncryptionAtRest'); + +const listClusters = [ + { + "BrokerNodeGroupInfo": { + "BrokerAZDistribution": "DEFAULT", + "ClientSubnets": [ + "subnet-02ed4181800d4658b", + "subnet-06629b4200870c740" + ], + "InstanceType": "kafka.m5.large", + "SecurityGroups": [ + "sg-0cb6c99daaa6b73c5" + ], + "StorageInfo": { + "EbsStorageInfo": { + "VolumeSize": 100 + } + } + }, + "ClientAuthentication": { + "Sasl": { + "Scram": { + "Enabled": false + }, + "Iam": { + "Enabled": true + } + }, + "Tls": { + "CertificateAuthorityArnList": [], + "Enabled": false + }, + "Unauthenticated": { + "Enabled": true + } + }, + "ClusterArn": "arn:aws:kafka:us-east-1:000111222333:cluster/sadeed-cl1/444e81bf-14ab-4839-923e-ac424325e2df-20", + "ClusterName": "sadeed-cl1", + "CreationTime": "2021-11-16T10:49:45.001000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + "CurrentVersion": "K1VC38T7YXB528", + "EncryptionInfo": { + "EncryptionAtRest": { + "DataVolumeKMSKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + }, + "EncryptionInTransit": { + "ClientBroker": "TLS_PLAINTEXT", + "InCluster": true + } + }, + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (clusters, keys, describeKey, clustersErr, keysErr, describeKeyErr) => { + var keyId = (clusters && clusters.length && clusters[0].EncryptionInfo.EncryptionAtRest.DataVolumeKMSKeyId) ? clusters[0].EncryptionInfo.EncryptionAtRest.DataVolumeKMSKeyId.split('/')[1] : null; + return { + kafka: { + listClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + + +describe('mskClusterEncryptionAtRest', function () { + describe('run', function () { + it('should PASS if MSK Cluster At-Rest is encrypted with desired encryption level', function (done) { + const cache = createCache(listClusters, listKeys, describeKey[0]); + mskClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MSK cluster is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should FAIL if MSK Cluster At-Rest is not encrypted with desired encyption level', function (done) { + const cache = createCache(listClusters, listKeys, describeKey[1]); + mskClusterEncryptionAtRest.run(cache, {msk_cluster_desired_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MSK cluster is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if No MSK Clusters found', function (done) { + const cache = createCache([]); + mskClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MSK clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MSK Clusters', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list MSK clusters" }); + mskClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + mskClusterEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/msk/mskClusterEncryptionInTransit.js b/plugins/aws/msk/mskClusterEncryptionInTransit.js new file mode 100644 index 000000000..275952b1a --- /dev/null +++ b/plugins/aws/msk/mskClusterEncryptionInTransit.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MSK Cluster Encryption In-Transit', + category: 'MSK', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that TLS encryption within the cluster feature is enabled for your Amazon MSK clusters.', + more_info: 'Amazon MSK in-transit encryption is an optional feature which encrypts data in transit within your MSK cluster. You can override this default at the time you create the cluster.', + link: 'https://docs.aws.amazon.com/msk/latest/developerguide/msk-encryption.html', + recommended_action: 'Enable TLS encryption within the cluster for all MSK clusters', + apis: ['Kafka:listClusters'], + realtime_triggers: ['kafka:CreateCluster','kafka:UpdateClusterConfiguration','kafka:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.kafka, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['kafka', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for MSK clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No MSK clusters found', region); + return rcb(); + } + + for (var cluster of listClusters.data) { + if (!cluster.ClusterArn) continue; + + var resource = cluster.ClusterArn; + + if (cluster.EncryptionInfo && + cluster.EncryptionInfo.EncryptionInTransit && + cluster.EncryptionInfo.EncryptionInTransit.InCluster) { + helpers.addResult(results, 0, + 'TLS encryption within the cluster is enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'TLS encryption within the cluster is not enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/msk/mskClusterEncryptionInTransit.spec.js b/plugins/aws/msk/mskClusterEncryptionInTransit.spec.js new file mode 100644 index 000000000..2ac9e8709 --- /dev/null +++ b/plugins/aws/msk/mskClusterEncryptionInTransit.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +const mskClusterEncryptionInTransit = require('./mskClusterEncryptionInTransit'); + +const listClusters = [ + { + "ClusterArn": "arn:aws:kafka:us-east-1:000111222333:cluster/sad/3dce8c4f-76a7-4b74-b1ec-192dec1a750b-20", + "ClusterName": "test", + "CreationTime": "2021-12-01T14:10:20.502000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + "CurrentVersion": "KTVPDKIKX0DER", + "EncryptionInfo": { + "EncryptionAtRest": { + "DataVolumeKMSKeyId": "arn:aws:kms:us-east-1:000111222333:key/39009d78-b364-4a0b-937f-c89a2c2b473f" + }, + "EncryptionInTransit": { + "ClientBroker": "TLS_PLAINTEXT", + "InCluster": true + } + }, + "EnhancedMonitoring": "DEFAULT", + }, + { + "ClusterArn": "arn:aws:kafka:us-east-1:000111222333:cluster/sadeed/d54ff036-fbce-4272-a3f2-3b50ecb66d57-20", "ClusterName": "sadeed", + "ClusterName": "test1", + "CreationTime": "2021-12-01T14:03:48.709000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + "CurrentVersion": "K1F83G8C2ARO7P", + "EncryptionInfo": { + "EncryptionAtRest": { + "DataVolumeKMSKeyId": "arn:aws:kms:us-east-1:000111222333:key/39009d78-b364-4a0b-937f-c89a2c2b473f" + }, + "EncryptionInTransit": { + "ClientBroker": "PLAINTEXT", + "InCluster": false + } + }, + "EnhancedMonitoring": "DEFAULT", + } +]; + +const createCache = (clusters) => { + return { + kafka: { + listClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('mskClusterEncryptionInTransit', function () { + describe('run', function () { + it('should FAIL if TLS encryption within the cluster is not enabled', function (done) { + const cache = createCache([listClusters[1]]); + mskClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if TLS encryption within the cluster is enabled', function (done) { + const cache = createCache([listClusters[0]]); + mskClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS no MSK clusters found', function (done) { + const cache = createCache([]); + mskClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error querying for MSK clusters', function (done) { + const cache = createErrorCache(); + mskClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for MSK clusters', function (done) { + const cache = createNullCache(); + mskClusterEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/msk/mskClusterPublicAccess.js b/plugins/aws/msk/mskClusterPublicAccess.js new file mode 100644 index 000000000..c9aa02ad6 --- /dev/null +++ b/plugins/aws/msk/mskClusterPublicAccess.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MSK Cluster Public Access', + category: 'MSK', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that public access feature within the cluster is disabled for your Amazon MSK clusters.', + more_info: 'Amazon MSK gives you the option to turn on public access to the brokers of MSK clusters running Apache Kafka 2.6.0 or later versions. For security reasons, you cannot turn on public access while creating an MSK cluster. However, you can update an existing cluster to make it publicly accessible.', + link: 'https://docs.aws.amazon.com/msk/latest/developerguide/public-access.html', + recommended_action: 'Check for public access feature within the cluster for all MSK clusters', + apis: ['Kafka:listClusters'], + realtime_triggers: ['kafka:CreateCluster', 'kafka:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.kafka, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['kafka', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for MSK clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No MSK clusters found', region); + return rcb(); + } + + for (var cluster of listClusters.data) { + if (!cluster.ClusterArn) continue; + + var resource = cluster.ClusterArn; + + if (cluster.BrokerNodeGroupInfo && + cluster.BrokerNodeGroupInfo.ConnectivityInfo && + cluster.BrokerNodeGroupInfo.ConnectivityInfo.PublicAccess && + cluster.BrokerNodeGroupInfo.ConnectivityInfo.PublicAccess.Type && + cluster.BrokerNodeGroupInfo.ConnectivityInfo.PublicAccess.Type.toUpperCase() === 'DISABLED') { + helpers.addResult(results, 0, + 'MSK cluster is not publicly accessible', region, resource); + } else { + helpers.addResult(results, 2, + 'MSK cluster is publicly accessible', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/msk/mskClusterPublicAccess.spec.js b/plugins/aws/msk/mskClusterPublicAccess.spec.js new file mode 100644 index 000000000..1fad74e39 --- /dev/null +++ b/plugins/aws/msk/mskClusterPublicAccess.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +const mskClusterPublicAccess = require('./mskClusterPublicAccess'); + +const listClusters = [ + { + "BrokerNodeGroupInfo": { + "BrokerAZDistribution": "DEFAULT", + "ClientSubnets": [ + "subnet-02ed4181800d4658b", + "subnet-04464dfb7e3dfe1ff", + "subnet-04604cc007728c2a7" + ], + "InstanceType": "kafka.m5.large", + "SecurityGroups": [ + "sg-0cb6c99daaa6b73c5" + ], + "StorageInfo": { + "EbsStorageInfo": { + "ProvisionedThroughput": { + "Enabled": false + }, + "VolumeSize": 1 + } + }, + "ConnectivityInfo": { + "PublicAccess": { + "Type": "SERVICE_PROVIDED_EIPS" + } + } + }, + + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/myCluster/794ab280-627c-4705-aaab-9f5b944fb9e3-25", + "ClusterName": "myCluster" + }, + { + "BrokerNodeGroupInfo": { + "BrokerAZDistribution": "DEFAULT", + "ClientSubnets": [ + "subnet-02ed4181800d4658b", + "subnet-04464dfb7e3dfe1ff", + "subnet-06629b4200870c740" + ], + "InstanceType": "kafka.m5.large", + "SecurityGroups": [ + "sg-0cb6c99daaa6b73c5" + ], + "StorageInfo": { + "EbsStorageInfo": { + "ProvisionedThroughput": { + "Enabled": false + }, + "VolumeSize": 1000 + } + }, + "ConnectivityInfo": { + "PublicAccess": { + "Type": "DISABLED" + } + } + }, + + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/sadeedcluster/b08122a8-7104-476a-b6ee-c59444fb04d5-25", + "ClusterName": "sadeedcluster" + + } +]; + +const createCache = (clusters) => { + return { + kafka: { + listClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + }, + }, + }, + }, + }; +}; + + +describe('mskClusterPublicAccess', function () { + describe('run', function () { + it('should FAIL if MSK cluster has public access enabled', function (done) { + const cache = createCache([listClusters[0]]); + mskClusterPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MSK cluster is publicly accessible'); + done(); + }); + }); + + it('should PASS if MSK cluster does not have public access enabled', function (done) { + const cache = createCache([listClusters[1]]); + mskClusterPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MSK cluster is not publicly accessible'); + done(); + }); + }); + + it('should PASS no MSK clusters found', function (done) { + const cache = createCache([]); + mskClusterPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MSK clusters found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for MSK clusters', function (done) { + const cache = createCache(null); + mskClusterPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MSK clusters'); + done(); + }); + }); + + it('should not return any results if there was an error querying for MSK clusters', function (done) { + const cache = createErrorCache(); + mskClusterPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/msk/mskClusterUnauthAccess.js b/plugins/aws/msk/mskClusterUnauthAccess.js new file mode 100644 index 000000000..39d89407b --- /dev/null +++ b/plugins/aws/msk/mskClusterUnauthAccess.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'MSK Cluster Unauthenticated Access', + category: 'MSK', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that unauthenticated access feature is disabled for your Amazon MSK clusters.', + more_info: 'Amazon MSK authenticates clients to allow or deny Apache Kafka actions. Alternatively, TLS or SASL/SCRAM can be used to authenticate clients, and Apache Kafka ACLs to allow or deny actions.', + link: 'https://docs.aws.amazon.com/msk/latest/developerguide/msk-authentication.html', + recommended_action: 'Ensure that MSK clusters does not have unauthenticated access enabled.', + apis: ['Kafka:listClusters'], + realtime_triggers: ['kafka:CreateCluster','kafka:UpdateSecurity', 'kafka:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.kafka, function(region, rcb){ + var listClusters = helpers.addSource(cache, source, + ['kafka', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for MSK clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No MSK clusters found', region); + return rcb(); + } + + for (var cluster of listClusters.data) { + if (!cluster.ClusterArn) continue; + + var resource = cluster.ClusterArn; + + if (cluster.ClientAuthentication && + cluster.ClientAuthentication.Unauthenticated && + cluster.ClientAuthentication.Unauthenticated.Enabled) { + helpers.addResult(results, 2, + 'Cluster has unauthenticated access enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'Cluster does not have unauthenticated access enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/msk/mskClusterUnauthAccess.spec.js b/plugins/aws/msk/mskClusterUnauthAccess.spec.js new file mode 100644 index 000000000..bc51070f5 --- /dev/null +++ b/plugins/aws/msk/mskClusterUnauthAccess.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +const mskClusterUnauthAccess = require('./mskClusterUnauthAccess'); + +const listClusters = [ + { + "ClientAuthentication": { + "Sasl": { + "Scram": { + "Enabled": false + }, + "Iam": { + "Enabled": true + } + }, + "Tls": { + "CertificateAuthorityArnList": [], + "Enabled": false + }, + "Unauthenticated": { + "Enabled": false + } + }, + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/myCluster/794ab280-627c-4705-aaab-9f5b944fb9e3-25", + "ClusterName": "myCluster", + "CreationTime": "2022-04-06T14:16:16.579000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + }, + { + + "ClientAuthentication": { + "Sasl": { + "Scram": { + "Enabled": false + }, + "Iam": { + "Enabled": true + } + }, + "Tls": { + "CertificateAuthorityArnList": [], + "Enabled": false + }, + "Unauthenticated": { + "Enabled": true + } + }, + "ClusterArn": "arn:aws:kafka:us-east-1:000011112222:cluster/sadeedcluster/b08122a8-7104-476a-b6ee-c59444fb04d5-25", + "ClusterName": "sadeedcluster", + "CreationTime": "2022-04-06T14:19:41.573000+00:00", + "CurrentBrokerSoftwareInfo": { + "KafkaVersion": "2.6.2" + }, + } +]; + +const createCache = (clusters) => { + return { + kafka: { + listClusters: { + 'us-east-1': { + data: clusters, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + kafka: { + listClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('mskClusterUnauthAccess', function () { + describe('run', function () { + it('should FAIL if Unauthentication is enabled for clients, and all actions are allowed', function (done) { + const cache = createCache([listClusters[1]]); + mskClusterUnauthAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster has unauthenticated access enabled'); + done(); + }); + }); + + it('should PASS if Unauthentication is disabled for clients, and all actions are not allowed', function (done) { + const cache = createCache([listClusters[0]]); + mskClusterUnauthAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster does not have unauthenticated access enabled'); + done(); + }); + }); + + it('should PASS no MSK clusters found', function (done) { + const cache = createCache([]); + mskClusterUnauthAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No MSK clusters found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for MSK clusters', function (done) { + const cache = createErrorCache(); + mskClusterUnauthAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MSK clusters'); + done(); + }); + }); + + it('should not return any results if there was an error querying for MSK clusters', function (done) { + const cache = createNullCache(); + mskClusterUnauthAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/mwaa/environmentAdminPrivileges.js b/plugins/aws/mwaa/environmentAdminPrivileges.js new file mode 100644 index 000000000..36c20010e --- /dev/null +++ b/plugins/aws/mwaa/environmentAdminPrivileges.js @@ -0,0 +1,169 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Environment Admin Privileges', + category: 'MWAA', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures no Amazon MWAA environment available in your AWS account has admin privileges.', + more_info: 'Amazon MWAA environments should have most-restrictive IAM permissions for security best practices.', + link: 'https://docs.aws.amazon.com/mwaa/latest/userguide/manage-access.html', + recommended_action: 'Modify IAM role attached with MWAA environment to provide the minimal amount of access required to perform its tasks', + apis: ['MWAA:listEnvironments', 'MWAA:getEnvironment', 'IAM:listRoles', 'IAM:listAttachedRolePolicies', 'IAM:listRolePolicies', + 'IAM:listPolicies', 'IAM:getPolicy', 'IAM:getPolicyVersion', 'IAM:getRolePolicy', 'STS:getCallerIdentity'], + realtime_triggers: ['mwaa:CreateEnvironment','mwaa:UpdateEnviroment', 'mwaa:DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var managedAdminPolicy = `arn:${awsOrGov}:iam::aws:policy/AdministratorAccess`; + + async.each(regions.mwaa, function(region, rcb){ + var listEnvironments = helpers.addSource(cache, source, + ['mwaa', 'listEnvironments', region]); + + if (!listEnvironments) return rcb(); + + if (listEnvironments.err || !listEnvironments.data) { + helpers.addResult(results, 3, + `Unable to query for Airflow environments: ${helpers.addError(listEnvironments)}`, region); + return rcb(); + } + + if (!listEnvironments.data.length) { + helpers.addResult(results, 0, 'No Airflow environments found', region); + return rcb(); + } + + async.each(listEnvironments.data, function(airflowEnv, cb){ + var resource = `arn:${awsOrGov}:airflow:${region}:${accountId}:environment/${airflowEnv}`; + + var getEnvironment = helpers.addSource(cache, source, + ['mwaa', 'getEnvironment', region, airflowEnv]); + + if (!getEnvironment || getEnvironment.err || !getEnvironment.data || !getEnvironment.data.Environment) { + helpers.addResult(results, 3, + `Unable to get Airflow environment: ${helpers.addError(getEnvironment)}`, region, resource); + return cb(); + } + + if (!getEnvironment.data.Environment.ExecutionRoleArn) { + helpers.addResult(results, 0, + 'Airflow environment does not have a role attached', region, resource); + return cb(); + } + + var roleNameArr = getEnvironment.data.Environment.ExecutionRoleArn.split('/'); + var roleName = roleNameArr[roleNameArr.length - 1]; + var adminPrivileged; + + var listAttachedRolePolicies = helpers.addSource(cache, source, + ['iam', 'listAttachedRolePolicies', defaultRegion, roleName]); + var listRolePolicies = helpers.addSource(cache, source, + ['iam', 'listRolePolicies', defaultRegion, roleName]); + var getRolePolicy = helpers.addSource(cache, source, + ['iam', 'getRolePolicy', defaultRegion, roleName]); + + if (!listAttachedRolePolicies || + listAttachedRolePolicies.err || + !listAttachedRolePolicies.data || + !listAttachedRolePolicies.data.AttachedPolicies) { + helpers.addResult(results, 3, + `Unable to query for IAM attached policy for role "${roleName}": ${helpers.addError(listAttachedRolePolicies)}`, + region, resource); + return cb(); + } + + if (!listRolePolicies || listRolePolicies.err || !listRolePolicies.data || !listRolePolicies.data.PolicyNames) { + helpers.addResult(results, 3, + `Unable to query for IAM role policy for role "${roleName}": ${helpers.addError(listRolePolicies)}`, + region, resource); + return cb(); + } + + for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { + if (!policy.PolicyArn) continue; + + if (policy.PolicyArn === managedAdminPolicy) { + helpers.addResult(results, 2, + 'Airflow environment has admin privileges', region, resource); + return cb(); + } + + var getPolicy = helpers.addSource(cache, source, + ['iam', 'getPolicy', defaultRegion, policy.PolicyArn]); + + if (getPolicy && + getPolicy.data && + getPolicy.data.Policy && + getPolicy.data.Policy.DefaultVersionId) { + var getPolicyVersion = helpers.addSource(cache, source, + ['iam', 'getPolicyVersion', defaultRegion, policy.PolicyArn]); + + if (getPolicyVersion && + getPolicyVersion.data && + getPolicyVersion.data.PolicyVersion && + getPolicyVersion.data.PolicyVersion.Document) { + let statements = helpers.normalizePolicyDocument( + getPolicyVersion.data.PolicyVersion.Document); + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (adminPrivileged) break; + } + + for (var policyName of listRolePolicies.data.PolicyNames) { + if (getRolePolicy && + getRolePolicy[policyName] && + getRolePolicy[policyName].data && + getRolePolicy[policyName].data.PolicyDocument) { + let statements = getRolePolicy[policyName].data.PolicyDocument; + if (!statements) break; + + // Loop through statements to see if admin privileges + for (let statement of statements) { + if (statement.Effect && statement.Effect.toUpperCase() === 'ALLOW' && + statement.Action && statement.Action.indexOf('*') > -1 && + statement.Resource && statement.Resource.indexOf('*') > -1) { + adminPrivileged = true; + break; + } + } + } + } + + if (!adminPrivileged) { + helpers.addResult(results, 0, + 'Airflow environment does not have admin privileges', region, resource); + } else { + helpers.addResult(results, 2, + 'Airflow environment has admin privileges', region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mwaa/environmentAdminPrivileges.spec.js b/plugins/aws/mwaa/environmentAdminPrivileges.spec.js new file mode 100644 index 000000000..0bfcd2a46 --- /dev/null +++ b/plugins/aws/mwaa/environmentAdminPrivileges.spec.js @@ -0,0 +1,317 @@ +const expect = require('chai').expect; +var environmentAdminPrivileges = require('./environmentAdminPrivileges'); + +const listEnvironments = [ + "env-1" +]; + +const getEnvironment = [ + { + "Environment": { + "Arn": "arn:aws:airflow:us-east-1:000011112222:environment/env-1", + "ExecutionRoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonMWAA-role-1", + "Name": "env-1", + "NetworkConfiguration": { + "SecurityGroupIds": [ + "sg-06bb33bc2a9d6cfa0", + "sg-0356a73d9749f97ad" + ], + "SubnetIds": [ + "subnet-027b3e2dbd13be412", + "subnet-0ba3663b2ac3734d2" + ] + }, + "WebserverAccessMode": "PRIVATE_ONLY", + } + }, + { + "Environment": { + "Arn": "arn:aws:airflow:us-east-1:000011112222:environment/env-1", + "ExecutionRoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonMWAA-role-2", + "Name": "env-1", + "NetworkConfiguration": { + "SecurityGroupIds": [ + "sg-06bb33bc2a9d6cfa0", + "sg-0356a73d9749f97ad" + ], + "SubnetIds": [ + "subnet-027b3e2dbd13be412", + "subnet-0ba3663b2ac3734d2" + ] + }, + "WebserverAccessMode": "PRIVATE_ONLY", + } + } +]; + +const listRoles = [ + { + "Path": "/", + "RoleName": "AmazonMWAA-role-1", + "RoleId": "AROAYE32SRU55L7TD7HQ7", + "Arn": "arn:aws:iam::000011112222:role/AmazonMWAA-role-1", + "CreateDate": "2020-12-22T08:47:57Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "airflow.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600 + }, + { + "Path": "/", + "RoleName": "AmazonMWAA-role-2", + "RoleId": "AROAYE32SRU5UELB2F76P", + "Arn": "arn:aws:iam::000011112222:role/AmazonMWAA-role-2", + "CreateDate": "2020-12-25T09:09:48Z", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "airflow.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + } +]; + +const listRolePolicies = [ + { + "PolicyNames": [ + "EFS-Full" + ] + } +]; + +const listAttachedRolePolicies = [ + { + "ResponseMetadata": { + "RequestId": 'f7d427cc-970b-47af-9b7d-3e06121f83da' + }, + "AttachedPolicies": [ + { + "PolicyName": 'AdministratorAccess', + "PolicyArn": 'arn:aws:iam::aws:policy/AdministratorAccess' + } + ], + "IsTruncated": false + }, + { + "ResponseMetadata": { + "RequestId": 'b06a66ed-53af-4737-b0d3-7ef9031d2c2e' + }, + "AttachedPolicies": [ + { + "PolicyName": 'Allow_Admin_Role_Policy', + "PolicyArn": 'arn:aws:iam::000011112222:policy/Allow_Admin_Role_Policy' + } + ], + "IsTruncated": false + } +]; + +const getRolePolicy = [ + { + "RoleName": 'AmazonMWAA-role-2', + "PolicyName": 'EFS-Full', + "PolicyDocument": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor0%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22elasticfilesystem%3A%2A%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D' + } +]; + +const getPolicy = [ + { + "Policy": { + "PolicyName": 'Allow_Manager_Role_Policy', + "PolicyId": 'ANPAYE32SRU57UHNCIGCT', + "Arn": 'arn:aws:iam::000011112222:policy/Allow_Manager_Role_Policy', + "Path": '/', + "DefaultVersionId": 'v5', + "AttachmentCount": 2, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true + } + } +]; + +const getPolicyVersion = [ + { + "PolicyVersion": { + "Document": '%7B%0A%20%20%20%20%22Version%22%3A%20%222012-10-17%22%2C%0A%20%20%20%20%22Statement%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor1%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Allow%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPolicies%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListPoliciesGrantingServiceAccess%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListRoles%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListUsers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22iam%3AListGroups%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22%2A%22%0A%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Sid%22%3A%20%22VisualEditor2%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Effect%22%3A%20%22Deny%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Action%22%3A%20%22iam%3ACreateGroup%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%22Resource%22%3A%20%22arn%3Aaws%3Aiam%3A%3A000011112222%3Agroup%2F%2A%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%7D', + "VersionId": 'v5', + } + } +]; + + +const createCache = (listEnvironments, getEnvironment, listRoles, listAttachedRolePolicies, listRolePolicies, getRolePolicy, getPolicy, getPolicyVersion) => { + var envName = (listEnvironments && listEnvironments.length) ? listEnvironments[0] : null; + var roleName = (listRoles && listRoles.length) ? listRoles[0].RoleName : null; + var policyArn = (listAttachedRolePolicies && listAttachedRolePolicies.AttachedPolicies) ? listAttachedRolePolicies.AttachedPolicies.PolicyArn : null; + var policyName = (listRolePolicies) ? listRolePolicies.PolicyNames[0] : null; + return { + mwaa: { + listEnvironments: { + 'us-east-1': { + data: listEnvironments + } + }, + getEnvironment: { + 'us-east-1': { + [envName]: { + data: getEnvironment + } + } + } + }, + iam: { + listRoles: { + 'us-east-1': { + data: listRoles + } + }, + listAttachedRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listAttachedRolePolicies + } + } + }, + listRolePolicies: { + 'us-east-1': { + [roleName]: { + data: listRolePolicies + } + } + }, + getPolicy: { + 'us-east-1': { + [policyArn]: { + data: getPolicy + } + } + }, + getRolePolicy: { + 'us-east-1': { + [policyName]: { + data: getRolePolicy + } + } + }, + getPolicyVersion: { + 'us-east-1': { + [policyArn]: { + data: getPolicyVersion + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + mwaa: { + listEnvironments: { + 'us-east-1': { + err: { + message: 'error listing Airflow environments' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mwaa: { + listEnvironments: { + 'us-east-1': null + } + } + }; +}; + +describe('environmentAdminPrivileges', function () { + describe('run', function () { + + it('should PASS if environment does not have admin priveleges', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0], [listRoles[0]], listAttachedRolePolicies[1], listRolePolicies[0], getRolePolicy[0]); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if environment has admin priveleges', function (done) { + const cache = createCache(listEnvironments, getEnvironment[1], [listRoles[1]], listAttachedRolePolicies[0], listRolePolicies[0], {}, getPolicy[0], getPolicyVersion[0]); + environmentAdminPrivileges.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Airflow environments found', function (done) { + const cache = createCache([]); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Airflow environments', function (done) { + const cache = createErrorCache(); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list attached role policies', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0], [listRoles[1]], null); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list role policies', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0], [listRoles[1]], listAttachedRolePolicies[0], null); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list Airflow environments response not found', function (done) { + const cache = createNullCache(); + environmentAdminPrivileges.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/mwaa/environmentDataEncrypted.js b/plugins/aws/mwaa/environmentDataEncrypted.js new file mode 100644 index 000000000..e1ddaadf6 --- /dev/null +++ b/plugins/aws/mwaa/environmentDataEncrypted.js @@ -0,0 +1,120 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Environment Data Encrypted', + category: 'MWAA', + domain: 'Compute', + severity: 'High', + description: 'Ensure that AWS MWAA environment data is encrypted', + more_info: 'Amazon MWAA encrypts data saved to persistent media with AWS-manager keys by default. ' + + 'Use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create MWAA environments with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/mwaa/latest/userguide/encryption-at-rest.html', + apis: ['MWAA:listEnvironments','MWAA:getEnvironment', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + mwaa_environmentdata_desired_encryption_level: { + name: 'MWAA Environment Data Deisred Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['mwaa:CreateEnvironment', 'mwaa:DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.mwaa_environmentdata_desired_encryption_level || this.settings.mwaa_environmentdata_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.mwaa, function(region, rcb){ + var listEnvironments = helpers.addSource(cache, source, + ['mwaa', 'listEnvironments', region]); + + if (!listEnvironments) return rcb(); + + if (listEnvironments.err || !listEnvironments.data) { + helpers.addResult(results, 3, + 'Unable to query MWAA Environments: ' + helpers.addError(listEnvironments), region); + return rcb(); + } + + if (!listEnvironments.data.length) { + helpers.addResult(results, 0, 'No MWAA Environments found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let environment of listEnvironments.data) { + var resource = `arn:${awsOrGov}:airflow:${region}:${accountId}:environment/${environment}`; + + var getEnvironment = helpers.addSource(cache, source, + ['mwaa', 'getEnvironment', region, environment]); + + + if (!getEnvironment || getEnvironment.err || !getEnvironment.data || !getEnvironment.data.Environment) { + helpers.addResult(results, 3, + `Unable to get MWAA environment: ${helpers.addError(getEnvironment)}`, + region, resource); + continue; + } + + if (getEnvironment.data.Environment && getEnvironment.data.Environment.KmsKey) { + var KmsKey = getEnvironment.data.Environment.KmsKey; + var keyId = KmsKey.split('/')[1] ? KmsKey.split('/')[1] : KmsKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, KmsKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `MWAA Environment data is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `MWAA Environment data is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mwaa/environmentDataEncrypted.spec.js b/plugins/aws/mwaa/environmentDataEncrypted.spec.js new file mode 100644 index 000000000..b9becf617 --- /dev/null +++ b/plugins/aws/mwaa/environmentDataEncrypted.spec.js @@ -0,0 +1,193 @@ +var expect = require('chai').expect; +var environmentDataEncrypted = require('./environmentDataEncrypted'); + +const listEnvironments = [ + "MyAirflowEnvironment", +]; + +const getEnvironment = [ + { + "Environment": { + "AirflowConfigurationOptions": {}, + "AirflowVersion": "2.0.2", + "Arn": "arn:aws:airflow:us-east-1:000111222333:environment/MyAirflowEnvironment", + "CreatedAt": "2021-11-16T17:29:30+05:00", + "DagS3Path": "data", + "EnvironmentClass": "mw1.small", + "ExecutionRoleArn": "arn:aws:iam::000111222333:role/service-role/AmazonMWAA-MyAirflowEnvironment-CKkXBd", + "KmsKey": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "LastUpdate": { + "CreatedAt": "2021-11-16T17:29:30+05:00", + "Error": { + "ErrorCode": "INCORRECT_CONFIGURATION", + "ErrorMessage": "You may need to check the execution role permissions policy for your environment, and that each of the VPC networking components required by the environment are configured to allow traffic. Troubleshooting: https://docs.aws.amazon.com/mwaa/latest/userguide/troubleshooting.html" + }, + "Status": "FAILED" + }, + "LoggingConfiguration": { + "DagProcessingLogs": { + "Enabled": false, + "LogLevel": "WARNING" + }, + "SchedulerLogs": { + "Enabled": false, + "LogLevel": "WARNING" + }, + "TaskLogs": { + "Enabled": true, + "LogLevel": "INFO" + }, + "WebserverLogs": { + "Enabled": false, + "LogLevel": "WARNING" + }, + "WorkerLogs": { + "Enabled": false, + "LogLevel": "WARNING" + } + }, + "MaxWorkers": 10, + "MinWorkers": 1, + "Name": "MyAirflowEnvironment", + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (environments, keys, getEnvironment, describeKey, environmentsErr, keysErr, describeKeyErr, getEnvironmentErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var environment = (environments && environments.length) ? environments[0]: null; + return { + mwaa: { + listEnvironments: { + 'us-east-1': { + err: environmentsErr, + data: environments + }, + }, + getEnvironment: { + 'us-east-1': { + [environment]: { + data: getEnvironment, + err: getEnvironmentErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('environmentDataEncrypted', function () { + describe('run', function () { + it('should PASS if MWAA Environment Data is encrypted with desired encryption level', function (done) { + const cache = createCache(listEnvironments, listKeys, getEnvironment[0], describeKey[0]); + environmentDataEncrypted.run(cache, { mwaa_environmentdata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if MWAA Environment Data is not encrypted with desired encryption level', function (done) { + const cache = createCache(listEnvironments,listKeys, getEnvironment[0], describeKey[1]); + environmentDataEncrypted.run(cache, { mwaa_environmentdata_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no MWAA Environment found', function (done) { + const cache = createCache([]); + environmentDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list MWAA Environment', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list MWAA Environment" }); + environmentDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listEnvironments, null, null, null, { message: "Unable to list KMS keys" }); + environmentDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/mwaa/webServerPublicAccess.js b/plugins/aws/mwaa/webServerPublicAccess.js new file mode 100644 index 000000000..15ff1b8b0 --- /dev/null +++ b/plugins/aws/mwaa/webServerPublicAccess.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web Server Public Access', + category: 'MWAA', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures web access to the Apache Airflow UI in your MWAA environment is not public.', + more_info: 'To restrict access to the Apache Airflow UI, environment should be configured to be accessible only from within the VPC selected.', + link: 'https://docs.aws.amazon.com/mwaa/latest/userguide/vpc-create.html', + recommended_action: 'Modify Amazon MWAA environments to set web server access mode to be private only', + apis: ['MWAA:listEnvironments', 'MWAA:getEnvironment', 'STS:getCallerIdentity'], + realtime_triggers: ['mwaa:CreateEnvironment','mwaa:UpdateEnviroment', 'mwaa:DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + async.each(regions.mwaa, function(region, rcb){ + var listEnvironments = helpers.addSource(cache, source, + ['mwaa', 'listEnvironments', region]); + + if (!listEnvironments) return rcb(); + + if (listEnvironments.err || !listEnvironments.data) { + helpers.addResult(results, 3, + `Unable to query for Airflow environments: ${helpers.addError(listEnvironments)}`, region); + return rcb(); + } + + if (!listEnvironments.data.length) { + helpers.addResult(results, 0, 'No Airflow environments found', region); + return rcb(); + } + + async.each(listEnvironments.data, function(airflowEnv, cb){ + var resource = `arn:${awsOrGov}:airflow:${region}:${accountId}:environment/${airflowEnv}`; + + var getEnvironment = helpers.addSource(cache, source, + ['mwaa', 'getEnvironment', region, airflowEnv]); + + if (!getEnvironment || getEnvironment.err || !getEnvironment.data || !getEnvironment.data.Environment) { + helpers.addResult(results, 3, + `Unable to get Airflow environment: ${helpers.addError(getEnvironment)}`, region, resource); + return cb(); + } + + if (getEnvironment.data.Environment.WebserverAccessMode && + getEnvironment.data.Environment.WebserverAccessMode.toUpperCase() === 'PRIVATE_ONLY') { + helpers.addResult(results, 0, + 'Apache Airflow UI can only be accessible from within the VPC', + region, resource); + } else { + helpers.addResult(results, 2, + 'Apache Airflow UI can be accessed over the internet', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/mwaa/webServerPublicAccess.spec.js b/plugins/aws/mwaa/webServerPublicAccess.spec.js new file mode 100644 index 000000000..20ecba679 --- /dev/null +++ b/plugins/aws/mwaa/webServerPublicAccess.spec.js @@ -0,0 +1,140 @@ +const expect = require('chai').expect; +var webServerPublicAccess = require('./webServerPublicAccess'); + +const listEnvironments = [ + "env-1" +]; + +const getEnvironment = [ + { + "Environment": { + "Arn": "arn:aws:airflow:us-east-1:000111222333:environment/env-1", + "ExecutionRoleArn": "arn:aws:iam::000111222333:role/service-role/AmazonMWAA-role-1", + "Name": "env-1", + "NetworkConfiguration": { + "SecurityGroupIds": [ + "sg-06bb33bc2a9d6cfa0", + "sg-0356a73d9749f97ad" + ], + "SubnetIds": [ + "subnet-027b3e2dbd13be412", + "subnet-0ba3663b2ac3734d2" + ] + }, + "WebserverAccessMode": "PRIVATE_ONLY", + } + }, + { + "Environment": { + "Arn": "arn:aws:airflow:us-east-1:000111222333:environment/env-1", + "ExecutionRoleArn": "arn:aws:iam::000111222333:role/service-role/AmazonMWAA-role-2", + "Name": "env-1", + "NetworkConfiguration": { + "SecurityGroupIds": [ + "sg-06bb33bc2a9d6cfa0", + "sg-0356a73d9749f97ad" + ], + "SubnetIds": [ + "subnet-027b3e2dbd13be412", + "subnet-0ba3663b2ac3734d2" + ] + }, + "WebserverAccessMode": "PUBLIC_ONLY", + } + } +]; + +const createCache = (listEnvironments, getEnvironment, listErr, getErr) => { + var envName = (listEnvironments && listEnvironments.length) ? listEnvironments[0] : null; + return { + mwaa: { + listEnvironments: { + 'us-east-1': { + err: listErr, + data: listEnvironments + } + }, + getEnvironment: { + 'us-east-1': { + [envName]: { + err: getErr, + data: getEnvironment + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + mwaa: { + listEnvironments: { + 'us-east-1': null + } + } + }; +}; + +describe('webServerPublicAccess', function () { + describe('run', function () { + + it('should PASS if Apache Airflow UI can only be accessible from within the VPC', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0]); + webServerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Apache Airflow UI can be accessed over the internet', function (done) { + const cache = createCache(listEnvironments, getEnvironment[1]); + webServerPublicAccess.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Airflow environments found', function (done) { + const cache = createCache([]); + webServerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Airflow environments', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0], { message: 'error listing Airflow environments'}); + webServerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to get Ariflow environment', function (done) { + const cache = createCache(listEnvironments, getEnvironment[0], null, { message: 'error getting Airflow environment'}); + webServerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list Airflow environments response not found', function (done) { + const cache = createNullCache(); + webServerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/neptune/neptuneDBIamAuth.js b/plugins/aws/neptune/neptuneDBIamAuth.js new file mode 100644 index 000000000..5187ad106 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBIamAuth.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database IAM Authentication Enabled', + category: 'Neptune', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that AWS Neptune database instance has IAM database authentication feature enabled.', + more_info: 'Enabling IAM authentication for AWS Neptune adds an extra layer of security by allowing access control through IAM credentials. It ensures that network traffic for clusters is encrypted using SSL and allows centralized management. All authentication requests are automatically signed with a secure access key instead of using a password.', + recommended_action: 'Modify Neptune database instance and enable IAM database authentication.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/iam-auth.html', + apis: ['Neptune:describeDBClusters'], + realtime_triggers: ['neptune:CreateDBCluster', 'neptune:DeleteDBCluster','neptune:ModifyDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.neptune, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['neptune', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database instances: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; + + if (cluster.IAMDatabaseAuthenticationEnabled) { + helpers.addResult(results, 0, 'Neptune database instance has IAM authentication enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'Neptune database instance does not have IAM authentication enabled', region, cluster.DBClusterArn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/neptune/neptuneDBIamAuth.spec.js b/plugins/aws/neptune/neptuneDBIamAuth.spec.js new file mode 100644 index 000000000..5a9752635 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBIamAuth.spec.js @@ -0,0 +1,90 @@ +var expect = require('chai').expect; +var neptuneDBIamAuth = require('./neptuneDBIamAuth'); + +const describeDBClusters = [ + { + "AllocatedStorage": 1, + "Engine": "neptune", + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + "IAMDatabaseAuthenticationEnabled": true + }, + { + "AllocatedStorage": 1, + "Engine": "neptune", + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", + "AssociatedRoles": [], + "IAMDatabaseAuthenticationEnabled": false + } +]; + + + +const createCache = (clusters, clustersErr) => { + return { + neptune: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + }; +}; + + + +describe('neptuneDBIamAuth', function () { + describe('run', function () { + it('should PASS if Neptune database instance has IAM authentication enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + neptuneDBIamAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance has IAM authentication enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should FAIL if Neptune database instance does not have IAM authentication enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + neptuneDBIamAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance does not have IAM authentication enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneDBIamAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune Database instances', function (done) { + const cache = createCache(null, { message: "Unable to list Neptune database instances" }); + neptuneDBIamAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list Neptune database instances:'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/neptune/neptuneDBInstanceEncrypted.js b/plugins/aws/neptune/neptuneDBInstanceEncrypted.js new file mode 100644 index 000000000..5ae37b307 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBInstanceEncrypted.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database Instance Encrypted', + category: 'Neptune', + domain: 'Databases', + severity: 'High', + description: 'Ensure that your AWS Neptune database instances are encrypted with KMS Customer Master Keys (CMKs) instead of AWS managed-keys.', + more_info: 'Neptune encrypted instances provide an additional layer of data protection by helping to secure your data from unauthorized access to the underlying storage. ' + + 'You can use Neptune encryption to increase data protection of your applications that are deployed in the cloud. ' + + 'You can also use it to fulfill compliance requirements for data-at-rest encryption.', + recommended_action: 'Encrypt Neptune database with desired encryption level', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/encrypt.html', + apis: ['Neptune:describeDBClusters', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + neptune_db_desired_encryption_level: { + name: 'Neptune Database Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awscmk', + } + }, + realtime_triggers: ['neptune:CreateDBCluster', 'neptune:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.neptune_db_desired_encryption_level || this.settings.neptune_db_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.neptune, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['neptune', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database instances: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; + + let resource = cluster.DBClusterArn; + + if (cluster.KmsKeyId) { + var kmsKeyId = cluster.KmsKeyId.split('/')[1] ? cluster.KmsKeyId.split('/')[1] : cluster.KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, cluster.KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Neptune database instance is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Neptune database instance is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Neptune database instance does not have encryption enabled', + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/neptune/neptuneDBInstanceEncrypted.spec.js b/plugins/aws/neptune/neptuneDBInstanceEncrypted.spec.js new file mode 100644 index 000000000..3a51b4fb7 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBInstanceEncrypted.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +var neptuneDBInstanceEncrypted = require('./neptuneDBInstanceEncrypted'); + +const describeDBClusters = [ + { + "AllocatedStorage": 1, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1f", + "us-east-1c" + ], + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "MultiAZ": false, + "Engine": "neptune", + "EngineVersion": "1.0.5.1", + "LatestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Port": 8182, + "MasterUsername": "admin", + "PreferredBackupWindow": "03:20-03:50", + "PreferredMaintenanceWindow": "fri:09:21-fri:09:51", + "ReadReplicaIdentifiers": [], + "DBClusterMembers": [ + { + "DBInstanceIdentifier": "database-2-instance-1", + "IsClusterWriter": true, + "DBClusterParameterGroupStatus": "in-sync", + "PromotionTier": 1 + } + ], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "HostedZoneId": "ZUFXD4SLT2LS7", + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (clusters, keys, describeKey, clustersErr, keysErr, describeKeyErr) => { + var keyId = (clusters && clusters.length && clusters[0].KmsKeyId) ? clusters[0].KmsKeyId.split('/')[1] : null; + return { + neptune: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + + +describe('neptuneDBInstanceEncrypted', function () { + describe('run', function () { + it('should PASS if Neptune database instance is encrypted with desired encryption level', function (done) { + const cache = createCache(describeDBClusters, listKeys, describeKey[0]); + neptuneDBInstanceEncrypted.run(cache, { neptunedb_cluster_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should FAIL if Neptune database instance is not encrypted with desired encyption level', function (done) { + const cache = createCache(describeDBClusters, listKeys, describeKey[1]); + neptuneDBInstanceEncrypted.run(cache, { neptunedb_cluster_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance is encrypted with awskms'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneDBInstanceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune Database instances', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Neptune database instances" }); + neptuneDBInstanceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + neptuneDBInstanceEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.js b/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.js new file mode 100644 index 000000000..bd6c0d9ed --- /dev/null +++ b/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database Minor Version Upgrade', + category: 'Neptune', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures Auto Minor version upgrade is enabled on Neptune database instances.', + more_info: 'AWS Neptune database service releases engine version upgrades regularly to introduce software features, bug fixes, security patches and performance improvements. Enabling auto minor version upgrade feature ensures that minor engine upgrades are applied automatically to the instance during the maintenance window.', + recommended_action: 'Modify Neptune database instance and enable automatic minor version upgrades feature.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/cluster-maintenance.html', + apis: ['Neptune:describeDBInstances'], + realtime_triggers: ['neptune:CreateDBInstance', 'neptune:DeleteDBInstance', 'neptune:ModifyDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.neptune, function(region, rcb){ + var describeDBInstances = helpers.addSource(cache, source, + ['neptune', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database cluster instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + var noInstance = true; + + for (let instance of describeDBInstances.data) { + if (!instance.DBInstanceArn || instance.Engine !== 'neptune') continue; + + noInstance = false; + + if (instance.AutoMinorVersionUpgrade) { + helpers.addResult(results, 0, 'Neptune database instance has auto minor version upgrade enabled', region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 2, 'Neptune database instance does not have auto minor version upgrade enabled', region, instance.DBInstanceArn); + } + } + + if (noInstance) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.spec.js b/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.spec.js new file mode 100644 index 000000000..f6fc1f7ae --- /dev/null +++ b/plugins/aws/neptune/neptuneDBMinorVersionUpgrade.spec.js @@ -0,0 +1,80 @@ +var expect = require('chai').expect; +var neptuneDBMinorVersionUpgrade = require('./neptuneDBMinorVersionUpgrade'); + +const describeDBInstances = [ + { + DBInstanceIdentifier: "db-neptune-1-instance-1", + Engine: "neptune", + AutoMinorVersionUpgrade: true, + DBClusterIdentifier: "db-neptune-1", + DBInstanceArn: "arn:aws:rds:us-east-1:12341234123:db:db-neptune-1-instance-1" + }, + { + DBInstanceIdentifier: "db-neptune-1-instance-1", + Engine: "neptune", + AutoMinorVersionUpgrade: false, + DBClusterIdentifier: "db-neptune-1", + DBInstanceArn: "arn:aws:rds:us-east-1:12341234123:db:db-neptune-1-instance-1" + } +]; + +const createCache = (clusters, clustersErr) => { + return { + neptune: { + describeDBInstances: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + }; +}; + +describe('neptuneDBMinorVersionUpgrade', function () { + describe('run', function () { + it('should PASS if Neptune database instance has auto minor version upgrade enabled', function (done) { + const cache = createCache([describeDBInstances[0]]); + neptuneDBMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance has auto minor version upgrade enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should FAIL if Neptune database instance does not have auto minor version upgrade enabled', function (done) { + const cache = createCache([describeDBInstances[1]]); + neptuneDBMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance does not have auto minor version upgrade enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneDBMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune Database instances', function (done) { + const cache = createCache(null, { message: "Unable to list Neptune database cluster instances" }); + neptuneDBMinorVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/neptune/neptuneDBMultiAz.js b/plugins/aws/neptune/neptuneDBMultiAz.js new file mode 100644 index 000000000..01c49ea40 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBMultiAz.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database Multiple AZ', + category: 'Neptune', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that AWS Neptune database instances are created to be cross-AZ for high availability.', + more_info: 'Enabling Multi-AZ feature for Neptune instances boosts database reliability by automatically replicating data across multiple availability zones. This ensures continuous availability and minimal downtime for graph database deployments.', + recommended_action: 'Create new Neptune database instance and enable multi-AZ feature.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/feature-overview-db-clusters.html', + apis: ['Neptune:describeDBClusters'], + realtime_triggers: ['neptune:CreateDBCluster', 'neptune:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + + async.each(regions.neptune, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['neptune', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database instances: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; + + let resource = cluster.DBClusterArn; + + if (cluster.MultiAZ) { + helpers.addResult(results, 0, 'Neptune database instance has multi-AZ enabled', region, resource); + } else { + helpers.addResult(results, 2, 'Neptune database instance does not have multi-AZ enabled', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/neptune/neptuneDBMultiAz.spec.js b/plugins/aws/neptune/neptuneDBMultiAz.spec.js new file mode 100644 index 000000000..0c7c827a9 --- /dev/null +++ b/plugins/aws/neptune/neptuneDBMultiAz.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var neptuneDbMultiAz = require('./neptuneDBMultiAz'); + +const describeDBClusters = [ + { + "AllocatedStorage": 1, + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + "Engine": "neptune", + "MultiAZ": true + }, + { + "AllocatedStorage": 1, + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", + "AssociatedRoles": [], + "Engine": "neptune", + "MultiAZ": false + } +]; + + + +const createCache = (clusters, clustersErr) => { + return { + neptune: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + }; +}; + + + +describe('neptuneDbMultiAz', function () { + describe('run', function () { + it('should PASS if Neptune database instance has Multi AZ enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + neptuneDbMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance has multi-AZ enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should FAIL if Neptune database instance does not have Multi AZ enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + neptuneDbMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance does not have multi-AZ enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneDbMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune Database instances', function (done) { + const cache = createCache(null, { message: "Unable to list Neptune database instances" }); + neptuneDbMultiAz.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/neptune/neptuneDbDeletionProtection.js b/plugins/aws/neptune/neptuneDbDeletionProtection.js new file mode 100644 index 000000000..0f333a043 --- /dev/null +++ b/plugins/aws/neptune/neptuneDbDeletionProtection.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database Deletion Protection Enabled', + category: 'Neptune', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that AWS Neptune database instances have deletion protection feature enabled.', + more_info: 'Enabling deletion protection feature for Amazon Neptune adds an extra layer of security, preventing accidental database deletions or deletion by an unauthorized user. A Neptune DB cluster can\'t be deleted while deletion protection is enabled which ensures continuous availability of data.', + recommended_action: 'Modify Neptune database instance and enable deletion protection.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/manage-console-instances-delete.html', + apis: ['Neptune:describeDBClusters'], + realtime_triggers: ['neptune:CreateDBCluster', 'neptune:DeleteDBCluster', 'neptune:ModifyDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.neptune, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['neptune', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database instances: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn || cluster.Engine != 'neptune') continue; + if (cluster.DeletionProtection) { + helpers.addResult(results, 0, 'Neptune database instance has deletion protection enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'Neptune database instance has deletion protection disabled', region, cluster.DBClusterArn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js b/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js new file mode 100644 index 000000000..082b291ec --- /dev/null +++ b/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var neptuneDbDeletionProtection = require('./neptuneDbDeletionProtection'); + +const describeDBClusters = [ + { + "AllocatedStorage": 1, + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + "Engine": "neptune", + "DeletionProtection": true + }, + { + "AllocatedStorage": 1, + "BackupRetentionPeriod": 1, + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", + "AssociatedRoles": [], + "Engine": "neptune", + "DeletionProtection": false + } +]; + + + +const createCache = (clusters, clustersErr) => { + return { + neptune: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + }, + }; +}; + + + +describe('neptuneDbDeletionProtection', function () { + describe('run', function () { + it('should PASS if Neptune database instance has deletion protection enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + neptuneDbDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance has deletion protection enabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should FAIL if Neptune database instance does not have deletion protection enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + neptuneDbDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance has deletion protection disabled'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneDbDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune Database instances', function (done) { + const cache = createCache(null, { message: "Unable to list Neptune database instances" }); + neptuneDbDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/neptune/neptuneInstanceBackupRetention.js b/plugins/aws/neptune/neptuneInstanceBackupRetention.js new file mode 100644 index 000000000..c491a872e --- /dev/null +++ b/plugins/aws/neptune/neptuneInstanceBackupRetention.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Neptune Database Instance Backup Retention', + category: 'Neptune', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Neptune database instances have set a minimum backup retention period.', + more_info: 'Neptune provides feature to retain incremental backups, which allows to quickly restore to any point within the backup retention period. Ensure that you have sufficient backup retention period configured in order to restore your data in the event of failure.', + recommended_action: 'Modify Neptune database instance to configure sufficient backup retention period.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/backup-restore-overview.html', + apis: ['Neptune:describeDBClusters'], + settings: { + neptune_db_backup_retention_threshold: { + name: 'Neptune Instance Minimum Backup Retention Period', + description: 'Desired number of days for Neptune database instance backup retention period.', + regex: '^[1-35]*$', + default: 7 + } + }, + realtime_triggers: ['neptune:CreateDBCluster','neptune:ModifyDBCluster','neptune:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var neptune_db_backup_retention_threshold = parseInt(settings.neptune_db_backup_retention_threshold || this.settings.neptune_db_backup_retention_threshold.default); + + async.each(regions.neptune, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['neptune', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list Neptune database instances: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No Neptune database instances found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; + + let resource = cluster.DBClusterArn; + + if (cluster.BackupRetentionPeriod && cluster.BackupRetentionPeriod >= neptune_db_backup_retention_threshold) { + helpers.addResult(results, 0, + `Neptune database instance has a backup retention period of ${cluster.BackupRetentionPeriod} which is greater than or equal to ${neptune_db_backup_retention_threshold} days limit`, + region, resource); + } else { + helpers.addResult(results, 2, + `Neptune database instance has a backup retention period of ${cluster.BackupRetentionPeriod} which is less than ${neptune_db_backup_retention_threshold} days limit`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/neptune/neptuneInstanceBackupRetention.spec.js b/plugins/aws/neptune/neptuneInstanceBackupRetention.spec.js new file mode 100644 index 000000000..b9c85cabb --- /dev/null +++ b/plugins/aws/neptune/neptuneInstanceBackupRetention.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +var neptuneInstanceBackupRetention = require('./neptuneInstanceBackupRetention'); + +const describeDBClusters = [ + { + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "Engine": "neptune", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + }, + { + "BackupRetentionPeriod": 10, + "DBClusterIdentifier": "database-3", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-3.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-3.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "Engine": "neptune", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-3", + "AssociatedRoles": [], + } +]; +const createCache = (clusters, clustersErr) => { + return { + neptune: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('neptuneInstanceBackupRetention', function () { + describe('run', function () { + it('should PASS if Neptune database instance Cluster has the recommended backup retention period', function (done) { + const cache = createCache([describeDBClusters[1]]); + neptuneInstanceBackupRetention.run(cache, { doc_db_backup_retention_threshold: 7 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Neptune database instance has a backup retention period of 10 which is greater than or equal to 7 days limit'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Neptune database instance do not have the recommended backup retention period', function (done) { + const cache = createCache([describeDBClusters[0]]); + neptuneInstanceBackupRetention.run(cache, { doc_db_backup_retention_threshold: 7 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Neptune database instance has a backup retention period of 1 which is less than 7 days limit'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Neptune database instances found', function (done) { + const cache = createCache([]); + neptuneInstanceBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Neptune database instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Neptune database instance', function (done) { + const cache = createCache(null, { message: "Unable to list Neptune database instance encryption" }); + neptuneInstanceBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.js b/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.js new file mode 100644 index 000000000..94287c22c --- /dev/null +++ b/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.js @@ -0,0 +1,134 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Collection CMK Encryption', + category: 'OpenSearch', + domain: 'Serverless', + severity: 'High', + description: 'Ensures that OpenSearch Serverless collections are encrypted with KMS Customer Master Keys (CMKs).', + more_info: 'OpenSearch Serverless should use KMS Customer Master Keys (CMKs) instead of AWS managed keys for encryption in order to have full control over data encryption and decryption.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-encryption.html', + recommended_action: 'Update the encryption policy and customer managed key for encryption.', + apis: ['OpenSearchServerless:listEncryptionSecurityPolicies', 'OpenSearchServerless:listCollections', + 'OpenSearchServerless:getEncryptionSecurityPolicy','KMS:describeKey', 'KMS:listKeys'], + settings: { + opensearch_collection_desired_encryption_level: { + name: 'OpenSearch Collection Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['opensearchserverless:CreateCollection', 'opensearchserverless:DeleteCollection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.opensearch_collection_desired_encryption_level || this.settings.opensearch_collection_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + + async.each(regions.opensearchserverless, function(region, rcb){ + var listCollections = helpers.addSource(cache, source, + ['opensearchserverless', 'listCollections', region]); + + if (!listCollections) return rcb(); + + if ( !listCollections.data || listCollections.err) { + helpers.addResult(results, 3, + 'Unable to list OpenSearch collections: ' + helpers.addError(listCollections), region); + return rcb(); + } + + if (!listCollections.data.length){ + helpers.addResult(results, 0, 'No OpenSearch collections found', region); + return rcb(); + } + + var listSecurityPolicies = helpers.addSource(cache, source, + ['opensearchserverless', 'listEncryptionSecurityPolicies', region]); + + if (!listSecurityPolicies && listSecurityPolicies.err || !listSecurityPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query list OpenSearch security policies: ' + helpers.addError(listSecurityPolicies), region); + return rcb(); + } + + if (!listSecurityPolicies.data.length) { + helpers.addResult(results, 2, 'No OpenSearch security policies found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + let policyMap = {}; + for (let policy of listSecurityPolicies.data){ + var getSecurityPolicy = helpers.addSource(cache, source, + ['opensearchserverless', 'getEncryptionSecurityPolicy', region, policy.name]); + + let securityPolicy; + if (getSecurityPolicy && getSecurityPolicy.data && getSecurityPolicy.data.securityPolicyDetail && getSecurityPolicy.data.securityPolicyDetail.policy){ + securityPolicy = getSecurityPolicy.data.securityPolicyDetail.policy; + + for (let collection of listCollections.data){ + let found = securityPolicy.Rules.find(rule => rule.Resource.indexOf(`collection/${collection.name}`) > -1 && + rule.ResourceType == 'collection'); + + if (!found) continue; + + if (securityPolicy.AWSOwnedKey){ + currentEncryptionLevel = 2; //awskms + } else { + if (securityPolicy.KmsARN) { + var kmsKeyId = securityPolicy.KmsARN.split('/')[1] ? securityPolicy.KmsARN.split('/')[1] : securityPolicy.KmsARN; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, securityPolicy.KmsARN); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + if (found && !policyMap[collection.arn]){ + + policyMap[collection.arn] = currentEncryptionLevel; + break; + } + } + } + } + } + + for (let col of listCollections.data){ + if (policyMap[col.arn] >= desiredEncryptionLevel){ + helpers.addResult(results, 0, `OpenSearch collection is encrypted with ${helpers.ENCRYPTION_LEVELS[policyMap[col.arn]]} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, region, col.arn); + } else { + helpers.addResult(results, 2, `OpenSearch collection is encrypted with ${helpers.ENCRYPTION_LEVELS[2]} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, region, col.arn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.spec.js b/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.spec.js new file mode 100644 index 000000000..b14f65400 --- /dev/null +++ b/plugins/aws/openSearchServerless/opensearchCollectionCmkEncrypted.spec.js @@ -0,0 +1,210 @@ +const expect = require('chai').expect; +var collectionCmkEncrypted = require('./opensearchCollectionCmkEncrypted'); + +const listCollections = [ + { + "arn": 'arn:aws:aoss:us-east-1:000011112222:collection:testing-123', + "id": "123xyz", + "name": "auto-test", + "status": "ACTIVE" + }, +]; +const listSecurityPolicies =[ + { + "name": "auto-test", + "type": "encryption", + } +] +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const getSecurityPolicy = [ + + { + securityPolicyDetail: { + createdDate: 1677926608534, + description: 'testPolicy', + lastModifiedDate: 1677926608534, + name: 'auto-test', + policy: { Rules: [{ Resource:["collection/test"], ResourceType:'collection' }], AWSOwnedKey: true }, + policyVersion: 'MTY3NzkyNjYwODUzNF8x', + type: 'encryption' + } + }, + { + securityPolicyDetail: { + createdDate: 1677926608534, + description: 'testPolicy', + lastModifiedDate: 1677926608534, + name: 'auto-test', + policy: { Rules: [{ Resource:["collection/auto-test"], ResourceType:'collection' }], KmsARN: 'arn:aws:kms:us-east-1:000011112222:key/2cff2321-73c6-4bac-95eb-bc9633d3e8a9'}, + policyVersion: 'MTY3NzkyNjYwODUzNF8x', + type: 'network' + } + } +] + +const createCache = (listCollections, listSecurityPolicies, getSecurityPolicy, keys, describeKey) => { + var keyId = (getSecurityPolicy && getSecurityPolicy.securityPolicyDetail.policy.KmsARN) ? + getSecurityPolicy.securityPolicyDetail.policy.KmsARN.split('/')[1] : null; + return { + opensearchserverless: { + listCollections: { + 'us-east-1': { + data: listCollections + } + }, + listEncryptionSecurityPolicies: { + 'us-east-1': { + data: listSecurityPolicies + } + }, + getEncryptionSecurityPolicy: { + 'us-east-1': { + "auto-test":{ + data: getSecurityPolicy + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('collectionCmkEncrypted', function () { + describe('run', function () { + + it('should give Unknown result if unable to list collections', function (done) { + const cache = createCache(null); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list OpenSearch collections'); + done(); + }); + }); + + it('should Pass if no collection found', function (done) { + const cache = createCache([]); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OpenSearch collections found'); + done(); + }); + }); + + it('should give UNKNOWN result if unable to list security Policies', function (done) { + const cache = createCache(listCollections, null); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query list OpenSearch security policies:'); + done(); + }); + }); + + it('should PASS if no security policy found', function (done) { + const cache = createCache(listCollections, []); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No OpenSearch security policies found'); + done(); + }); + }); + it('should PASS if collection is publicly accessible', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[1], listKeys, describeKey[0]); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenSearch collection is encrypted with awscmk'); + done(); + }); + }); + it('should FAIL if collection is not publicly accessible', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[0], listKeys, describeKey[1]); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch collection is encrypted with awskms'); + done(); + }); + }); + it('should give Unknown if unable to list kms keys', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[1], null, describeKey[1]); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + it('should give Unknown if unable to query kms keys', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[1], listKeys); + collectionCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query KMS key'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.js b/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.js new file mode 100644 index 000000000..87b36bf8a --- /dev/null +++ b/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Collection Public Access', + category: 'OpenSearch', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensures that OpenSearch Serverless collections are not publicly accessible.', + more_info: 'OpenSearch Serverless collections should be not be publicly accessible to prevent unauthorized actions.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-network.html', + recommended_action: 'Update the network policy and remove the public access to the collection.', + apis: ['OpenSearchServerless:listNetworkSecurityPolicies', 'OpenSearchServerless:getNetworkSecurityPolicy', 'OpenSearchServerless:listCollections'], + realtime_triggers: ['opensearchserverless:CreateCollection', 'opensearserverless:UpdateCollection', 'opensearchserverless:DeleteCollection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.opensearchserverless, function(region, rcb){ + var listCollections = helpers.addSource(cache, source, + ['opensearchserverless', 'listCollections', region]); + + if (!listCollections) return rcb(); + + if ( !listCollections.data || listCollections.err) { + helpers.addResult(results, 3, + 'Unable to query list OpenSearch collections: ' + helpers.addError(listCollections), region); + return rcb(); + } + + if (!listCollections.data.length){ + helpers.addResult(results, 0, 'No OpenSearch collections found', region); + return rcb(); + } + var listSecurityPolicies = helpers.addSource(cache, source, + ['opensearchserverless', 'listNetworkSecurityPolicies', region]); + + if (!listSecurityPolicies && listSecurityPolicies.err || !listSecurityPolicies.data) { + helpers.addResult(results, 3, + 'Unable to list OpenSearch security policies: ' + helpers.addError(listSecurityPolicies), region); + return rcb(); + } + + + let policyMap = {}; + for (let policy of listSecurityPolicies.data){ + var getSecurityPolicy = helpers.addSource(cache, source, + ['opensearchserverless', 'getNetworkSecurityPolicy', region, policy.name]); + + if (getSecurityPolicy && getSecurityPolicy.data && getSecurityPolicy.data.securityPolicyDetail && getSecurityPolicy.data.securityPolicyDetail.policy){ + for (let collection of listCollections.data){ + for (let p of getSecurityPolicy.data.securityPolicyDetail.policy){ + if (p.AllowFromPublic){ + let found = p.Rules.find(rule => rule.Resource.indexOf(`collection/${collection.name}`) > -1 && + rule.ResourceType == 'collection'); + + if (found && !policyMap[collection.arn]){ + policyMap[collection.arn] = policy.name; + break; + } + } + } + } + } + } + + for (let col of listCollections.data){ + if (policyMap[col.arn]){ + helpers.addResult(results, 2, 'OpenSearch collection is publicly accessible', region, col.arn); + } else { + helpers.addResult(results, 0, 'OpenSearch collection is not publicly accessible', region, col.arn); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.spec.js b/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.spec.js new file mode 100644 index 000000000..d778e9f64 --- /dev/null +++ b/plugins/aws/openSearchServerless/opensearchCollectionPublicAccess.spec.js @@ -0,0 +1,123 @@ +const expect = require('chai').expect; +var collectionPublicAccess = require('./opensearchCollectionPublicAccess'); + +const listCollections = [ + { + "arn": 'arn:aws:lambda:us-east-1:000011112222:function:testing-123', + "id": "123xyz", + "name": "test", + "status": "ACTIVE" + }, +]; +const listSecurityPolicies =[ + { + "name": "auto-test", + "type": "network", + } +] + +const getSecurityPolicy = [ + + { + securityPolicyDetail: { + createdDate: 1677926608534, + description: 'testPolicy', + lastModifiedDate: 1677926608534, + name: 'auto-test', + policy: [ { Rules: [{ Resource:["collection/test"], ResourceType:'collection' }], AllowFromPublic: true } ], + policyVersion: 'MTY3NzkyNjYwODUzNF8x', + type: 'network' + } + }, + { + securityPolicyDetail: { + createdDate: 1677926608534, + description: 'testPolicy', + lastModifiedDate: 1677926608534, + name: 'auto-test', + policy: [ { Rules: [{ Resource:["collection/temp"], ResourceType:'collection' }], AllowFromPublic: true } ], + policyVersion: 'MTY3NzkyNjYwODUzNF8x', + type: 'network' + } + } +] + +const createCache = (listCollections, listSecurityPolicies, getSecurityPolicy) => { + return { + opensearchserverless: { + listCollections: { + 'us-east-1': { + data: listCollections + } + }, + listNetworkSecurityPolicies: { + 'us-east-1': { + data: listSecurityPolicies + } + }, + getNetworkSecurityPolicy: { + 'us-east-1': { + "auto-test":{ + data: getSecurityPolicy + } + } + } + } + }; +}; + +describe('collectionPublicAccess', function () { + describe('run', function () { + + it('should give Unknown result if unable to list collections', function (done) { + const cache = createCache(null); + collectionPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query list OpenSearch collections:'); + done(); + }); + }); + + it('should Pass if no collection found', function (done) { + const cache = createCache([]); + collectionPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OpenSearch collections found'); + done(); + }); + }); + + it('should give UNKNOWN result if unable to list security Policies', function (done) { + const cache = createCache(listCollections, null); + collectionPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list OpenSearch security policies:'); + done(); + }); + }); + + it('should PASS if collection is publically accessible', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[0]); + collectionPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch collection is publicly accessible'); + done(); + }); + }); + + it('should FAIL if collection is not publicly accessible', function (done) { + const cache = createCache(listCollections, listSecurityPolicies, getSecurityPolicy[1]); + collectionPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenSearch collection is not publicly accessible'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchAccessFromIps.js b/plugins/aws/opensearch/opensearchAccessFromIps.js new file mode 100644 index 000000000..4f574c3b1 --- /dev/null +++ b/plugins/aws/opensearch/opensearchAccessFromIps.js @@ -0,0 +1,115 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Access From IP Addresses', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensure only whitelisted IP addresses can access Amazon OpenSearch domains.', + more_info: 'OpenSearch domains should only be accessible only from whitelisted IP addresses to avoid unauthorized access.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-ip', + recommended_action: 'Modify OpenSearch domain access policy to allow only known/whitelisted IP addresses.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + settings: { + whitelisted_ip_addresses: { + name: 'Whitelisted IP Addresses', + description: 'A comma-separated list of trusted IP addresses allowed to access OpenSearch domains', + regex: '/^(?=.*[^.]$)((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}/([1-9]|1[0-9]){1}$/', + default: '' + } + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var whitelisted_ip_addresses = settings.whitelisted_ip_addresses || this.settings.whitelisted_ip_addresses.default; + + if (!whitelisted_ip_addresses.length) return callback(null, results, source); + + whitelisted_ip_addresses = whitelisted_ip_addresses.split(','); + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + if (!listDomainNames) return rcb(); + + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + `Unable to query for OpenSearch domains: ${helpers.addError(listDomainNames)}`, region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + async.each(listDomainNames.data, function(domain, dcb){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult(results, 3, + `Unable to query for OpenSearch domain config: ${helpers.addError(describeDomain)}`, region); + return dcb(); + } + + var resource = describeDomain.data.DomainStatus.ARN; + + if (!describeDomain.data.DomainStatus.AccessPolicies) { + helpers.addResult(results, 0, + 'No access policy found', region, resource); + return dcb(); + } + + var statements = helpers.normalizePolicyDocument(describeDomain.data.DomainStatus.AccessPolicies); + var globalAccess = false; + var intruderIps = []; + + for (var s in statements) { + var statement = statements[s]; + if (!statement.Condition && statement.Principal && helpers.globalPrincipal(statement.Principal, settings)) { + globalAccess = true; + break; + } + + if (statement.Condition && statement.Condition.IpAddress && + statement.Condition.IpAddress['aws:SourceIp'] && statement.Condition.IpAddress['aws:SourceIp'].length) { + statement.Condition.IpAddress['aws:SourceIp'].forEach(ip => { + if (whitelisted_ip_addresses.indexOf(ip) < 0) intruderIps.push(ip); + }); + } + } + + if (globalAccess) { + helpers.addResult(results, 2, + `OpenSearch domain "${domain.DomainName}" is publicly exposed`, region, resource); + return dcb(); + } + + if (!intruderIps.length) { + helpers.addResult(results, 0, + `OpenSearch domain "${domain.DomainName}" is not accessible from any unknown IP address`, region, resource); + } else { + helpers.addResult(results, 2, + `OpenSearch domain "${domain.DomainName}" is accessible from these unknown IP addresses: ${intruderIps.join(', ')}`, + region, resource); + } + + dcb(); + }, function(){ + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/opensearch/opensearchAccessFromIps.spec.js b/plugins/aws/opensearch/opensearchAccessFromIps.spec.js new file mode 100644 index 000000000..b8e250604 --- /dev/null +++ b/plugins/aws/opensearch/opensearchAccessFromIps.spec.js @@ -0,0 +1,170 @@ +const expect = require('chai').expect; +const osAccessFromIps = require('./opensearchAccessFromIps'); + +const domainNames = [ + { + "DomainName": "test-domain3-1" + }, + { + "DomainName": "test-domain-2" + }, + { + "DomainName": "test-domain-3" + } +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-1", + "DomainName": "test-domain-1", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-1", + "Created": true, + "Deleted": false, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:560213429563:domain/es-domain-1/*\",\"Condition\":{\"IpAddress\":{\"aws:SourceIp\":[\"18.208.0.0/13\",\"52.95.245.0/24\"]}}}]}", + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-2", + "DomainName": "test-domain-2", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-2", + "Created": true, + "Deleted": false, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:560213429563:domain/es-domain-1/*\"}]}", + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-3", + "DomainName": "test-domain-2", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-2", + "Created": true, + "Deleted": false, + } + } +]; + +const createCache = (domainNames, domains) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + describeDomain: { + 'us-east-1': { + [name]: { + data: domains + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + es: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('osAccessFromIps', function () { + describe('run', function () { + it('should FAIL if domain is publicly exposed', function (done) { + const cache = createCache([domainNames[1]], domains[1]); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.includes('OpenSearch domain "test-domain-2" is publicly exposed'); + done(); + }); + }); + + it('should PASS if domain is not exposed to any unknown IP addresses', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13,52.95.245.0/24' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('OpenSearch domain "test-domain3-1" is not accessible from any unknown IP address'); + done(); + }); + }); + + it('should FAIL if domain is not exposed to unknown IP addresses', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/16' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.includes('OpenSearch domain "test-domain3-1" is accessible from these unknown IP addresses: 18.208.0.0/13, 52.95.245.0/24'); + done(); + }); + }); + + it('should PASS if no access policy found', function (done) { + const cache = createCache([domainNames[2]], domains[2]); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('No access policy found'); + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([]); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('No OpenSearch domains found') + done(); + }); + }); + + it('should UNKNOWN if unable to list domain names', function (done) { + const cache = createErrorCache(); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.includes('Unable to query for OpenSearch domains') + done(); + }); + }); + + it('should not return any results if list domain names response not found', function (done) { + const cache = createNullCache(); + osAccessFromIps.run(cache, { whitelisted_ip_addresses: '18.208.0.0/13' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any results if whitelisted IP addresses are not provided in settings', function (done) { + const cache = createNullCache(); + osAccessFromIps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchClusterStatus.js b/plugins/aws/opensearch/opensearchClusterStatus.js new file mode 100644 index 000000000..e4c0c83cc --- /dev/null +++ b/plugins/aws/opensearch/opensearchClusterStatus.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Cluster Status', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensure that OpenSearch clusters are healthy, i.e status is green.', + more_info: 'Unhealthy Amazon OpenSearch clusters with the status set to "Red" is crucial for availability of OpenSearch applications.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/cloudwatch-alarms.html', + recommended_action: 'Configure alarms to send notification if cluster status remains red for more than a minute.', + apis: ['OpenSearch:listDomainNames', 'CloudWatch:getEsMetricStatistics', 'STS:getCallerIdentity'], + realtime_triggers: ['openSearch:CreateDomain', 'opensearch:UpdateDomainConfig','opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var accRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', accRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + `Unable to query for OpenSearch domains: ${helpers.addError(listDomainNames)}`, region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(domain => { + if (!domain.DomainName) return; + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + var getMetricStats = helpers.addSource(cache, source, + ['cloudwatch', 'getEsMetricStatistics', region, domain.DomainName]); + + if (!getMetricStats || getMetricStats.err || !getMetricStats.data) { + helpers.addResult(results, 3, + `Unable to query for OpenSearch domain metric stat: ${helpers.addError(getMetricStats)}`, region, resource); + return; + } + const data = getMetricStats.data.Datapoints.find(datapoint => datapoint.Maximum && datapoint.Maximum >= 1); + const status = data ? 2 : 0; + helpers.addResult(results, status, + `OpenSearch Domain is ${data ? 'unhealthy': 'healthy'}`, region, resource); + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchClusterStatus.spec.js b/plugins/aws/opensearch/opensearchClusterStatus.spec.js new file mode 100644 index 000000000..96dc5917c --- /dev/null +++ b/plugins/aws/opensearch/opensearchClusterStatus.spec.js @@ -0,0 +1,179 @@ +const expect = require('chai').expect; +const osClusterStatus = require('./opensearchClusterStatus'); + +const domainNames = [ + { + "DomainName": "test-domain3-1" + } +]; + +const esMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Maximum": 1.333, + "Unit": "Count" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Maximum": 1.333, + "Unit": "Count" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Maximum": 1.333, + "Unit": "Count" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Maximum": 0, + "Unit": "Count" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Maximum": 0, + "Unit": "Count" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Maximum": 0, + "Unit": "Count" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Unit": "Count" + } + ] + } +] + +const createCache = (domainNames, metrics) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + }, + cloudwatch: { + getEsMetricStatistics: { + 'us-east-1': { + [name]: { + data: metrics + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + cloudwatch: { + getEsMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getEsMetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('osClusterStatus', function () { + describe('run', function () { + it('should FAIL if metric count is greater than 1', function (done) { + const cache = createCache([domainNames[0]], esMetricStatistics[0]); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.includes('OpenSearch Domain is unhealthy'); + done(); + }); + }); + + it('should PASS if metric count is lesser than 1', function (done) { + const cache = createCache([domainNames[0]], esMetricStatistics[1]); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('OpenSearch Domain is healthy'); + done(); + }); + }); + + it('should PASS if metric count is not part of the response', function (done) { + const cache = createCache([domainNames[0]], esMetricStatistics[2]); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('OpenSearch Domain is healthy'); + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([]); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('No OpenSearch domains found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list domain names', function (done) { + const cache = createErrorCache(); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.includes('Unable to query for OpenSearch domains:'); + done(); + }); + }); + + it('should not return any results if list domain names response not found', function (done) { + const cache = createNullCache(); + osClusterStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchCrossAccountAccess.js b/plugins/aws/opensearch/opensearchCrossAccountAccess.js new file mode 100644 index 000000000..6e6d3c518 --- /dev/null +++ b/plugins/aws/opensearch/opensearchCrossAccountAccess.js @@ -0,0 +1,172 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Domain Cross Account access', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Critical', + description: 'Ensures that only trusted accounts have access to OpenSearch domains.', + more_info: 'Allowing unrestricted access of OpenSearch clusters will cause data leaks and data loss. This can be prevented by restricting access only to the trusted entities by implementing the appropriate access policies.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/cross-cluster-search.html', + recommended_action: 'Restrict the access to OpenSearch clusters to allow only trusted accounts.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity', 'Organizations:listAccounts'], + settings: { + os_whitelisted_aws_account_principals: { + name: 'Whitelisted AWS Account Principals', + description: 'A comma-separated list of trusted cross account principals', + regex: '^.*$', + default: '' + }, + os_whitelisted_aws_account_principals_regex: { + name: 'Whitelisted AWS Account Principals Regex', + description: 'If set, plugin will compare cross account principals against this regex instead of otherwise given comma-separated list' + + 'Example regex: ^arn:aws:iam::(111111111111|222222222222|):.+$', + regex: '^.*$', + default: '' + }, + os_whitelist_aws_organization_accounts: { + name: 'Whitelist AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + }, + os_policy_condition_keys: { + name: 'OpenSearch Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount,aws:PrincipalArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner' + }, + }, + realtime_triggers: ['opensearch:CreateDomain','opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var config= { + os_whitelisted_aws_account_principals : settings.os_whitelisted_aws_account_principals || this.settings.os_whitelisted_aws_account_principals.default, + os_whitelisted_aws_account_principals_regex : settings.os_whitelisted_aws_account_principals_regex || this.settings.os_whitelisted_aws_account_principals_regex.default, + os_whitelist_aws_organization_accounts: settings.os_whitelist_aws_organization_accounts || this.settings.os_whitelist_aws_organization_accounts.default, + os_policy_condition_keys: settings.os_policy_condition_keys || this.settings.os_policy_condition_keys.default, + }; + var allowedConditionKeys = config.os_policy_condition_keys.split(','); + var makeRegexBased = (config.os_whitelisted_aws_account_principals_regex.length) ? true : false; + var whitelistOrganization = (config.os_whitelist_aws_organization_accounts == 'true'); + config.os_whitelisted_aws_account_principals_regex = new RegExp(config.os_whitelisted_aws_account_principals_regex); + + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', regions.default, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', defaultRegion]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, defaultRegion); + return callback(null, results, source); + } + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(domain => { + if (!domain.DomainName) return; + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult(results, 3, + 'Unable to query for ES domain config: ' + helpers.addError(describeDomain), region, resource); + } else { + var localDomain = describeDomain.data.DomainStatus; + + if (!localDomain.AccessPolicies) { + helpers.addResult(results, 0, + 'OpenSearch domain does not have access policy defined', region, resource); + return; + } + + var statements = helpers.normalizePolicyDocument(localDomain.AccessPolicies); + + if (!statements){ + helpers.addResult(results, 0, 'No statement exists for the policy', region, resource); + return; + } + + var restrictedAccountPrincipals = []; + var crossAccountEs = false; + + statements.forEach(statement => { + if (!statement.Principal) return; + + let conditionalPrincipals = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, true, accountId, settings); + if (helpers.crossAccountPrincipal(statement.Principal, accountId, undefined , settings) || + (conditionalPrincipals && conditionalPrincipals.length)) { + let crossAccountPrincipals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + + if (conditionalPrincipals && conditionalPrincipals.length) { + conditionalPrincipals.forEach(conPrincipal => { + if (!conPrincipal.includes(accountId)) crossAccountPrincipals.push(conPrincipal); + }); + } + + if (!crossAccountPrincipals.length) return; + crossAccountEs = true; + crossAccountPrincipals.forEach(principal => { + if (whitelistOrganization) { + if (organizationAccounts.find(account => principal.includes(account))) return; + } + if (makeRegexBased) { + if (!config.os_whitelisted_aws_account_principals_regex.test(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + } else if (!config.os_whitelisted_aws_account_principals.includes(principal) && + !restrictedAccountPrincipals.includes(principal)) restrictedAccountPrincipals.push(principal); + }); + } + }); + + if (crossAccountEs && !restrictedAccountPrincipals.length) { + helpers.addResult(results, 0, + 'OpenSearch domain contains trusted account principals only', region, resource); + } else if (crossAccountEs) { + helpers.addResult(results, 2, + `OpenSearch domain contains these untrusted account principals: ${restrictedAccountPrincipals.join(', ')}`, region,resource); + } else { + helpers.addResult(results, 0, + 'OpenSearch domain does not contain cross-account policy statement', region, resource); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/opensearch/opensearchCrossAccountAccess.spec.js b/plugins/aws/opensearch/opensearchCrossAccountAccess.spec.js new file mode 100644 index 000000000..8696ff9ac --- /dev/null +++ b/plugins/aws/opensearch/opensearchCrossAccountAccess.spec.js @@ -0,0 +1,197 @@ +const expect = require('chai').expect; +var osCrossAccountAccess = require('./opensearchCrossAccountAccess'); + +const domainNames = [ + { + "DomainName": "test-domain-1" + }, +]; + +const domains = [ + { + 'DomainStatus': { + 'DomainId': '111111111111/test-domain-1', + 'DomainName': 'test-domain-1', + 'ARN': 'arn:aws:es:us-east-1:11111111111:domain/test-domain-1', + 'Created': true, + 'Deleted': false, + 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::211111111111:user/y"},"Action":"es:*","Resource":"arn:aws:events:us-east-1:111111111111:domain/test-domain-1/*"}]}', + } + }, + { + 'DomainStatus': { + 'DomainId': '111111111111/test-domain-1', + 'DomainName': 'test-domain-1', + 'ARN': 'arn:aws:es:us-east-1:111111111111:domain/test-domain-1', + 'Created': true, + 'Deleted': false, + 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::111111111111:user/x"},"Action":"es:*","Resource":"arn:aws:events:us-east-1:111111111111:domain/test-domain-1/*"}]}', + } + }, + { + 'DomainStatus': { + 'DomainId': '111111111111/test-domain-1', + 'DomainName': 'test-domain-1', + 'ARN': 'arn:aws:es:us-east-1:111111111111:domain/test-domain-1', + 'Created': true, + 'Deleted': false, + 'AccessPolicies': '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"es:*","Resource":"arn:aws:es:us-east-1:111111111111:domain/test-domain-1/*"}]}', + } + }, + { + 'DomainStatus': { + 'DomainId': '111111111111/test-domain-1', + 'DomainName': 'test-domain-1', + 'ARN': 'arn:aws:es:us-east-1:111111111111:domain/test-domain-1', + 'Created': true, + 'Deleted': false, + } + }, +]; + +const organizationAccounts = [ + { + "Id": "211111111111", + "Arn": "arn:aws:organizations::211111111111:account/o-sb9qmv2zif/111111111111", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + }, + { + "Id": "123456654322", + "Arn": "arn:aws:organizations::123456654322:account/o-sb9qmv2zif/123456654322", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + } +] + +const createCache = (domainNames, domains, accounts, domainNamesErr,domainsErr) => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + err: domainNamesErr + } + }, + describeDomain: { + 'us-east-1': { + 'test-domain-1': { + data: domains, + err: domainsErr + } + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1':{ + data: '111111111111' + } + } + }, + organizations: { + listAccounts: { + 'us-east-1': { + data: accounts + } + } + } + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null + }, + describeDomain: { + 'us-east-1': null + } + } + }; +}; + +describe('osCrossAccountAccess', function () { + describe('run', function () { + + it('should PASS if opensearch domain has cross-account access policy attached', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osCrossAccountAccess.run(cache, {"os_whitelisted_aws_account_principals":['arn:aws:iam::211111111111:user/y']}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('OpenSearch domain contains trusted account principals only') + done(); + }); + }); + + it('should PASS if cross-account role contains organization account ID and setting to allow organization account is true', function (done) { + const cache = createCache([domainNames[0]], domains[0], [organizationAccounts[0]]); + osCrossAccountAccess.run(cache, { "os_whitelist_aws_organization_accounts": "true" }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.includes('OpenSearch domain contains trusted account principals only') + done(); + }); + }); + + it('should PASS if opensearch domain does not have cross-account access policy attached', function (done) { + const cache = createCache([domainNames[0]], domains[1]); + osCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('OpenSearch domain does not contain cross-account policy statement') + done(); + }); + }); + + it('should PASS if no opensearch Domain policy found', function (done) { + const cache = createCache([domainNames[0]], domains[3]); + osCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('OpenSearch domain does not have access policy defined') + done(); + }); + }); + + it('should FAIL if no opensearch domain found', function (done) { + const cache = createCache([]); + osCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('No OpenSearch domains found') + done(); + }); + }); + + it('should UNKNOWN if unable to describe opensearch domain', function (done) { + const cache = createCache([], [], [], { message: 'Unable to query ES domains' }); + osCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('Unable to query for OpenSearch domains') + done(); + }); + }); + + it('should not return anything if query opensearch domains response not found', function (done) { + const cache = createNullCache(); + osCrossAccountAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.js b/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.js new file mode 100644 index 000000000..fe6e65176 --- /dev/null +++ b/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Dedicated Master Enabled', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Amazon OpenSearch domains are using dedicated master nodes.', + more_info: 'Using OpenSearch dedicated master nodes to separate management tasks from index and search requests will improve the clusters ability to manage easily different types of workload and make them more resilient in production.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-dedicatedmasternodes.html', + recommended_action: 'Update the domain to use dedicated master nodes.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + realtime_triggers: ['opensearch:CreateDomain','opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(domain => { + if (!domain.DomainName) return; + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region, resource); + } else { + var localDomain = describeDomain.data.DomainStatus; + + if (localDomain.ClusterConfig && + localDomain.ClusterConfig.DedicatedMasterEnabled) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to use dedicated master node', region, resource); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not configured to use dedicated master node', region, resource); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.spec.js b/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.spec.js new file mode 100644 index 000000000..f7cac906b --- /dev/null +++ b/plugins/aws/opensearch/opensearchDedicatedMasterEnabled.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var osDedicatedMasterEnabled = require('./opensearchDedicatedMasterEnabled'); + +const domains = [ + { + "DomainStatus": { + "DomainName": 'mydomain', + "ARN": 'arn:1234', + "ClusterConfig": { + "DedicatedMasterEnabled": true + } + } + }, + { + "DomainStatus": { + "DomainName": 'mydomain', + "ARN": '"arn":1234', + "ClusterConfig": { + "DedicatedMasterEnabled": false + } + } + }, + { + "DomainStatus": { + "DomainName": 'mydomain', + "ARN": 'arn:1234', + "ClusterConfig": {} + } + } +] + +const domainNames = [ + { + DomainName: 'mydomain' + } +] + +const createCache = (listData, descData) => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeDomain: { + 'us-east-1': { + 'mydomain': { + err: null, + data: descData + } + } + } + } + } +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + describeDomain: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + describeDomain: { + 'us-east-1': null + } + }, + }; +} + +describe('osDedicatedMasterEnabled', function () { + describe('run', function () { + it('should PASS if dedicated master nodes are used', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osDedicatedMasterEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenSearch domain is configured to use dedicated master node') + done(); + }); + }); + + it('should PASS if no opensearch domains present', function (done) { + const cache = createCache([], {}); + osDedicatedMasterEnabled.run(cache, {}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OpenSearch domains found') + done(); + }); + }); + + it('should FAIL if dedicated master node not used', function (done) { + const cache = createCache([domainNames[0]], domains[1]); + osDedicatedMasterEnabled.run(cache, {}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain is not configured to use dedicated master node') + done(); + }); + }); + + it('should FAIL if opensearch cluster config does not have dedicated master enabled key', function (done) { + const cache = createCache([domainNames[0]], domains[2]); + osDedicatedMasterEnabled.run(cache, {}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain is not configured to use dedicated master node') + done(); + }); + }); + + it('should UNKNOWN if unable to list domain names', function (done) { + const cache = createErrorCache(); + osDedicatedMasterEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OpenSearch domains') + done(); + }); + }); + + it('should not return any results if list domain names response not found', function (done) { + const cache = createNullCache(); + osDedicatedMasterEnabled.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }) +}); diff --git a/plugins/aws/opensearch/opensearchDesiredInstanceTypes.js b/plugins/aws/opensearch/opensearchDesiredInstanceTypes.js new file mode 100644 index 000000000..f3aded4f1 --- /dev/null +++ b/plugins/aws/opensearch/opensearchDesiredInstanceTypes.js @@ -0,0 +1,115 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Desired Instance Type', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that all your Amazon OpenSearch cluster instances are of given instance types.', + more_info: 'Limiting the type of Amazon OpenSearch cluster instances that can be provisioned will help address compliance requirements and prevent unexpected charges on the AWS bill.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/supported-instance-types.html', + recommended_action: 'Reconfigure the domain to have the desired instance types.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + settings: { + os_desired_data_instance_type: { + name: 'OpenSearch Data Instance Type', + description: 'Instance type of OpenSearch data instances', + regex: '^.*$', + default: '' + }, + os_desired_master_instance_type: { + name: 'OpenSearch Master Instance Type', + description: 'Instance type of OpenSearch dedicated master instances', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + + const os_desired_data_instance_type = settings.os_desired_data_instance_type || this.settings.os_desired_data_instance_type.default; + const os_desired_master_instance_type = settings.os_desired_master_instance_type || this.settings.os_desired_master_instance_type.default; + + if (!os_desired_data_instance_type.length && !os_desired_master_instance_type.length) return callback(null, results, source); + + const acctRegion = helpers.defaultRegion(settings); + const accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + const awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + const listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + for (const domain of listDomainNames.data) { + if (!domain.DomainName) continue; + + const describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region, resource); + continue; + } + + let disallowedDataInstanceTypes = []; + let disallowedDedicatedInstanceTypes = []; + + if (describeDomain.data.DomainStatus.ClusterConfig) { + const config = describeDomain.data.DomainStatus.ClusterConfig; + + if (config.InstanceType && !os_desired_data_instance_type.includes(config.InstanceType)) disallowedDataInstanceTypes.push(config.InstanceType); + if (config.DedicatedMasterType && !os_desired_master_instance_type.includes(config.DedicatedMasterType)) disallowedDedicatedInstanceTypes.push(config.DedicatedMasterType); + } + + if (disallowedDedicatedInstanceTypes.length && disallowedDataInstanceTypes.length) { + helpers.addResult(results, 2, + `OpenSearch domain is using ${disallowedDedicatedInstanceTypes.join(', ')} master instance(s) and ${disallowedDataInstanceTypes.join(', ')} data instance(s)`, + region, resource); + } else if (disallowedDedicatedInstanceTypes.length) { + helpers.addResult(results, 2, + `OpenSearch domain is using ${disallowedDedicatedInstanceTypes.join(', ')} master instance(s)`, + region, resource); + } else if (disallowedDataInstanceTypes.length) { + helpers.addResult(results, 2, + `OpenSearch domain is using ${disallowedDataInstanceTypes.join(', ')} data instance(s)`, + region, resource); + } else { + helpers.addResult(results, 0, + 'ES domain is using allowed master and node instance types', + region, resource); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchDesiredInstanceTypes.spec.js b/plugins/aws/opensearch/opensearchDesiredInstanceTypes.spec.js new file mode 100644 index 000000000..e6571ac5f --- /dev/null +++ b/plugins/aws/opensearch/opensearchDesiredInstanceTypes.spec.js @@ -0,0 +1,156 @@ +const expect = require('chai').expect; +const osDesiredInstanceTypes = require('./opensearchDesiredInstanceTypes'); + +const domainNames = [ + { + "DomainName": "test-domain" + }, +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain", + "DomainName": "test-domain", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain", + "ClusterConfig": { + "InstanceType": "t2.small.elasticsearch", + "DedicatedMasterType": "t2.small.elasticsearch", + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain", + "DomainName": "test-domain", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain", + "ClusterConfig": { + "InstanceType": "t2.small.elasticsearch", + } + } + }, +]; + +const createCache = (domainNames, domains) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + describeDomain: { + 'us-east-1': { + [name]: { + data: domains + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('osDesiredInstanceTypes', function () { + describe('run', function () { + it('should FAIL if dedicated master and data node are not of desired instance type', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.medium.elasticsearch', os_desired_master_instance_type: 't2.medium.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain is using t2.small.elasticsearch master instance(s) and t2.small.elasticsearch data instance(s)') + done(); + }); + }); + + it('should FAIL if dedicated master instance is not of desired type', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osDesiredInstanceTypes.run(cache, {os_desired_master_instance_type: 't2.medium.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain is using t2.small.elasticsearch master instance(s) and t2.small.elasticsearch data instance(s)') + done(); + }); + }); + + it('should FAIL if data node instance is not of desired type', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.medium.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain is using t2.small.elasticsearch master instance(s) and t2.small.elasticsearch data instance(s)') + done(); + }); + }); + + it('should PASS if master and data instances re of desired instance types', function (done) { + const cache = createCache([domainNames[0]], domains[1]); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.small.elasticsearch', os_desired_master_instance_type: 't2.small.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('ES domain is using allowed master and node instance types') + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([], []); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.small.elasticsearch', os_desired_master_instance_type: 't2.small.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OpenSearch domains found') + done(); + }); + }); + + it('should UNKNOWN if there was an error listing domain names', function (done) { + const cache = createErrorCache(); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.small.elasticsearch', os_desired_master_instance_type: 't2.small.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OpenSearch domains') + done(); + }); + }); + + it('should not return any results if unable to query for domain names', function (done) { + const cache = createNullCache(); + osDesiredInstanceTypes.run(cache, {os_desired_data_instance_type: 't2.small.elasticsearch', os_desired_master_instance_type: 't2.small.elasticsearch'}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should not return any results if settings are not provided', function (done) { + const cache = createNullCache(); + osDesiredInstanceTypes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.js b/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.js new file mode 100644 index 000000000..05821f328 --- /dev/null +++ b/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.js @@ -0,0 +1,110 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Encryption Enabled', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensure that AWS OpenSearch domains have encryption enabled.', + more_info: 'OpenSearch domains should be encrypted to ensure that data is secured.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/encryption-at-rest.html', + recommended_action: 'Ensure encryption-at-rest is enabled for all OpenSearch domains.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'KMS:listKeys', 'KMS:describeKey', 'STS:getCallerIdentity'], + settings: { + es_encryption_level: { + name: 'OpenSearch Domain Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var config = { + desiredEncryptionLevelString: settings.es_encryption_level || this.settings.es_encryption_level.default + }; + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(domain => { + if (!domain.DomainName) return; + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region, resource); + } else { + if (describeDomain.data.DomainStatus && + describeDomain.data.DomainStatus.EncryptionAtRestOptions && + describeDomain.data.DomainStatus.EncryptionAtRestOptions.Enabled && + describeDomain.data.DomainStatus.EncryptionAtRestOptions.KmsKeyId) { + var kmsKeyId = describeDomain.data.DomainStatus.EncryptionAtRestOptions.KmsKeyId.split('/')[1]; + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, region, kmsKeyId); + return; + } + + var currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `OpenSearch domain has encryption at-rest enabled for data at encryption level ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `OpenSearch domain has encryption at-rest enabled for data at encryption level ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not configured to use encryption at rest', region, resource); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, +}; diff --git a/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.spec.js b/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.spec.js new file mode 100644 index 000000000..d88526269 --- /dev/null +++ b/plugins/aws/opensearch/opensearchDomainEncryptionEnabled.spec.js @@ -0,0 +1,229 @@ +var expect = require('chai').expect; +var osDomainEncryptionEnabled = require('./opensearchDomainEncryptionEnabled'); + +const domains = [ + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EncryptionAtRestOptions: { + Enabled: true, + KmsKeyId: 'arn:aws:kms:us-east-1:111122223333:key/34e9wedw-ae6b-4c36-9405-06e67bccswwd' + } + } + }, + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EncryptionAtRestOptions: { + Enabled: true, + KmsKeyId: 'arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1' + } + } + }, + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EncryptionAtRestOptions: { + Enabled: false + } + } + }, + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EncryptionAtRestOptions: { + Enabled: true + } + } + } +] + +const domainNames = [ + { + DomainName: 'mydomain' + } +] + + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "34e9wedw-ae6b-4c36-9405-06e67bccswwd", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/34e9wedw-ae6b-4c36-9405-06e67bccswwd", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "My key", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "111122223333", + "KeyId": "75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/75e9285f-ae6b-4c36-9405-06e67bcc7ef1", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +] + +const createCache = (listData, descData, describeKey) => { + var keyId = ( descData && descData.DomainStatus && + descData.DomainStatus.EncryptionAtRestOptions && + descData.DomainStatus.EncryptionAtRestOptions.KmsKeyId) ? descData.DomainStatus.EncryptionAtRestOptions.KmsKeyId.split('/')[1] : null; + + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeDomain: { + 'us-east-1': { + 'mydomain': { + err: null, + data: descData + } + } + } + }, + kms: { + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + }, + }, + }, + }, + } +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + describeDomain: { + 'us-east-1': { + err: { + message: 'error describing domain names' + }, + }, + } + }, + kms: { + describeKey: { + 'us-east-1': { + err: { + message: 'error describing keys' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + describeDomain: { + 'us-east-1': null + } + }, + kms: { + describeKey: { + 'us-east-1': null + } + }, + }; +} + +describe('osDomainEncryptionEnabled', function () { + describe('run', function () { + + it('should PASS if OpenSearch domain has encryption at rest at an encryption level greater than or equal to target encryption level', function (done) { + const cache = createCache([domainNames[0]], domains[1], describeKey[0]); + osDomainEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no OpenSearch domains present', function (done) { + const cache = createCache([], {}); + osDomainEncryptionEnabled.run(cache, {}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if OpenSearch encryption config is disabled', function (done) { + const cache = createCache([domainNames[0]], domains[2], describeKey[0]); + osDomainEncryptionEnabled.run(cache, {}, (err, results) => {; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if OpenSearch domain has encryption at rest at an encryption level greater than target encryption level', function (done) { + const cache = createCache([domainNames[0]], domains[0], describeKey[1]); + osDomainEncryptionEnabled.run(cache, {es_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to list domain names', function (done) { + const cache = createErrorCache(); + osDomainEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if list domain names response not found', function (done) { + const cache = createNullCache(); + osDomainEncryptionEnabled.run(cache, { }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }) +}) \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchEnableAuditLogs.js b/plugins/aws/opensearch/opensearchEnableAuditLogs.js new file mode 100644 index 000000000..357178726 --- /dev/null +++ b/plugins/aws/opensearch/opensearchEnableAuditLogs.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Audit Logs Enabled', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that audit logs feature is enabled for OpenSearch domains.', + more_info: 'Enabling audit logs feature allows to keep track of all user activity on Amazon OpenSearch domains (clusters), including failed login attempts, including authentication success and failures, index changes, and incoming search queries, enhancing security and compliance monitoring.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/audit-logs.html', + recommended_action: 'Modify Opensearch domain and enable audit logs.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.es, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain) { + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, 'Unable to query for OpenSearch domain config: ' + + helpers.addError(describeDomain), region); + return; + } else { + if (describeDomain.data.DomainStatus.LogPublishingOptions && + describeDomain.data.DomainStatus.LogPublishingOptions.AUDIT_LOGS && + describeDomain.data.DomainStatus.LogPublishingOptions.AUDIT_LOGS.Enabled) { + helpers.addResult(results, 0, + 'Audit Logs feature is enabled for OpenSearch domain', region, resource); + } else { + helpers.addResult(results, 2, + 'Audit Logs feature is not enabled for OpenSearch domain', region, resource); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/opensearch/opensearchEnableAuditLogs.spec.js b/plugins/aws/opensearch/opensearchEnableAuditLogs.spec.js new file mode 100644 index 000000000..461fd3c59 --- /dev/null +++ b/plugins/aws/opensearch/opensearchEnableAuditLogs.spec.js @@ -0,0 +1,144 @@ +const expect = require('chai').expect; +const opensearchEnableAuditLogs = require('./opensearchEnableAuditLogs'); + +const domainNames = [ + { + "DomainName": "test-domain3-104" + }, + { + "DomainName": "test-domain-104" + }, + { + "DomainName": "test-domain2-104" + } +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-104", + "DomainName": "test-domain-104", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-104", + "Created": true, + "Deleted": false, + "LogPublishingOptions": { + "AUDIT_LOGS": { + "Enabled": true + } + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain3-104", + "DomainName": "test-domain3-104", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain3-104", + "Created": true, + "Deleted": false, + } + } +]; + +const createCache = (domainNames, domains) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + describeDomain: { + 'us-east-1': { + [name]: { + data: domains + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('opensearchEnableAuditLogs', function () { + describe('run', function () { + it('should FAIL if Audit Logs feature is not enabled for OpenSearch domain', function (done) { + const cache = createCache([domainNames[0]], domains[1]); + opensearchEnableAuditLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Audit Logs feature is not enabled for OpenSearch domain') + done(); + }); + }); + + it('should PASS if Audit Logs feature is enabled for OpenSearch domain', function (done) { + const cache = createCache([domainNames[1]], domains[0]); + opensearchEnableAuditLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Audit Logs feature is enabled for OpenSearch domain') + done(); + }); + }); + + + it('should PASS if no domain names found', function (done) { + const cache = createCache([]); + opensearchEnableAuditLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('No OpenSearch domains found') + done(); + }); + }); + + it('should UNKNOWN if there was an error listing domain names', function (done) { + const cache = createErrorCache(); + opensearchEnableAuditLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Unable to query for OpenSearch domains') + done(); + }); + }); + + it('should UNKNOWN if unable to query for describing domain names', function (done) { + const cache = createCache(null,domainNames[1]); + opensearchEnableAuditLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Unable to query for OpenSearch domains') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchEncryptedDomain.js b/plugins/aws/opensearch/opensearchEncryptedDomain.js new file mode 100644 index 000000000..351b62391 --- /dev/null +++ b/plugins/aws/opensearch/opensearchEncryptedDomain.js @@ -0,0 +1,150 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Encrypted Domain', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensures OpenSearch domains are encrypted with KMS', + more_info: 'OpenSearch domains should be encrypted to ensure data at rest is secured.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/encryption-at-rest.html', + recommended_action: 'Ensure encryption-at-rest is enabled for all OpenSearch domains.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + remediation_description: 'OpenSearch domain will be encrypted with KMS.', + remediation_min_version: '202102151900', + apis_remediate: ['OpenSearch:listDomainNames', 'KMS:listKeys', 'KMS:describeKey'], + remediation_inputs: { + kmsKeyIdForES: { + name: '(Optional) KMS Key Id For ElasticSearch', + description: 'KMS Key Id that will be used to encrypt ElasticSearch domain', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + actions: { + remediate: ['OpenSearch:updateDomainConfig'], + rollback: ['OpenSearch:updateDomainConfig'] + }, + permissions: { + remediate: ['opensearch:UpdateDomainConfig'], + rollback: ['opensearch:UpdateDomainConfig'] + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.es, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + + if (localDomain.EncryptionAtRestOptions && + localDomain.EncryptionAtRestOptions.Enabled) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to use encryption at rest', region, localDomain.ARN); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not configured to use encryption at rest', region, localDomain.ARN); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'opensearchEncryptedDomain'; + let defaultKeyDesc = 'Default master key that protects my OpenSearch data when no other key is defined'; + var domainNameArr = resource.split(':'); + var domain = domainNameArr[domainNameArr.length - 1].split('/')[1]; + + // find the location of the domain needing to be remediated + var domainLocation = domainNameArr[3]; + + // add the location of the domain to the config + config.region = domainLocation; + + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdForES) { + params = { + DomainName: domain, + EncryptionAtRestOptions: { + 'Enabled': true, + 'KmsKeyId': settings.input.kmsKeyIdForES + }, + }; + } else { + let defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default OpenSearch key for the region ${config.region}`); + params = { + DomainName: domain, + EncryptionAtRestOptions: { + 'Enabled': true, + 'KmsKeyId': defaultKmsKeyId + }, + }; + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'OpenSearch': resource + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'OpenSearch': domain + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; diff --git a/plugins/aws/es/esEncryptedDomain.spec.js b/plugins/aws/opensearch/opensearchEncryptedDomain.spec.js similarity index 74% rename from plugins/aws/es/esEncryptedDomain.spec.js rename to plugins/aws/opensearch/opensearchEncryptedDomain.spec.js index 0ab13b76b..993246364 100644 --- a/plugins/aws/es/esEncryptedDomain.spec.js +++ b/plugins/aws/opensearch/opensearchEncryptedDomain.spec.js @@ -1,17 +1,16 @@ -var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esEncryptedDomain'); +var es = require('./opensearchEncryptedDomain'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +22,13 @@ const createCache = (listData, descData) => { } }; -describe('esEncryptedDomain', function () { +describe('osEncryptedDomain', function () { describe('run', function () { - it('should give passing result if no ES domains present', function (done) { + it('should give passing result if no opensearch domains present', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +40,11 @@ describe('esEncryptedDomain', function () { es.run(cache, {}, callback); }) - it('should give error result if ES encryption config is disabled', function (done) { + it('should give error result if opensearch encryption config is disabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is not configured to use encryption at rest') + expect(results[0].message).to.include('OpenSearch domain is not configured to use encryption at rest') done() }; @@ -69,11 +68,11 @@ describe('esEncryptedDomain', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES encryption config is enabled', function (done) { + it('should give passing result if opensearch encryption config is enabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use encryption at rest') + expect(results[0].message).to.include('OpenSearch domain is configured to use encryption at rest') done() }; diff --git a/plugins/aws/opensearch/opensearchExposedDomain.js b/plugins/aws/opensearch/opensearchExposedDomain.js new file mode 100644 index 000000000..c5d36fdb3 --- /dev/null +++ b/plugins/aws/opensearch/opensearchExposedDomain.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Exposed Domain', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensures OpenSearch domains are not publicly exposed to all AWS accounts', + more_info: 'OpenSearch domains should not be publicly exposed to all AWS accounts.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html', + recommended_action: 'Update OpenSearch domain to set access control.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + async.each(listDomainNames.data, function(domain, cb){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + var resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region, resource); + return cb(); + } + + var exposed; + + if (describeDomain.data.DomainStatus.AccessPolicies) { + var statements = helpers.normalizePolicyDocument(describeDomain.data.DomainStatus.AccessPolicies); + + if (statements && statements.length) { + for (let statement of statements) { + var statementPrincipals = helpers.extractStatementPrincipals(statement); + exposed = statementPrincipals.find(principal => principal == '*'); + if (exposed) break; + } + + if (exposed) { + helpers.addResult(results, 2, + 'Domain :' + domain.DomainName + ': is exposed to all AWS accounts', + region, resource); + } else { + helpers.addResult(results, 0, + 'Domain :' + domain.DomainName + ': is not exposed to all AWS accounts', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'No statement found for access policies', region, resource); + } + } else { + helpers.addResult(results, 2, + 'No access policy found', region, resource); + } + + cb(); + }, function() { + rcb(); + }); + + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchExposedDomain.spec.js b/plugins/aws/opensearch/opensearchExposedDomain.spec.js new file mode 100644 index 000000000..6e264f690 --- /dev/null +++ b/plugins/aws/opensearch/opensearchExposedDomain.spec.js @@ -0,0 +1,316 @@ +const expect = require('chai').expect; +const osExposedDomain = require('./opensearchExposedDomain'); + +const domainNames = [ + { + "DomainName": "test-domain3-104" + }, + { + "DomainName": "test-domain-104" + }, + { + "DomainName": "test-domain2-104" + } +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-104", + "DomainName": "test-domain-104", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-104", + "Created": true, + "Deleted": false, + "Endpoints": { + "vpc": "vpc-test-domain-104-cpdukg4kpajspjci6szlymbqvi.us-east-1.es.amazonaws.com" + }, + "Processing": false, + "UpgradeProcessing": false, + "ElasticsearchVersion": "7.7", + "ElasticsearchClusterConfig": { + "InstanceType": "t2.small.elasticsearch", + "InstanceCount": 1, + "DedicatedMasterEnabled": false, + "ZoneAwarenessEnabled": false, + "WarmEnabled": false + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeType": "gp2", + "VolumeSize": 10 + }, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:1123456654321:domain/test-domain-104/*\"}]}", + "SnapshotOptions": {}, + "VPCOptions": { + "VPCId": "vpc-99de2fe4", + "SubnetIds": [ + "subnet-c21b84cc" + ], + "AvailabilityZones": [ + "us-east-1f" + ], + "SecurityGroupIds": [ + "sg-047e6cc36b13ec60e" + ] + }, + "CognitoOptions": { + "Enabled": false + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "AdvancedOptions": { + "rest.action.multi.allow_explicit_index": "true" + }, + "ServiceSoftwareOptions": { + "CurrentVersion": "R20200721", + "NewVersion": "", + "UpdateAvailable": false, + "Cancellable": false, + "UpdateStatus": "COMPLETED", + "Description": "There is no software update available for this domain.", + "AutomatedUpdateDate": 0.0, + "OptionalDeployment": true + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "AdvancedSecurityOptions": { + "Enabled": false, + "InternalUserDatabaseEnabled": false + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain3-104", + "DomainName": "test-domain3-104", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain3-104", + "Created": true, + "Deleted": false, + "Endpoint": "search-test-domain3-104-oqrea5hh2cok7twvowby43f3iy.us-east-1.es.amazonaws.com", + "Processing": false, + "UpgradeProcessing": false, + "ElasticsearchVersion": "7.7", + "ElasticsearchClusterConfig": { + "InstanceType": "t2.small.elasticsearch", + "InstanceCount": 1, + "DedicatedMasterEnabled": false, + "ZoneAwarenessEnabled": false, + "WarmEnabled": false + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeType": "gp2", + "VolumeSize": 10 + }, + "SnapshotOptions": {}, + "CognitoOptions": { + "Enabled": false + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "AdvancedOptions": { + "rest.action.multi.allow_explicit_index": "true" + }, + "ServiceSoftwareOptions": { + "CurrentVersion": "R20200721", + "NewVersion": "", + "UpdateAvailable": false, + "Cancellable": false, + "UpdateStatus": "COMPLETED", + "Description": "There is no software update available for this domain.", + "AutomatedUpdateDate": 0.0, + "OptionalDeployment": true + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "AdvancedSecurityOptions": { + "Enabled": false, + "InternalUserDatabaseEnabled": false + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain2-104", + "DomainName": "test-domain2-104", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain2-104", + "Created": true, + "Deleted": false, + "Endpoints": { + "vpc": "vpc-test-domain2-104-zekicf2qfhcvve2x4letx66rcm.us-east-1.es.amazonaws.com" + }, + "Processing": false, + "UpgradeProcessing": false, + "ElasticsearchVersion": "7.7", + "ElasticsearchClusterConfig": { + "InstanceType": "t2.small.elasticsearch", + "InstanceCount": 1, + "DedicatedMasterEnabled": false, + "ZoneAwarenessEnabled": false, + "WarmEnabled": false + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeType": "gp2", + "VolumeSize": 10 + }, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::1123456654321:role/service-role/AmazonComprehendServiceRole-akhtar-comprehend-role\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:1123456654321:domain/test-domain2-104/*\"}]}", + "SnapshotOptions": {}, + "VPCOptions": { + "VPCId": "vpc-99de2fe4", + "SubnetIds": [ + "subnet-6a8b635b" + ], + "AvailabilityZones": [ + "us-east-1e" + ], + "SecurityGroupIds": [ + "sg-001639e564442dfec" + ] + }, + "CognitoOptions": { + "Enabled": false + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "AdvancedOptions": { + "rest.action.multi.allow_explicit_index": "true" + }, + "ServiceSoftwareOptions": { + "CurrentVersion": "R20200721", + "NewVersion": "", + "UpdateAvailable": false, + "Cancellable": false, + "UpdateStatus": "COMPLETED", + "Description": "There is no software update available for this domain.", + "AutomatedUpdateDate": 0.0, + "OptionalDeployment": true + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "AdvancedSecurityOptions": { + "Enabled": false, + "InternalUserDatabaseEnabled": false + } + } + } +]; + +const createCache = (domainNames, domains) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + describeDomain: { + 'us-east-1': { + [name]: { + data: domains + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('osExposedDomain', function () { + describe('run', function () { + it('should FAIL if domain is exposed to all AWS accounts', function (done) { + const cache = createCache([domainNames[1]], domains[1]); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if domain is not exposed to all AWS accounts', function (done) { + const cache = createCache([domainNames[1]], domains[2]); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if no access policy found', function (done) { + const cache = createCache([domainNames[2]], domains[2]); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([]); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error listing domain names', function (done) { + const cache = createErrorCache(); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for domain names', function (done) { + const cache = createNullCache(); + osExposedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchHttpsOnly.js b/plugins/aws/opensearch/opensearchHttpsOnly.js new file mode 100644 index 000000000..062216a16 --- /dev/null +++ b/plugins/aws/opensearch/opensearchHttpsOnly.js @@ -0,0 +1,146 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch HTTPS Only', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures OpenSearch domains are configured to enforce HTTPS connections', + more_info: 'OpenSearch domains should be configured to enforce HTTPS connections for all clients to ensure encryption of data in transit.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html', + recommended_action: 'Ensure HTTPS connections are enforced for all OpenSearch domains.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + remediation_description: 'OpenSearch domain will be configured to enforce HTTPS.', + remediation_min_version: '202011271930', + apis_remediate: ['OpenSearch:listDomainNames'], + remediation_inputs: { + tlsSecurityPolicyforOpenSearch: { + name: '(Optional) TLS Security Policy', + description: 'The TLS Security Policy that needs to be applied to the HTTPS endpoint', + regex: '^.*$', + required: false + } + }, + actions: { + remediate: ['OpenSearch:updateDomainConfig'], + rollback: ['OpenSearch:updateDomainConfig'] + }, + permissions: { + remediate: ['opensearch:UpdateDomainConfig'], + rollback: ['opensearch:UpdateDomainConfig'] + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + + if (localDomain.DomainEndpointOptions && + localDomain.DomainEndpointOptions.EnforceHTTPS) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to enforce HTTPS', region, localDomain.ARN); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not configured to enforce HTTPS', region, localDomain.ARN); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'opensearchHttpsOnly'; + var domainNameArr = resource.split(':'); + var domain = domainNameArr[domainNameArr.length - 1].split('/')[1]; + + // find the location of the domain needing to be remediated + var domainLocation = domainNameArr[3]; + + // add the location of the domain to the config + config.region = domainLocation; + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.tlsSecurityPolicyforEs) { + params = { + DomainName: domain, + DomainEndpointOptions: { + EnforceHTTPS: true, + TLSSecurityPolicy: settings.input.tlsSecurityPolicyforEs + }, + }; + } else { + params = { + DomainName: domain, + DomainEndpointOptions: { + EnforceHTTPS: true, + TLSSecurityPolicy: 'Policy-Min-TLS-1-2-2019-07' + }, + }; + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Enforce HTTPS': 'Disabled', + 'OpenSearch': resource + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enforce HTTPS', + 'OpenSearch': domain + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; diff --git a/plugins/aws/es/esHttpsOnly.spec.js b/plugins/aws/opensearch/opensearchHttpsOnly.spec.js similarity index 75% rename from plugins/aws/es/esHttpsOnly.spec.js rename to plugins/aws/opensearch/opensearchHttpsOnly.spec.js index 6306a9321..6edd960aa 100644 --- a/plugins/aws/es/esHttpsOnly.spec.js +++ b/plugins/aws/opensearch/opensearchHttpsOnly.spec.js @@ -1,17 +1,16 @@ -var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esHttpsOnly'); +var es = require('./opensearchHttpsOnly'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +22,13 @@ const createCache = (listData, descData) => { } }; -describe('esHttpsOnly', function () { +describe('osHttpsOnly', function () { describe('run', function () { - it('should give passing result if no ES domains present', function (done) { + it('should give passing result if no OpenSearch domains present', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +40,11 @@ describe('esHttpsOnly', function () { es.run(cache, {}, callback); }) - it('should give error result if ES domain is not configured to enforce HTTPS', function (done) { + it('should give error result if OpenSearch domain is not configured to enforce HTTPS', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is not configured to enforce HTTPS') + expect(results[0].message).to.include('OpenSearch domain is not configured to enforce HTTPS') done() }; @@ -69,11 +68,11 @@ describe('esHttpsOnly', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES domain is configured to enforce HTTPS', function (done) { + it('should give passing result if OpenSearch domain is configured to enforce HTTPS', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to enforce HTTPS') + expect(results[0].message).to.include('OpenSearch domain is configured to enforce HTTPS') done() }; diff --git a/plugins/aws/es/esLoggingEnabled.js b/plugins/aws/opensearch/opensearchLoggingEnabled.js similarity index 57% rename from plugins/aws/es/esLoggingEnabled.js rename to plugins/aws/opensearch/opensearchLoggingEnabled.js index 44b15d965..16e0aa5c7 100644 --- a/plugins/aws/es/esLoggingEnabled.js +++ b/plugins/aws/opensearch/opensearchLoggingEnabled.js @@ -2,50 +2,53 @@ var async = require('async'); var helpers = require('../../../helpers/aws'); module.exports = { - title: 'ElasticSearch Logging Enabled', - category: 'ES', - description: 'Ensures ElasticSearch domains are configured to log data to CloudWatch', - more_info: 'ElasticSearch domains should be configured with logging enabled with logs sent to CloudWatch for analysis and long-term storage.', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-createdomain-configure-slow-logs', - recommended_action: 'Ensure logging is enabled and a CloudWatch log group is specified for each ElasticSearch domain.', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], + title: 'OpenSearch Logging Enabled', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures OpenSearch domains are configured to log data to CloudWatch', + more_info: 'OpenSearch domains should be configured with logging enabled with logs sent to CloudWatch for analysis and long-term storage.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createdomain-configure-slow-logs.html', + recommended_action: 'Ensure logging is enabled and a CloudWatch log group is specified for each OpenSearch domain.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); - async.each(regions.es, function(region, rcb) { + async.each(regions.opensearch, function(region, rcb) { var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); + ['opensearch', 'listDomainNames', region]); if (!listDomainNames) return rcb(); if (listDomainNames.err || !listDomainNames.data) { helpers.addResult( results, 3, - 'Unable to query for ES domains: ' + helpers.addError(listDomainNames), region); + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); return rcb(); } if (!listDomainNames.data.length){ - helpers.addResult(results, 0, 'No ES domains found', region); + helpers.addResult(results, 0, 'No OpenSearch domains found', region); return rcb(); } listDomainNames.data.forEach(function(domain){ - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data || - !describeElasticsearchDomain.data.DomainStatus) { + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { helpers.addResult( results, 3, - 'Unable to query for ES domain config: ' + helpers.addError(describeElasticsearchDomain), region); + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; + var localDomain = describeDomain.data.DomainStatus; var logSelectionArr = ['SEARCH_SLOW_LOGS', 'INDEX_SLOW_LOGS', 'ES_APPLICATION_LOGS']; var cloudWatchDisabled = []; if (localDomain.LogPublishingOptions && @@ -64,20 +67,19 @@ module.exports = { } if (!logSelectionArr.length) { helpers.addResult(results, 0, - 'ES domain logging is enabled and sending logs to CloudWatch', region, localDomain.ARN); + 'OpenSearch domain logging is enabled and sending logs to CloudWatch', region, localDomain.ARN); } else if (cloudWatchDisabled.length) { let logStr = cloudWatchDisabled.join(', ').replace(/_/g, ' '); helpers.addResult(results, 2, - `ES domain logging is enabled but logs are not configured to be sent to CloudWatch for: ${logStr}`, region, localDomain.ARN); - } - else { + `OpenSearch domain logging is enabled but logs are not configured to be sent to CloudWatch for: ${logStr}`, region, localDomain.ARN); + } else { let logStr = logSelectionArr.join(', ').replace(/_/g, ' '); helpers.addResult(results, 2, - `The following logs are not configured for the ES domain: ${logStr}`, region, localDomain.ARN); + `The following logs are not configured for the OpenSearch domain: ${logStr}`, region, localDomain.ARN); } } else { helpers.addResult(results, 2, - 'ES domain logging is not enabled', region, localDomain.ARN); + 'OpenSearch domain logging is not enabled', region, localDomain.ARN); } } }); diff --git a/plugins/aws/es/esLoggingEnabled.spec.js b/plugins/aws/opensearch/opensearchLoggingEnabled.spec.js similarity index 80% rename from plugins/aws/es/esLoggingEnabled.spec.js rename to plugins/aws/opensearch/opensearchLoggingEnabled.spec.js index 80b57c10f..1a2dc4def 100644 --- a/plugins/aws/es/esLoggingEnabled.spec.js +++ b/plugins/aws/opensearch/opensearchLoggingEnabled.spec.js @@ -1,17 +1,16 @@ -var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esLoggingEnabled'); +var es = require('./opensearchLoggingEnabled'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +22,13 @@ const createCache = (listData, descData) => { } }; -describe('esLoggingEnabled', function () { +describe('osLoggingEnabled', function () { describe('run', function () { - it('should give passing result if no ES domains present', function (done) { + it('should give passing result if no opensearch domains present', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +40,11 @@ describe('esLoggingEnabled', function () { es.run(cache, {}, callback); }) - it('should give error result if ES logging is disabled', function (done) { + it('should give error result if OpenSearch logging is disabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('The following logs are not configured for the ES domain') + expect(results[0].message).to.include('The following logs are not configured for the OpenSearch domain') done() }; @@ -71,10 +70,10 @@ describe('esLoggingEnabled', function () { es.run(cache, {}, callback); }) - it('should give failing result if ES logging is enabled without CloudWatch', function (done) { + it('should give failing result if OpenSearch logging is enabled without CloudWatch', function (done) { const callback = (err, results) => { expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain logging is enabled but logs are not configured to be sent to CloudWatch') + expect(results[0].message).to.include('OpenSearch domain logging is enabled but logs are not configured to be sent to CloudWatch') done() }; @@ -105,11 +104,11 @@ describe('esLoggingEnabled', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES logging is enabled', function (done) { + it('should give passing result if OpenSearch logging is enabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain logging is enabled and sending logs to CloudWatch') + expect(results[0].message).to.include('OpenSearch domain logging is enabled and sending logs to CloudWatch') done() }; diff --git a/plugins/aws/opensearch/opensearchNodeToNodeEncryption.js b/plugins/aws/opensearch/opensearchNodeToNodeEncryption.js new file mode 100644 index 000000000..0f798ef31 --- /dev/null +++ b/plugins/aws/opensearch/opensearchNodeToNodeEncryption.js @@ -0,0 +1,125 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Node To Node Encryption', + category: 'OpenSearch', + domain: 'Databases', + severity: 'High', + description: 'Ensures OpenSearch domain traffic is encrypted in transit between nodes', + more_info: 'OpenSearch domains should use node-to-node encryption to ensure data in transit remains encrypted using TLS 1.2.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ntn.html', + recommended_action: 'Ensure node-to-node encryption is enabled for all OpenSearch domains.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + remediation_description: 'OpenSearch domain will be configured to use node-to-node encryption.', + remediation_min_version: '202102152200', + apis_remediate: ['OpenSearch:listDomainNames'], + actions: { + remediate: ['OpenSearch:updateDomainConfig'], + rollback: ['OpenSearch:updateDomainConfig'] + }, + permissions: { + remediate: ['opensearch:UpdateDomainConfig'], + rollback: ['opensearch:UpdateDomainConfig'] + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + + if (localDomain.NodeToNodeEncryptionOptions && + localDomain.NodeToNodeEncryptionOptions.Enabled) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to use node-to-node encryption', region, localDomain.ARN); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not configured to use node-to-node encryption', region, localDomain.ARN); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'opensearchNodeToNodeEncryption'; + var domainNameArr = resource.split(':'); + var domain = domainNameArr[domainNameArr.length - 1].split('/')[1]; + + // find the location of the domain needing to be remediated + var domainLocation = domainNameArr[3]; + + // add the location of the domain to the config + config.region = domainLocation; + + // create the params necessary for the remediation + var params = { + DomainName: domain, + NodeToNodeEncryptionOptions: { + Enabled: true + }, + }; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'NodeToNodeEncryption': 'Disabled', + 'OpenSearch': resource + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'NodeToNodeENCRYPTED', + 'OpenSearch': domain + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; diff --git a/plugins/aws/es/esNodeToNodeEncryption.spec.js b/plugins/aws/opensearch/opensearchNodeToNodeEncryption.spec.js similarity index 74% rename from plugins/aws/es/esNodeToNodeEncryption.spec.js rename to plugins/aws/opensearch/opensearchNodeToNodeEncryption.spec.js index a4ec7dec6..91763e7a6 100644 --- a/plugins/aws/es/esNodeToNodeEncryption.spec.js +++ b/plugins/aws/opensearch/opensearchNodeToNodeEncryption.spec.js @@ -1,17 +1,16 @@ -var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esNodeToNodeEncryption'); +var es = require('./opensearchNodeToNodeEncryption'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +22,13 @@ const createCache = (listData, descData) => { } }; -describe('esNodeToNodeEncryption', function () { +describe('osNodeToNodeEncryption', function () { describe('run', function () { - it('should give passing result if no ES domains present', function (done) { + it('should give passing result if no OpenSearch domains present', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +40,11 @@ describe('esNodeToNodeEncryption', function () { es.run(cache, {}, callback); }) - it('should give error result if ES encryption config is disabled', function (done) { + it('should give error result if OpenSearch encryption config is disabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is not configured to use node-to-node encryption') + expect(results[0].message).to.include('OpenSearch domain is not configured to use node-to-node encryption') done() }; @@ -69,11 +68,11 @@ describe('esNodeToNodeEncryption', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES encryption config is enabled', function (done) { + it('should give passing result if OpenSearch encryption config is enabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use node-to-node encryption') + expect(results[0].message).to.include('OpenSearch domain is configured to use node-to-node encryption') done() }; diff --git a/plugins/aws/opensearch/opensearchPublicEndpoint.js b/plugins/aws/opensearch/opensearchPublicEndpoint.js new file mode 100644 index 000000000..9e1f00f5d --- /dev/null +++ b/plugins/aws/opensearch/opensearchPublicEndpoint.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Public Service Domain', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures OpenSearch domains are created with private VPC endpoint options', + more_info: 'OpenSearch domains can either be created with a public endpoint or with a VPC configuration that enables internal VPC communication. Domains should be created without a public endpoint to prevent potential public access to the domain.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/os-vpc.html', + recommended_action: 'Configure the OpenSearch domain to use a VPC endpoint for secure VPC communication.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + settings: { + allow_os_public_endpoint_if_ip_condition_policy: { + name: 'Allow Public Only If IP Condition Policy or Restricted Principal', + description: 'Allows public OpenSearch endpoints if set to true and if there is an IP Condition policy and/or a restricted non-star principal.', + regex: '^(true|false)$', + default: 'false' + }, + }, + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var config = { + allow_os_public_endpoint_if_ip_condition_policy: settings.allow_os_public_endpoint_if_ip_condition_policy || this.settings.allow_os_public_endpoint_if_ip_condition_policy.default + }; + + config.allow_os_public_endpoint_if_ip_condition_policy = (config.allow_os_public_endpoint_if_ip_condition_policy === 'true' || config.allow_os_public_endpoint_if_ip_condition_policy === true); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + + // assume we have no bad policies + var validPolicy = true; + + if (config.allow_os_public_endpoint_if_ip_condition_policy && + localDomain.AccessPolicies) { // evaluate policies if the setting is enabled. + var policies = helpers.normalizePolicyDocument(localDomain.AccessPolicies); + if (!policies) policies = []; // if no policy document then no statements + + for (var p in policies) { + var policy = policies[p]; + var containsIpPolicy = policy.Condition && policy.Condition.IpAddress; + + if (!containsIpPolicy && helpers.globalPrincipal(policy.Principal, settings)) { + validPolicy = false; + } + } + } + + if (localDomain.VPCOptions && + localDomain.VPCOptions.VPCId && + localDomain.VPCOptions.VPCId.length) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to use a VPC endpoint', region, localDomain.ARN); + } else { + if (config.allow_os_public_endpoint_if_ip_condition_policy) { + if (validPolicy) { + helpers.addResult(results, 0, + 'OpenSearch domain is configured to use a public endpoint, but is allowed since there are no public access policies.', region, localDomain.ARN); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is configured to use a public endpoint and has disallowed public access policies.', region, localDomain.ARN); + } + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is configured to use a public endpoint.', region, localDomain.ARN); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/es/esPublicEndpoint.spec.js b/plugins/aws/opensearch/opensearchPublicEndpoint.spec.js similarity index 76% rename from plugins/aws/es/esPublicEndpoint.spec.js rename to plugins/aws/opensearch/opensearchPublicEndpoint.spec.js index 96bf9f6d8..28e4d1070 100644 --- a/plugins/aws/es/esPublicEndpoint.spec.js +++ b/plugins/aws/opensearch/opensearchPublicEndpoint.spec.js @@ -1,17 +1,17 @@ var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esPublicEndpoint'); +var es = require('./opensearchPublicEndpoint'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +23,13 @@ const createCache = (listData, descData) => { } }; -describe('esPublicEndpoint', function () { +describe('osPublicEndpoint', function () { describe('run', function () { - it('should give passing result if no ES domains present with no settings.', function (done) { + it('should give passing result if no OpenSearch domains present with no settings.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +41,11 @@ describe('esPublicEndpoint', function () { es.run(cache, {}, callback); }) - it('should give passing result if no ES domains present with setting set to true. ', function (done) { + it('should give passing result if no OpenSearch domains present with setting set to true. ', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -54,14 +54,14 @@ describe('esPublicEndpoint', function () { {} ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) - it('should give passing result if no ES domains present with setting set to false.', function (done) { + it('should give passing result if no OpenSearch domains present with setting set to false.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -70,14 +70,14 @@ describe('esPublicEndpoint', function () { {} ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) - it('should give error result if ES VPC config is disabled', function (done) { + it('should give error result if OpenSearch VPC config is disabled', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -99,11 +99,11 @@ describe('esPublicEndpoint', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES VPC config is disabled with no access policy and setting set to true ', function (done) { + it('should give passing result if OpenSearch VPC config is disabled with no access policy and setting set to true ', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint, but is allowed since there are no public access policies.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint, but is allowed since there are no public access policies.') done() }; @@ -122,14 +122,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) - it('should give error result if ES VPC config is disabled without access policy and setting set to false ', function (done) { + it('should give error result if OpenSearch VPC config is disabled without access policy and setting set to false ', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -148,14 +148,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) - it('should give passing result if ES VPC config is enabled with no settings.', function (done) { + it('should give passing result if OpenSearch VPC config is enabled with no settings.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a VPC endpoint') + expect(results[0].message).to.include('OpenSearch domain is configured to use a VPC endpoint') done() }; @@ -179,11 +179,11 @@ describe('esPublicEndpoint', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES VPC config is enabled with setting set to true.', function (done) { + it('should give passing result if OpenSearch VPC config is enabled with setting set to true.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a VPC endpoint') + expect(results[0].message).to.include('OpenSearch domain is configured to use a VPC endpoint') done() }; @@ -204,14 +204,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) - it('should give passing result if ES VPC config is enabled with setting set to false.', function (done) { + it('should give passing result if OpenSearch VPC config is enabled with setting set to false.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a VPC endpoint') + expect(results[0].message).to.include('OpenSearch domain is configured to use a VPC endpoint') done() }; @@ -232,14 +232,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) it('should give passing result if Ip condition setting is passed', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint, but is allowed since there are no public access policies.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint, but is allowed since there are no public access policies.') done() }; @@ -279,14 +279,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) it('should give failing result if Ip condition setting is passed, but public policy not allowed', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -326,14 +326,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) it('should give failing result if Ip condition setting is passed, but no setting passed', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -380,7 +380,7 @@ describe('esPublicEndpoint', function () { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint and has disallowed public access policies.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint and has disallowed public access policies.') done() }; @@ -411,14 +411,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) it('should give failing result if no Ip condition setting is passed, but setting set to false.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -451,14 +451,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) it('should give failing result if no Ip condition setting is passed, but no setting passed.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -496,7 +496,7 @@ describe('esPublicEndpoint', function () { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint, but is allowed since there are no public access policies.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint, but is allowed since there are no public access policies.') done() }; @@ -529,14 +529,14 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: true}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: true}, callback); }) it('should give failing result if no Ip condition setting is passed but a valid principal is, with setting set to false.', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain is configured to use a public endpoint.') + expect(results[0].message).to.include('OpenSearch domain is configured to use a public endpoint.') done() }; @@ -569,7 +569,7 @@ describe('esPublicEndpoint', function () { } ); - es.run(cache, {allow_es_public_endpoint_if_ip_condition_policy: false}, callback); + es.run(cache, {allow_os_public_endpoint_if_ip_condition_policy: false}, callback); }) }) }) \ No newline at end of file diff --git a/plugins/aws/es/esRequireIAMAuth.js b/plugins/aws/opensearch/opensearchRequireIAMAuth.js similarity index 50% rename from plugins/aws/es/esRequireIAMAuth.js rename to plugins/aws/opensearch/opensearchRequireIAMAuth.js index 007477c3a..e8392da20 100644 --- a/plugins/aws/es/esRequireIAMAuth.js +++ b/plugins/aws/opensearch/opensearchRequireIAMAuth.js @@ -2,76 +2,79 @@ var async = require('async'); var helpers = require('../../../helpers/aws'); module.exports = { - title: 'ElasticSearch IAM Authentication', - category: 'ES', - description: 'Ensures ElasticSearch domains require IAM Authentication', - more_info: 'ElasticSearch domains can allow access without IAM authentication by having a policy that does not specify the principal or has a wildcard principal', - link: 'https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-ac.html', - recommended_action: 'Configure the ElasticSearch domain to have an access policy without a global principal or no principal', - apis: ['ES:listDomainNames', 'ES:describeElasticsearchDomain'], + title: 'OpenSearch IAM Authentication', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Low', + description: 'Ensures OpenSearch domains require IAM Authentication', + more_info: 'OpenSearch domains can allow access without IAM authentication by having a policy that does not specify the principal or has a wildcard principal', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html', + recommended_action: 'Configure the OpenSearch domain to have an access policy without a global principal or no principal', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); - async.each(regions.es, function(region, rcb) { + async.each(regions.opensearch, function(region, rcb) { var listDomainNames = helpers.addSource(cache, source, - ['es', 'listDomainNames', region]); + ['opensearch', 'listDomainNames', region]); if (!listDomainNames) return rcb(); if (listDomainNames.err || !listDomainNames.data) { helpers.addResult( results, 3, - 'Unable to query for ElasticSearch domains: ' + helpers.addError(listDomainNames), region); + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); return rcb(); } if (!listDomainNames.data.length) { - helpers.addResult(results, 0, 'No ElasticSearch domains found', region); + helpers.addResult(results, 0, 'No OpenSearch domains found', region); return rcb(); } listDomainNames.data.forEach(function(domain) { - var describeElasticsearchDomain = helpers.addSource(cache, source, - ['es', 'describeElasticsearchDomain', region, domain.DomainName]); + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); - if (!describeElasticsearchDomain || - describeElasticsearchDomain.err || - !describeElasticsearchDomain.data) { + if (!describeDomain || + describeDomain.err || + !describeDomain.data) { helpers.addResult( results, 3, - 'Unable to query for ElasticSearch domain config: ' + helpers.addError(describeElasticsearchDomain), region); + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); } else { - if (!describeElasticsearchDomain.data.DomainStatus) { + if (!describeDomain.data.DomainStatus) { helpers.addResult(results, 0, - 'ElasticSearch domain has no access policies', region, localDomain.ARN); + 'OpenSearch domain has no access policies', region, localDomain.ARN); } else { - var localDomain = describeElasticsearchDomain.data.DomainStatus; + var localDomain = describeDomain.data.DomainStatus; var policies = helpers.normalizePolicyDocument(localDomain.AccessPolicies); if (!policies || !policies.length) { helpers.addResult(results, 0, - 'ElasticSearch domain has no access policies', region, localDomain.ARN); + 'OpenSearch domain has no access policies', region, localDomain.ARN); } else { var found = []; for (var p in policies) { var policy = policies[p]; if (policy.Effect && policy.Effect == 'Allow' && !policy.Principal) { found.push(policy); - } else if (policy.Effect && policy.Effect == 'Allow' && helpers.globalPrincipal(policy.Principal)) { + } else if (policy.Effect && policy.Effect == 'Allow' && helpers.globalPrincipal(policy.Principal, settings)) { found.push(policy); } } if (found.length > 0) { helpers.addResult(results, 2, - 'ElasticSearch domain has policy that does not require IAM authentication', region, localDomain.ARN); + 'OpenSearch domain has policy that does not require IAM authentication', region, localDomain.ARN); } else { helpers.addResult(results, 0, - 'ElasticSearch domain access policies require IAM authentication', region, localDomain.ARN); + 'OpenSearch domain access policies require IAM authentication', region, localDomain.ARN); } } diff --git a/plugins/aws/es/esRequireIAMAuth.spec.js b/plugins/aws/opensearch/opensearchRequireIAMAuth.spec.js similarity index 88% rename from plugins/aws/es/esRequireIAMAuth.spec.js rename to plugins/aws/opensearch/opensearchRequireIAMAuth.spec.js index 436e3ea6e..ad0f732ef 100644 --- a/plugins/aws/es/esRequireIAMAuth.spec.js +++ b/plugins/aws/opensearch/opensearchRequireIAMAuth.spec.js @@ -1,17 +1,17 @@ var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esRequireIAMAuth'); +var es = require('./opensearchRequireIAMAuth'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +23,13 @@ const createCache = (listData, descData) => { } }; -describe('esPublicEndpoint', function() { +describe('osPublicEndpoint', function() { describe('run', function() { - it('should give passing result if no ES domains present', function(done) { + it('should give passing result if no OpenSearch domains present', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ElasticSearch domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -45,7 +45,7 @@ describe('esPublicEndpoint', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ElasticSearch domain has no access policies') + expect(results[0].message).to.include('OpenSearch domain has no access policies') done() }; @@ -69,7 +69,7 @@ describe('esPublicEndpoint', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ElasticSearch domain has policy that does not require IAM authentication') + expect(results[0].message).to.include('OpenSearch domain has policy that does not require IAM authentication') done() }; @@ -115,7 +115,7 @@ describe('esPublicEndpoint', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ElasticSearch domain has policy that does not require IAM authentication') + expect(results[0].message).to.include('OpenSearch domain has policy that does not require IAM authentication') done() }; @@ -158,7 +158,7 @@ describe('esPublicEndpoint', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ElasticSearch domain access policies require IAM authentication') + expect(results[0].message).to.include('OpenSearch domain access policies require IAM authentication') done() }; diff --git a/plugins/aws/opensearch/opensearchTlsVersion.js b/plugins/aws/opensearch/opensearchTlsVersion.js new file mode 100644 index 000000000..e2a67c8ba --- /dev/null +++ b/plugins/aws/opensearch/opensearchTlsVersion.js @@ -0,0 +1,80 @@ +const async = require('async'); +const helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch TLS Version', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure OpenSearch domain is using the latest security policy to only allow TLS v1.2', + more_info: 'OpenSearch domains should be configured to enforce TLS version 1.2 for all clients to ensure encryption of data in transit with updated features.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html', + recommended_action: 'Update OpenSearch domain to set TLSSecurityPolicy to contain TLS version 1.2.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + + const acctRegion = helpers.defaultRegion(settings); + const accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + const awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + const listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + async.each(listDomainNames.data, function(domain, cb){ + if (!domain.DomainName) return cb(); + + const describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region, resource); + return cb(); + } + if (describeDomain.data.DomainStatus.DomainEndpointOptions && + describeDomain.data.DomainStatus.DomainEndpointOptions.TLSSecurityPolicy && + describeDomain.data.DomainStatus.DomainEndpointOptions.TLSSecurityPolicy == 'Policy-Min-TLS-1-2-2019-07') { + helpers.addResult(results, 0, + 'OpenSearch domain is using TLS version 1.2', region, resource); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain is not using TLS version 1.2', region, resource); + } + + cb(); + }, function() { + rcb(); + }); + + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/opensearch/opensearchTlsVersion.spec.js b/plugins/aws/opensearch/opensearchTlsVersion.spec.js new file mode 100644 index 000000000..3337d3a54 --- /dev/null +++ b/plugins/aws/opensearch/opensearchTlsVersion.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var osTlsVersion = require('./opensearchTlsVersion'); + +const domainNames = [ + { + "DomainName": "test-domain" + } +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain", + "DomainName": "test-domain", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain", + "DomainEndpointOptions": { + "EnforceHTTPS": true, + "TLSSecurityPolicy": 'Policy-Min-TLS-1-2-2019-07', + "CustomEndpointEnabled": false, + "CustomEndpoint": null, + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain", + "DomainName": "test-domain", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain", + "DomainEndpointOptions": { + "EnforceHTTPS": true, + "TLSSecurityPolicy": 'Policy-Min-TLS-1-0-2019-07', + "CustomEndpointEnabled": false, + "CustomEndpoint": null, + } + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain", + "DomainName": "test-domain", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain", + "DomainEndpointOptions": { + "EnforceHTTPS": true, + "CustomEndpointEnabled": false, + "CustomEndpoint": null, + } + } + } +]; + +const createCache = (domainNames, domains) => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: null, + data: domainNames + } + }, + describeDomain: { + 'us-east-1': { + 'test-domain': { + err: null, + data: domains + } + } + } + } + } +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + es: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('osTlsVersion', function () { + describe('run', function () { + it('should PASS if OpenSearch domains have TLS version 1.2', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if OpenSearch domains do not have TLS version 1.2', function (done) { + const cache = createCache([domainNames[0]], domains[1]); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if OpenSearch domain does not have TLSSecurityPolicy property', function (done) { + const cache = createCache([domainNames[0]], domains[2]); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([], {}); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if there was an error listing domain names', function (done) { + const cache = createErrorCache(); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to query for domain names', function (done) { + const cache = createNullCache(); + osTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/opensearch/opensearchUpgradeAvailable.js b/plugins/aws/opensearch/opensearchUpgradeAvailable.js new file mode 100644 index 000000000..9f15adf8c --- /dev/null +++ b/plugins/aws/opensearch/opensearchUpgradeAvailable.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Upgrade Available', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures OpenSearch domains are running the latest service software', + more_info: 'OpenSearch domains should be configured to run the latest service software which often contains security updates.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/version-migration.html', + recommended_action: 'Ensure each OpenSearch domain is running the latest service software and update out-of-date domains.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + var currentVersion = localDomain.ServiceSoftwareOptions.CurrentVersion; + var upgradeVersion = localDomain.ServiceSoftwareOptions.NewVersion; + var upgradeAvailable = localDomain.ServiceSoftwareOptions.UpdateAvailable; + var upgradeStatus = localDomain.ServiceSoftwareOptions.UpdateStatus; + + if (upgradeAvailable && upgradeStatus !== 'NOT_ELIGIBLE') { + helpers.addResult(results, 2, + 'OpenSearch domain service software version: ' + currentVersion + ' is eligible for an upgrade to version: ' + upgradeVersion, region, localDomain.ARN); + } else { + helpers.addResult(results, 0, + 'OpenSearch domain service software version: ' + currentVersion + ' is the latest eligible upgraded version', region, localDomain.ARN); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/es/esUpgradeAvailable.spec.js b/plugins/aws/opensearch/opensearchUpgradeAvailable.spec.js similarity index 75% rename from plugins/aws/es/esUpgradeAvailable.spec.js rename to plugins/aws/opensearch/opensearchUpgradeAvailable.spec.js index 350dca935..faa460efa 100644 --- a/plugins/aws/es/esUpgradeAvailable.spec.js +++ b/plugins/aws/opensearch/opensearchUpgradeAvailable.spec.js @@ -1,17 +1,17 @@ var assert = require('assert'); var expect = require('chai').expect; -var es = require('./esUpgradeAvailable'); +var es = require('./opensearchUpgradeAvailable'); const createCache = (listData, descData) => { return { - es: { + opensearch: { listDomainNames: { 'us-east-1': { err: null, data: listData } }, - describeElasticsearchDomain: { + describeDomain: { 'us-east-1': { 'mydomain': { err: null, @@ -23,13 +23,13 @@ const createCache = (listData, descData) => { } }; -describe('esUpgradeAvailable', function () { +describe('osUpgradeAvailable', function () { describe('run', function () { - it('should give passing result if no ES domains present', function (done) { + it('should give passing result if no opensearch domains present', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('No ES domains found') + expect(results[0].message).to.include('No OpenSearch domains found') done() }; @@ -41,11 +41,11 @@ describe('esUpgradeAvailable', function () { es.run(cache, {}, callback); }) - it('should give error result if ES domain upgrade is available', function (done) { + it('should give error result if OpenSearch domain upgrade is available', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('ES domain service software version: 1 is eligible for an upgrade to version: 2') + expect(results[0].message).to.include('OpenSearch domain service software version: 1 is eligible for an upgrade to version: 2') done() }; @@ -72,11 +72,11 @@ describe('esUpgradeAvailable', function () { es.run(cache, {}, callback); }) - it('should give passing result if ES domain is not upgrade eligible', function (done) { + it('should give passing result if OpenSearch domain is not upgrade eligible', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('ES domain service software version: 1 is the latest eligible upgraded version') + expect(results[0].message).to.include('OpenSearch domain service software version: 1 is the latest eligible upgraded version') done() }; diff --git a/plugins/aws/opensearch/opensearchVersion.js b/plugins/aws/opensearch/opensearchVersion.js new file mode 100644 index 000000000..45ecef415 --- /dev/null +++ b/plugins/aws/opensearch/opensearchVersion.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Version', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures OpenSearch domains are using the latest engine version.', + more_info: 'OpenSearch domains should be upgraded to the latest version for optimal performance and security.', + link: 'https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html', + recommended_action: 'Update OpenSearch domain to set to latest engine version.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run:function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domains: ' + helpers.addError(listDomainNames), region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + listDomainNames.data.forEach(function(domain){ + var describeDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeDomain || + describeDomain.err || + !describeDomain.data || + !describeDomain.data.DomainStatus) { + helpers.addResult( + results, 3, + 'Unable to query for OpenSearch domain config: ' + helpers.addError(describeDomain), region); + } else { + var localDomain = describeDomain.data.DomainStatus; + var currentVersion = localDomain && localDomain.EngineVersion? localDomain.EngineVersion: ''; + + if ((currentVersion.includes('Elasticsearch') && currentVersion.includes('7.10')) || (currentVersion.includes('OpenSearch') && currentVersion.includes('2.5'))) { + helpers.addResult(results, 0, + 'OpenSearch domain is running the latest version', region, localDomain.ARN); + } else { + helpers.addResult(results, 2, + 'OpenSearch domain should be upgraded to latest version', region, localDomain.ARN); + } + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/opensearch/opensearchVersion.spec.js b/plugins/aws/opensearch/opensearchVersion.spec.js new file mode 100644 index 000000000..3550bb85a --- /dev/null +++ b/plugins/aws/opensearch/opensearchVersion.spec.js @@ -0,0 +1,145 @@ + +var expect = require('chai').expect; +var es = require('./opensearchVersion'); + +const createCache = (listData, descData) => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: null, + data: listData + } + }, + describeDomain: { + 'us-east-1': { + 'mydomain': { + err: null, + data: descData + } + } + } + } + } +}; +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': null, + }, + }, + }; +}; + + + describe('osVersion', function () { + describe('run', function () { + it('should give passing result if no opensearch domains present', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OpenSearch domains found'); + expect(results[0].region).to.equal('us-east-1'); + done() + }; + + const cache = createCache( + [], + {} + ); + + es.run(cache, {}, callback); + }) + it('should give passing result the version of opensearch is engine version is 7.10', function (done) { + const callback = (err, results) => { + + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenSearch domain is running the latest version'); + expect(results[0].region).to.equal('us-east-1'); + done() + }; + + const cache = createCache( + [ + { + DomainName: 'mydomain' + } + ], + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EngineVersion: 'Elasticsearch_7.10.' + }} + ); + + es.run(cache, {}, callback); + }) + + it('should give error result if OpenSearch domain is not running the latest version', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenSearch domain should be upgraded to latest version'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }; + + const cache = createCache( + [ + { + DomainName: 'mydomain' + } + ], + { + DomainStatus: { + DomainName: 'mydomain', + ARN: 'arn:1234', + EngineVersion: 'OpenSearch_1.5' + } + } + ); + + es.run(cache, {}, callback); + }) + it('should UNKNOWN if there was an error listing domain names', function (done) { + const callback= (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for OpenSearch domains'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }; + + const cache = createErrorCache(); + es.run(cache,{},callback); + }); + it('should not return any results if unable to query for domain names', function (done) { + const callback= (err,results)=>{ + expect(results.length).to.equal(0); + done(); + }; + + const cache = createNullCache(); + es.run(cache,{},callback); + + }); + }); + }); + diff --git a/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.js b/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.js new file mode 100644 index 000000000..5d5788550 --- /dev/null +++ b/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'OpenSearch Zone Awareness Enabled', + category: 'OpenSearch', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that OpenSearch domains have zone awareness enabled.', + more_info: 'To improve the fault-tolerance for your OpenSearch domain, ensure you enable zone awareness. It distributes the OpenSearch nodes across multiple availability zones in the same AWS region and assures the cluster is highly available.', + link: 'https://aws.amazon.com/blogs/security/how-to-control-access-to-your-amazon-elasticsearch-service-domain/', + recommended_action: 'Modify OpenSearch domain configuration and enable domain zone awareness.', + apis: ['OpenSearch:listDomainNames', 'OpenSearch:describeDomain', 'STS:getCallerIdentity'], + realtime_triggers: ['opensearch:CreateDomain', 'opensearch:UpdateDomainConfig', 'opensearch:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.opensearch, function(region, rcb) { + var listDomainNames = helpers.addSource(cache, source, + ['opensearch', 'listDomainNames', region]); + + if (!listDomainNames) return rcb(); + + if (listDomainNames.err || !listDomainNames.data) { + helpers.addResult( + results, 3, + `Unable to query for OpenSearch domains: ${helpers.addError(listDomainNames)}`, region); + return rcb(); + } + + if (!listDomainNames.data.length){ + helpers.addResult(results, 0, 'No OpenSearch domains found', region); + return rcb(); + } + + for (var domain of listDomainNames.data) { + if (!domain.DomainName) continue; + + const resource = `arn:${awsOrGov}:es:${region}:${accountId}:domain/${domain.DomainName}`; + + var describeOpenSearchDomain = helpers.addSource(cache, source, + ['opensearch', 'describeDomain', region, domain.DomainName]); + + if (!describeOpenSearchDomain || + describeOpenSearchDomain.err || + !describeOpenSearchDomain.data) { + helpers.addResult(results, 3, + `Unable to query for OpenSearch domain config: ${helpers.addError(describeOpenSearchDomain)}`, region, resource); + continue; + } + + if (describeOpenSearchDomain.data.DomainStatus && describeOpenSearchDomain.data.DomainStatus.ClusterConfig && + describeOpenSearchDomain.data.DomainStatus.ClusterConfig.ZoneAwarenessEnabled) { + helpers.addResult(results, 0,'OpenSearch domain has zone awareness enabled', region, resource); + } else { + helpers.addResult(results, 2,'OpenSearch domain does not have zone awareness enabled', region, resource); + } + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.spec.js b/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.spec.js new file mode 100644 index 000000000..0b81662a9 --- /dev/null +++ b/plugins/aws/opensearch/opensearchZoneAwarenessEnabled.spec.js @@ -0,0 +1,142 @@ +const expect = require('chai').expect; +const opensearchZoneAwarenessEnabled = require('./opensearchZoneAwarenessEnabled'); + +const domainNames = [ + { + "DomainName": "test-domain3-1" + }, + { + "DomainName": "test-domain-2" + } +]; + +const domains = [ + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-1", + "DomainName": "test-domain-1", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-1", + "Created": true, + "Deleted": false, + "ClusterConfig": { + "ZoneAwarenessEnabled": false + }, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:560213429563:domain/es-domain-1/*\",\"Condition\":{\"IpAddress\":{\"aws:SourceIp\":[\"18.208.0.0/13\",\"52.95.245.0/24\"]}}}]}", + } + }, + { + "DomainStatus": { + "DomainId": "1123456654321/test-domain-2", + "DomainName": "test-domain-2", + "ARN": "arn:aws:es:us-east-1:1123456654321:domain/test-domain-2", + "Created": true, + "Deleted": false, + "ClusterConfig": { + "ZoneAwarenessEnabled": true + }, + "AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:560213429563:domain/es-domain-1/*\"}]}", + } + }, +]; + +const createCache = (domainNames, domains) => { + if (domainNames && domainNames.length) var name = domainNames[0].DomainName; + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + data: domainNames, + }, + }, + describeDomain: { + 'us-east-1': { + [name]: { + data: domains + } + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '11111222222' + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + opensearch: { + listDomainNames: { + 'us-east-1': { + err: { + message: 'error listing domain names' + }, + }, + }, + }, + }; +}; + + + +describe('opensearchZoneAwarenessEnabled', function () { + describe('run', function () { + it('should FAIL if domain zone awareness is not enabled', function (done) { + const cache = createCache([domainNames[0]], domains[0]); + opensearchZoneAwarenessEnabled.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('OpenSearch domain does not have zone awareness enabled'); + done(); + }); + }); + + it('should PASS if domain zone awareness is enabled', function (done) { + const cache = createCache([domainNames[1]], domains[1]); + opensearchZoneAwarenessEnabled.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('OpenSearch domain has zone awareness enabled'); + done(); + }); + }); + + it('should PASS if no domain names found', function (done) { + const cache = createCache([]); + opensearchZoneAwarenessEnabled.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No OpenSearch domains found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list domain', function (done) { + const cache = createErrorCache(); + opensearchZoneAwarenessEnabled.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for OpenSearch domains'); + done(); + }); + }); + + it('should not return unknown results if unable to describe domain names', function (done) { + const cache = createCache([domainNames[0]],null); + opensearchZoneAwarenessEnabled.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for OpenSearch domain config'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/organizations/enableAllFeatures.js b/plugins/aws/organizations/enableAllFeatures.js index d5072a726..49e3568f4 100644 --- a/plugins/aws/organizations/enableAllFeatures.js +++ b/plugins/aws/organizations/enableAllFeatures.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Enable All Organization Features', category: 'Organizations', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures all Organization features are enabled', more_info: 'All AWS Organizations should be enabled to take advantage of all shared security controls and policies across all member accounts.', recommended_action: 'Enable all AWS Organizations features.', link: 'https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_org_support-all-features.html?icmpid=docs_orgs_console', apis: ['Organizations:describeOrganization'], + realtime_triggers: ['organizations:CreateOrganization', 'organizations:EnableAllFeatures', 'organizations:DeleteOrganization'], run: function(cache, settings, callback) { var results = []; @@ -15,6 +18,8 @@ module.exports = { var region = helpers.defaultRegion(settings); var describeOrganization = helpers.addSource(cache, source, ['organizations', 'describeOrganization', region]); + if (!describeOrganization) return callback(null, results, source); + if (!describeOrganization.data || describeOrganization.err) { if (!describeOrganization.err || describeOrganization.err.code !== 'AWSOrganizationsNotInUseException') { helpers.addResult(results, 3, 'Cannot describe the organization', 'global'); diff --git a/plugins/aws/organizations/organizationInvite.js b/plugins/aws/organizations/organizationInvite.js index bd5d09664..04c802fab 100644 --- a/plugins/aws/organizations/organizationInvite.js +++ b/plugins/aws/organizations/organizationInvite.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Organization Invite', category: 'Organizations', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensure all Organization invites are accepted', more_info: 'AWS Organizations invites should be accepted or rejected quickly so that member accounts can take advantage of all Organization features.', recommended_action: 'Enable all AWS Organizations features', link: 'https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_org_support-all-features.html?icmpid=docs_orgs_console', apis: ['Organizations:listHandshakesForAccount'], + realtime_triggers: ['organizations:CreateOrganization', 'organizations:AcceptHandshake' ,'organizations:DeclineHandshake', 'organizations:CancleHandshake', 'organizations:DeleteOrganization'], run: function(cache, settings, callback) { var results = []; @@ -15,6 +18,8 @@ module.exports = { var region = helpers.defaultRegion(settings); var listHandshakesForAccount = helpers.addSource(cache, source, ['organizations', 'listHandshakesForAccount', region]); + if (!listHandshakesForAccount) return callback(null, results, source); + if (!listHandshakesForAccount.data || listHandshakesForAccount.err) { helpers.addResult(results, 3, 'Cannot list organization handshakes', 'global'); return callback(null, results, source); diff --git a/plugins/aws/proton/environmentTemplateEncrypted.js b/plugins/aws/proton/environmentTemplateEncrypted.js new file mode 100644 index 000000000..b1f9cadf4 --- /dev/null +++ b/plugins/aws/proton/environmentTemplateEncrypted.js @@ -0,0 +1,116 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Environment Template Encrypted', + category: 'Proton', + domain: 'Databases', + severity: 'High', + description: 'Ensure that AWS Proton environment template is encrypted with desired level.', + more_info: 'AWS Proton encrypts sensitive data in your template bundles at rest in the S3 bucket where you store your template bundles using AWS-managed keys. Use customer-managed keys (CMKs) in order to meet regulatory compliance requirements within your organization.', + recommended_action: 'Create Proton environment template with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/proton/latest/adminguide/data-protection.html', + apis: ['Proton:listEnvironmentTemplates','Proton:getEnvironmentTemplate', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + proton_environmenttemplate_desired_encryption_level: { + name: 'Environment Template Desired Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['proton:CreateEnviromentTemplate', 'proton:DeleteEnviromentTemplate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.proton_environmenttemplate_desired_encryption_level || this.settings.proton_environmenttemplate_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.proton, function(region, rcb){ + var listEnvironmentTemplates = helpers.addSource(cache, source, + ['proton', 'listEnvironmentTemplates', region]); + + if (!listEnvironmentTemplates) return rcb(); + + if (listEnvironmentTemplates.err || !listEnvironmentTemplates.data) { + helpers.addResult(results, 3, + 'Unable to query Environment Template: ' + helpers.addError(listEnvironmentTemplates), region); + return rcb(); + } + + if (!listEnvironmentTemplates.data.length) { + helpers.addResult(results, 0, 'No Environment Template found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let template of listEnvironmentTemplates.data) { + if (!template.arn) continue; + + let resource = template.arn; + + var getEnvironmentTemplate = helpers.addSource(cache, source, + ['proton', 'getEnvironmentTemplate', region, template.name]); + + if (!getEnvironmentTemplate || getEnvironmentTemplate.err || !getEnvironmentTemplate.data || + !getEnvironmentTemplate.data.environmentTemplate) { + helpers.addResult(results, 3, + `Unable to get Environment Template description: ${helpers.addError(getEnvironmentTemplate)}`, + region, resource); + continue; + } + + if (getEnvironmentTemplate.data.environmentTemplate.encryptionKey) { + var encryptionKey = getEnvironmentTemplate.data.environmentTemplate.encryptionKey; + var keyId = encryptionKey.split('/')[1] ? encryptionKey.split('/')[1] : encryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, encryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Proton environment template is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Proton environment template is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/proton/environmentTemplateEncrypted.spec.js b/plugins/aws/proton/environmentTemplateEncrypted.spec.js new file mode 100644 index 000000000..9b080aed3 --- /dev/null +++ b/plugins/aws/proton/environmentTemplateEncrypted.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +var environmentTemplateEncrypted = require('./environmentTemplateEncrypted'); + +const listEnvironmentTemplates = [ + { + "arn": "arn:aws:proton:us-east-1:000111222333:environment-template/sadeed1", + "createdAt": "2021-11-18T17:01:54.758000+05:00", + "displayName": "call me brown boy", + "lastModifiedAt": "2021-11-18T17:01:54.758000+05:00", + "name": "sadeed1" + }, + { + "arn": "arn:aws:proton:us-east-1:000111222333:environment-template/sad1", + "createdAt": "2021-11-18T17:36:46.281000+05:00", + "displayName": "sadeed", + "lastModifiedAt": "2021-11-18T17:36:46.281000+05:00", + "name": "sad1" + } +]; + +const getEnvironmentTemplate = [ + { + "environmentTemplate": { + "arn": "arn:aws:proton:us-east-1:000111222333:environment-template/sadeed1", + "createdAt": "2021-11-18T17:01:54.758000+05:00", + "displayName": "call me brown boy", + "encryptionKey": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "lastModifiedAt": "2021-11-18T17:01:54.758000+05:00", + "name": "sadeed1" + } + } + +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (templates, keys, getEnvironmentTemplate, describeKey, templatesErr, keysErr, describeKeyErr, getEnvironmentTemplateErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var name = (templates && templates.length) ? templates[0].name: null; + return { + proton: { + listEnvironmentTemplates: { + 'us-east-1': { + err: templatesErr, + data: templates + }, + }, + getEnvironmentTemplate: { + 'us-east-1': { + [name]: { + data: getEnvironmentTemplate, + err: getEnvironmentTemplateErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('environmentTemplateEncrypted', function () { + describe('run', function () { + it('should PASS if Proton environment template is encrypted with desired encryption level', function (done) { + const cache = createCache([listEnvironmentTemplates[0]], listKeys, getEnvironmentTemplate[0], describeKey[0]); + environmentTemplateEncrypted.run(cache, { proton_environmenttemplate_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Proton environment template is not encrypted with desired encryption level', function (done) { + const cache = createCache([listEnvironmentTemplates[1]],listKeys, getEnvironmentTemplate[0], describeKey[1]); + environmentTemplateEncrypted.run(cache, { proton_environmenttemplate_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Proton environment template found', function (done) { + const cache = createCache([]); + environmentTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Proton environment template', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Proton environment template" }); + environmentTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listEnvironmentTemplates, null, null, null, { message: "Unable to list KMS keys" }); + environmentTemplateEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/qldb/ledgerDeletionProtection.js b/plugins/aws/qldb/ledgerDeletionProtection.js new file mode 100644 index 000000000..b303a51e0 --- /dev/null +++ b/plugins/aws/qldb/ledgerDeletionProtection.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Ledger Deletion Protection', + category: 'QLDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that AWS QLDB ledger has deletion protection feature enabled.', + more_info: 'Enabling deletion protection for an Amazon QLDB ledger prevents accidental or unauthorized deletions, ensuring the ledger remains secure and accessible. It requires explicit action to disable this protection before the ledger can be deleted.', + recommended_action: 'Modify QLDB ledger and enable deletion protection.', + link: 'https://docs.aws.amazon.com/qldb/latest/developerguide/ledger-management.basics.html', + apis: ['QLDB:listLedgers','QLDB:describeLedger','STS:getCallerIdentity'], + realtime_triggers: ['qldb:CreateLedger', 'qldb:UpdateLedger', 'qldb:DeleteLedger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + async.each(regions.qldb, function(region, rcb){ + var listLedgers = helpers.addSource(cache, source, + ['qldb', 'listLedgers', region]); + + if (!listLedgers) return rcb(); + + if (listLedgers.err || !listLedgers.data) { + helpers.addResult(results, 3, + 'Unable to query QLDB ledgers: ' + helpers.addError(listLedgers), region); + return rcb(); + } + + if (!listLedgers.data.length) { + helpers.addResult(results, 0, 'No QLDB ledgers found', region); + return rcb(); + } + + for (let ledger of listLedgers.data) { + if (!ledger.Name) continue; + + let resource = `arn:${awsOrGov}:qldb:${region}:${accountId}:ledger/${ledger.Name}`; + + var describeLedger = helpers.addSource(cache, source, + ['qldb', 'describeLedger', region, ledger.Name]); + + if (!describeLedger || describeLedger.err || !describeLedger.data ) { + helpers.addResult(results, 3, + `Unable to get QLDB ledgers description: ${helpers.addError(describeLedger)}`, + region, resource); + continue; + } + + if (describeLedger.data.DeletionProtection) { + helpers.addResult(results, 0, + 'QLDB ledger has deletion protection enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'QLDB ledger does not have deletion protection enabled', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/qldb/ledgerDeletionProtection.spec.js b/plugins/aws/qldb/ledgerDeletionProtection.spec.js new file mode 100644 index 000000000..afa8a2822 --- /dev/null +++ b/plugins/aws/qldb/ledgerDeletionProtection.spec.js @@ -0,0 +1,100 @@ +var expect = require('chai').expect; +var ledgerDeletionProtection = require('./ledgerDeletionProtection'); + +const listLedgers = [ + { + "Name": "test-ledger", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00" + } +]; + +const describeLedger = [ + { + "Name": "test-ledger", + "Arn": "arn:aws:qldb:us-east-1:000111222333:ledger/test-ledger", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00", + "PermissionsMode": "STANDARD", + "DeletionProtection": true, + }, + { + "Name": "test-ledger", + "Arn": "arn:aws:qldb:us-east-1:000111222333:ledger/test-ledger", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00", + "PermissionsMode": "STANDARD", + "DeletionProtection": false, + } +]; + +const createCache = (ledgers, describeLedger, ledgersErr, describeLedgerErr) => { + var name = (ledgers && ledgers.length) ? ledgers[0].Name: null; + return { + qldb: { + listLedgers: { + 'us-east-1': { + err: ledgersErr, + data: ledgers + }, + }, + describeLedger: { + 'us-east-1': { + [name]: { + data: describeLedger, + err: describeLedgerErr + } + } + } + }, + }; +}; + +describe('ledgerDeletionProtection', function () { + describe('run', function () { + it('should PASS if QLDB ledger has deletion protection enabled', function (done) { + const cache = createCache(listLedgers, describeLedger[0]); + ledgerDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('QLDB ledger has deletion protection enabled'); + done(); + }); + }); + + it('should FAIL if QLDb ledger does not have deletion protection enabled', function (done) { + const cache = createCache(listLedgers, describeLedger[1]); + ledgerDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('QLDB ledger does not have deletion protection enabled'); + done(); + }); + }); + + it('should PASS if no QLDB ledgers found', function (done) { + const cache = createCache([]); + ledgerDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No QLDB ledgers found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list QLDB ledgers', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list QLDB ledgers" }); + ledgerDeletionProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query QLDB ledgers'); + done(); + }); + }); + + }); +}) diff --git a/plugins/aws/qldb/ledgerEncrypted.js b/plugins/aws/qldb/ledgerEncrypted.js new file mode 100644 index 000000000..59e176070 --- /dev/null +++ b/plugins/aws/qldb/ledgerEncrypted.js @@ -0,0 +1,123 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Ledger Encrypted', + category: 'QLDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that AWS QLDB ledger is encrypted using desired encryption level', + more_info: 'QLDB encryption at rest provides enhanced security by encrypting all ledger data at rest using encryption keys in AWS Key Management Service (AWS KMS).' + + 'Use customer-managed keys (CMKs) instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create QLDB ledger with customer-manager keys (CMKs)', + link: 'https://docs.aws.amazon.com/qldb/latest/developerguide/encryption-at-rest.html', + apis: ['QLDB:listLedgers','QLDB:describeLedger', 'KMS:describeKey', 'KMS:listKeys', 'STS:getCallerIdentity'], + settings: { + qldb_ledger_desired_encryption_level: { + name: 'QLDB ledger desired encryption level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['qldb:CreateLedger', 'qldb:UpdateLedger', 'qldb:DeleteLedger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.qldb_ledger_desired_encryption_level || this.settings.qldb_ledger_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.qldb, function(region, rcb){ + var listLedgers = helpers.addSource(cache, source, + ['qldb', 'listLedgers', region]); + + if (!listLedgers) return rcb(); + + if (listLedgers.err || !listLedgers.data) { + helpers.addResult(results, 3, + 'Unable to query Ledgers: ' + helpers.addError(listLedgers), region); + return rcb(); + } + + if (!listLedgers.data.length) { + helpers.addResult(results, 0, 'No Ledgers found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let ledger of listLedgers.data) { + if (!ledger.Name) continue; + + let resource = `arn:${awsOrGov}:qldb:${region}:${accountId}:ledger/${ledger.Name}`; + + var describeLedger = helpers.addSource(cache, source, + ['qldb', 'describeLedger', region, ledger.Name]); + + if (!describeLedger || describeLedger.err || !describeLedger.data ) { + helpers.addResult(results, 3, + `Unable to get Ledgers description: ${helpers.addError(describeLedger)}`, + region, resource); + continue; + } + + if (describeLedger.data.EncryptionDescription && + describeLedger.data.EncryptionDescription.KmsKeyArn) { + + var kmsKeyArn = describeLedger.data.EncryptionDescription.KmsKeyArn; + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `QLDB ledger is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `QLDB ledger is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/qldb/ledgerEncrypted.spec.js b/plugins/aws/qldb/ledgerEncrypted.spec.js new file mode 100644 index 000000000..2419d95b9 --- /dev/null +++ b/plugins/aws/qldb/ledgerEncrypted.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var ledgerEncrypted = require('./ledgerEncrypted'); + +const listLedgers = [ + { + "Name": "sadeed1", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00" + } +]; + +const describeLedger = [ + { + "Name": "sadeed1", + "Arn": "arn:aws:qldb:us-east-1:000111222333:ledger/sadeed1", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00", + "PermissionsMode": "STANDARD", + "DeletionProtection": true, + "EncryptionDescription": { + "KmsKeyArn": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "EncryptionStatus": "ENABLED" + } + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (ledgers, keys, describeLedger, describeKey, ledgersErr, keysErr, describeKeyErr, describeLedgerErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyId : null; + var name = (ledgers && ledgers.length) ? ledgers[0].Name: null; + return { + qldb: { + listLedgers: { + 'us-east-1': { + err: ledgersErr, + data: ledgers + }, + }, + describeLedger: { + 'us-east-1': { + [name]: { + data: describeLedger, + err: describeLedgerErr + } + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('ledgerEncrypted', function () { + describe('run', function () { + it('should PASS if QLDB ledger is encrypted with desired encryption level', function (done) { + const cache = createCache(listLedgers, listKeys, describeLedger[0], describeKey[0]); + ledgerEncrypted.run(cache, { qldb_ledger_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if QLDb ledger is not encrypted with desired encryption level', function (done) { + const cache = createCache(listLedgers,listKeys, describeLedger[0], describeKey[1]); + ledgerEncrypted.run(cache, { qldb_ledger_desired_encryption_level : 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no QLDB ledgers found', function (done) { + const cache = createCache([]); + ledgerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list QLDB ledgers', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list QLDB ledgers" }); + ledgerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listLedgers, null, null, null, { message: "Unable to list KMS keys" }); + ledgerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/qldb/ledgerHasTags.js b/plugins/aws/qldb/ledgerHasTags.js new file mode 100644 index 000000000..f6973c2f7 --- /dev/null +++ b/plugins/aws/qldb/ledgerHasTags.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Ledger Has Tags', + category: 'QLDB', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that AWS QLDB ledgers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify QLDB ledger and add tags.', + link: 'https://docs.aws.amazon.com/qldb/latest/developerguide/tagging.html', + apis: ['QLDB:listLedgers','ResourceGroupsTaggingAPI:getResources','STS:getCallerIdentity'], + realtime_triggers: ['qldb:CreateLedger', 'qldb:DeleteLedger', 'qldb:TagResource', 'qldb:UntagResource'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + + async.each(regions.qldb, function(region, rcb){ + var listLedgers = helpers.addSource(cache, source, + ['qldb', 'listLedgers', region]); + + if (!listLedgers) return rcb(); + + if (listLedgers.err || !listLedgers.data) { + helpers.addResult(results, 3, + 'Unable to query QLDB ledgers: ' + helpers.addError(listLedgers), region); + return rcb(); + } + + if (!listLedgers.data.length) { + helpers.addResult(results, 0, 'No QLDB ledgers found', region); + return rcb(); + } + + const arnList = []; + + for (let ledger of listLedgers.data) { + if (!ledger.Name) continue; + + let resource = `arn:${awsOrGov}:qldb:${region}:${accountId}:ledger/${ledger.Name}`; + arnList.push(resource); + } + + helpers.checkTags(cache, 'QLDB ledger', arnList, region, results, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/qldb/ledgerHasTags.spec.js b/plugins/aws/qldb/ledgerHasTags.spec.js new file mode 100644 index 000000000..ab3365604 --- /dev/null +++ b/plugins/aws/qldb/ledgerHasTags.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var ledgerHasTags = require('./ledgerHasTags'); + +const listLedgers = [ + { + "Name": "test-ledger", + "State": "ACTIVE", + "CreationDateTime": "2021-11-19T16:29:08.899000+05:00" + } +]; + +const getResources = [ + { + "ResourceARN": "arn:aws:qldb:us-east-1:000111222333:ledger/test-ledger", + "Tags": [], + }, + { + "ResourceARN": "arn:aws:qldb:us-east-1:000111222333:ledger/test-ledger", + "Tags": [{key: 'value'}], + } +] + +const createCache = (ledgers, rgData, ledgersErr) => { + var name = (ledgers && ledgers.length) ? ledgers[0].Name: null; + return { + qldb: { + listLedgers: { + 'us-east-1': { + err: ledgersErr, + data: ledgers + }, + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '000111222333' + } + } + } + }; +}; + +describe('ledgerHasTags', function () { + describe('run', function () { + it('should PASS if QLDB ledger has tags', function (done) { + const cache = createCache(listLedgers, [getResources[1]]); + ledgerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('QLDB ledger has tags'); + done(); + }); + }); + + it('should FAIL if QLDb ledger does not have tags', function (done) { + const cache = createCache(listLedgers, [getResources[0]]); + ledgerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('QLDB ledger does not have any tags'); + done(); + }); + }); + + it('should PASS if no QLDB ledgers found', function (done) { + const cache = createCache([]); + ledgerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No QLDB ledgers found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list QLDB ledgers', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list QLDB ledgers" }); + ledgerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query QLDB ledgers'); + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listLedgers[0]],null); + ledgerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + done(); + }); + }); + + }); +}) diff --git a/plugins/aws/rds/iamDbAuthenticationEnabled.js b/plugins/aws/rds/iamDbAuthenticationEnabled.js new file mode 100644 index 000000000..b055e1df9 --- /dev/null +++ b/plugins/aws/rds/iamDbAuthenticationEnabled.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS IAM Database Authentication Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures IAM Database Authentication is enabled for RDS database instances to manage database access', + more_info: 'AWS Identity and Access Management (IAM) can be used to authenticate to your RDS DB instances.', + link: 'https://docs.aws.amazon.com/neptune/latest/userguide/iam-auth.html', + recommended_action: 'Modify the PostgreSQL and MySQL type RDS instances to enable IAM database authentication.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3', 'rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query for RDS instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn || !instance.Engine) return; + + if (['postgres', 'mysql'].includes(instance.Engine)) { + if (instance.IAMDatabaseAuthenticationEnabled) { + helpers.addResult(results, 0, + 'RDS instance has IAM Database Authentication enabled', region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 2, + 'RDS instance does not have IAM Database Authentication enabled', region, instance.DBInstanceArn); + } + } else { + helpers.addResult(results, 0, + `RDS instance engine type ${instance.Engine} does not support IAM database authentication`, region, instance.DBInstanceArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/rds/iamDbAuthenticationEnabled.spec.js b/plugins/aws/rds/iamDbAuthenticationEnabled.spec.js new file mode 100644 index 000000000..d6dd30cd7 --- /dev/null +++ b/plugins/aws/rds/iamDbAuthenticationEnabled.spec.js @@ -0,0 +1,92 @@ +const expect = require('chai').expect; +var iamDbAuthenticationEnabled = require('./iamDbAuthenticationEnabled'); + +const describeDBInstances = [ + { + "DBInstanceArn": "arn:aws:rds:ap-south-1:111222333444:db:database-1", + "IAMDatabaseAuthenticationEnabled": true, + "Engine": "postgres", + }, + { + "DBInstanceArn": "arn:aws:rds:ap-south-1:111222333444:db:database-1", + "IAMDatabaseAuthenticationEnabled": false, + "Engine": "postgres", + } +]; + +const createCache = (clusterData, clusterErr) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: clusterData, + err: clusterErr + } + } + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null + } + } + }; +}; + +describe('iamDbAuthenticationEnabled', function () { + describe('run', function () { + + it('should PASS if RDS instance has IAM Database Authentication enabled', function (done) { + const cache = createCache([describeDBInstances[0]]); + iamDbAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if RDS instance does not have IAM Database Authentication enabled', function (done) { + const cache = createCache([describeDBInstances[1]]); + iamDbAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS instances found', function (done) { + const cache = createCache([]); + iamDbAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe RDS instances', function (done) { + const cache = createCache([], { message: 'Unable to describe instances' }); + iamDbAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should not return anything if describe DB instances response not found', function (done) { + const cache = createNullCache(); + iamDbAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/rds/idleRDSInstance.js b/plugins/aws/rds/idleRDSInstance.js new file mode 100644 index 000000000..66312fdc0 --- /dev/null +++ b/plugins/aws/rds/idleRDSInstance.js @@ -0,0 +1,125 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Idle Instance Status', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure there are no RDS instances with CPU utilization below all of the defined thresholds within last 7 days.', + more_info: 'Idle Amazon RDS instance is a prime candidate for reducing monthly AWS expenses and preventing unnecessary usage charges from accumulating.', + link: 'https://docs.aws.amazon.com/prescriptive-guidance/latest/amazon-rds-monitoring-alerting/db-instance-cloudwatch-metrics.html', + recommended_action: 'Identify and remove idle RDS instance', + apis: ['RDS:describeDBInstances', 'CloudWatch:getRdsMetricStatistics', 'CloudWatch:getRdsWriteIOPSMetricStatistics', 'CloudWatch:getRdsReadIOPSMetricStatistics'], + settings: { + rds_idle_instance_cpu_percentage: { + name: 'RDS Idle Instance Average CPU Usage Percentage', + description: 'Return a failing result when consumed RDS instance cpu threshold is equal to or less than this percentage', + regex: '^(100|[1-9][0-9]?)$', + default: '1.0' + }, + rds_idle_instance_readIOPS_percentage: { + name: 'RDS Idle Instance Average Read IOPS Percentage', + description: 'Return a failing result when consumed RDS instance read IOPS threshold is equal to or less than this percentage', + regex: '^(100|[1-9][0-9]?)$', + default: '20' + }, + rds_idle_instance_writeIOPS_percentage: { + name: 'RDS Idle Instance Average Write IOPS Percentage', + description: 'Return a failing result when consumed RDS instance write IOPS threshold is equal to or less than this percentage', + regex: '^(100|[1-9][0-9]?)$', + default: '20' + } + }, + realtime_triggers: ['rds:CreateDBInstance','rds:DeleteDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var rds_idle_instance_cpu_percentage = settings.rds_idle_instance_cpu_percentage || this.settings.rds_idle_instance_cpu_percentage.default; + var rds_idle_instance_readIOPS_percentage = settings.rds_idle_instance_readIOPS_percentage || this.settings.rds_idle_instance_readIOPS_percentage.default; + var rds_idle_instance_writeIOPS_percentage = settings.rds_idle_instance_writeIOPS_percentage || this.settings.rds_idle_instance_writeIOPS_percentage.default; + rds_idle_instance_cpu_percentage = parseFloat(rds_idle_instance_cpu_percentage); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult( + results, 3, + `Unable to query for RDS instance: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length){ + helpers.addResult(results, 0, 'No RDS instance found', region); + return rcb(); + } + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn) return; + + var getRdsMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getRdsMetricStatistics', region, instance.DBInstanceIdentifier]); + var getRdsReadIOPSMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getRdsReadIOPSMetricStatistics', region, instance.DBInstanceIdentifier]); + var getRdsWriteIOPSMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getRdsWriteIOPSMetricStatistics', region, instance.DBInstanceIdentifier]); + + if (!getRdsMetricStatistics || getRdsMetricStatistics.err || + !getRdsMetricStatistics.data || !getRdsMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3,`Unable to query for CPU metric statistics: ${helpers.addError(getRdsMetricStatistics)}`, region, instance.DBInstanceArn); + return; + } + + if (!getRdsReadIOPSMetricStatistics || getRdsReadIOPSMetricStatistics.err || + !getRdsReadIOPSMetricStatistics.data || !getRdsReadIOPSMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3, `Unable to query for Read IOPS metric statistics: ${helpers.addError(getRdsReadIOPSMetricStatistics)}`, region, instance.DBInstanceArn); + return; + } + + if (!getRdsWriteIOPSMetricStatistics || getRdsWriteIOPSMetricStatistics.err || + !getRdsWriteIOPSMetricStatistics.data || !getRdsWriteIOPSMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3,`Unable to query for Write IOPS metric statistics: ${helpers.addError(getRdsWriteIOPSMetricStatistics)}`, region, instance.DBInstanceArn); + return; + } + + if (!getRdsWriteIOPSMetricStatistics.data.Datapoints.length || !getRdsReadIOPSMetricStatistics.data.Datapoints.length || !getRdsMetricStatistics.data.Datapoints.length ) { + helpers.addResult(results, 0,'Metric statistics are not available', region, instance.DBInstanceArn); + return; + } + + var cpuIdle = false; + var readIopsIdle = false; + var writeIopsIdle = false; + // Check CPU utilization + if (getRdsMetricStatistics.data.Datapoints.every(datapoint => datapoint.Average <= rds_idle_instance_cpu_percentage)) { + cpuIdle = true; + } + // Check Read IOPS + if (getRdsReadIOPSMetricStatistics.data.Datapoints.every(datapoint => datapoint.Sum <= rds_idle_instance_readIOPS_percentage)) { + readIopsIdle = true; + } + // Check Write IOPS + if (getRdsWriteIOPSMetricStatistics.data.Datapoints.every(datapoint => datapoint.Sum <= rds_idle_instance_writeIOPS_percentage)) { + writeIopsIdle = true; + } + + if (cpuIdle && readIopsIdle && writeIopsIdle) { + helpers.addResult(results, 2, 'RDS instance is idle', region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'RDS instance is not idle', region, instance.DBInstanceArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/idleRDSInstance.spec.js b/plugins/aws/rds/idleRDSInstance.spec.js new file mode 100644 index 000000000..21df9f023 --- /dev/null +++ b/plugins/aws/rds/idleRDSInstance.spec.js @@ -0,0 +1,397 @@ +const expect = require('chai').expect; +const idleRDSInstance = require('./idleRDSInstance'); + +const describeDBInstances=[ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + + }, + { + DBInstanceIdentifier: 'test-2', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'Fatima', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-2', + + }, + { + DBInstanceIdentifier: 'test-3', + DBInstanceClass: 'db.t3.micro', + Engine: 'mysql', + DBInstanceStatus: 'available', + MasterUsername: 'admin', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-3', + + } +] + +const rdsMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 0.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 0, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 0.333, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 94.99, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 90.70, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 99.20, + "Unit": "Percent" + }, + ] + } +] +const rdsReadMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2023-08-23T08:00:00+00:00", + "Sum": 20.345, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T03:00:00+00:00", + "Sum": 25.681474214651491, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T18:00:00+00:00", + "Sum": 35.744509676375273, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T13:00:00+00:00", + "Sum": 16.948755730537165, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T16:00:00+00:00", + "Sum": 20.948286932273096, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T21:00:00+00:00", + "Sum": 24.876655210316418, + "Unit": "Count/Second" + }, + + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2023-08-23T08:00:00+00:00", + "Sum": 0.345, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T03:00:00+00:00", + "Sum": 5.681474214651491, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T18:00:00+00:00", + "Sum": 5.744509676375273, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T13:00:00+00:00", + "Sum": 1.948755730537165, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T16:00:00+00:00", + "Sum": 0.948286932273096, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T21:00:00+00:00", + "Sum": 4.876655210316418, + "Unit": "Count/Second" + }, + + ] + }, +] +const rdsWriteMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2023-08-23T08:00:00+00:00", + "Sum": 25.79992379178903, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T03:00:00+00:00", + "Sum": 35.681474214651491, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T18:00:00+00:00", + "Sum": 45.744509676375273, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T13:00:00+00:00", + "Sum": 26.948755730537165, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T16:00:00+00:00", + "Sum": 17.948286932273096, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T21:00:00+00:00", + "Sum": 20.876655210316418, + "Unit": "Count/Second" + }, + + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2023-08-23T08:00:00+00:00", + "Sum": 2.345, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T03:00:00+00:00", + "Sum": 2.681474214651491, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T18:00:00+00:00", + "Sum": 2.744509676375273, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T13:00:00+00:00", + "Sum": 1.948755730537165, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T16:00:00+00:00", + "Sum": 0.948286932273096, + "Unit": "Count/Second" + }, + { + "Timestamp": "2023-08-23T21:00:00+00:00", + "Sum": 4.876655210316418, + "Unit": "Count/Second" + }, + + ] + }, +] + + +const createCache = (instance, cpuMetrics, writeMetric, readMetric) => { + if (instance && instance.length) var id = instance[0].DBInstanceIdentifier; + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: instance, + }, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': { + [id]: { + data: cpuMetrics + } + } + }, + getRdsWriteIOPSMetricStatistics: { + 'us-east-1': { + [id]: { + data: writeMetric + } + } + }, + getRdsReadIOPSMetricStatistics: { + 'us-east-1': { + [id]: { + data: readMetric + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + }, + getRdsWriteIOPSMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + }, + getRdsReadIOPSMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': null + }, + getRdsWriteIOPSMetricStatistics: { + 'us-east-1': null + }, + getRdsReadIOPSMetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('idleRDSInstance', function () { + describe('run', function () { + it('should PASS if the RDS Instance cpu utilization is more than 1.0 percent or more than 20 Read or Write IOPS', function (done) { + const cache = createCache([describeDBInstances[0]], rdsMetricStatistics[1], rdsReadMetricStatistics[0], rdsWriteMetricStatistics[0]); + idleRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if the RDS Instance cpu utilization is less than or equal to 1.0 percent', function (done) { + const cache = createCache([describeDBInstances[1]], rdsMetricStatistics[0], rdsReadMetricStatistics[1], rdsWriteMetricStatistics[1]); + idleRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + idleRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No RDS instance found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe RDS Instance', function (done) { + const cache = createErrorCache(); + idleRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for RDS instance: '); + done(); + }); + }); + + it('should not return any results if describe RDS Instance response not found', function (done) { + const cache = createNullCache(); + idleRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/mysqlVulnerabilityCheck.js b/plugins/aws/rds/mysqlVulnerabilityCheck.js new file mode 100644 index 000000000..37b5008a5 --- /dev/null +++ b/plugins/aws/rds/mysqlVulnerabilityCheck.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS MySQL Vulnerability Check', + category: 'RDS', + severity: 'Medium', + description: ' Ensures RDS MySQL instances are not vulnerable to specific CVEs.', + more_info: 'Vulnerabilities in RDS MySQL instances can pose significant risks to the security and integrity of your database environment. By identifying and addressing these vulnerabilities, you can protect your data from unauthorized access, data tampering, or privilege escalation.', + link: 'https://aws.amazon.com/security/security-bulletins/mysql-5-5-and-5-6-security-advisory/', + recommended_action: 'Update the MySQL engine version to a more recent, patched version to mitigate the vulnerabilities.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query for RDS instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn || !instance.Engine) return; + + if (instance.Engine && instance.Engine.toLowerCase().includes('mysql')) { + const isVulnerable = instance.EngineVersion.match(/^(5\.5\.(?:[0-3][0-9]|[0-9])|5\.6\.(?:[0-1][0-9]|[0-9]|20))$/); + + if (isVulnerable) { + helpers.addResult(results, 2, 'RDS MySQL instance is vulnerable', region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'No vulnerabilities found in RDS MySQL instance', region, instance.DBInstanceArn); + } + } else { + helpers.addResult(results, 0, 'RDS does not have MYSQL instance', region); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/mysqlVulnerabilityCheck.spec.js b/plugins/aws/rds/mysqlVulnerabilityCheck.spec.js new file mode 100644 index 000000000..7c9f96662 --- /dev/null +++ b/plugins/aws/rds/mysqlVulnerabilityCheck.spec.js @@ -0,0 +1,249 @@ +var expect = require('chai').expect; +var mysqlVulnerabilityCheck = require('./mysqlVulnerabilityCheck.js'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "mysql", + "DBInstanceStatus": "creating", + "MasterUsername": "admin", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 0, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "mysql", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 0, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "5.6.20", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + + +const createCache = (rdsInstances) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('mysqlVulnerabilityCheck', function () { + describe('run', function () { + it('should PASS if RDS mysql instance does not contain the CVE-2014-6491, CVE-2014-6494, CVE-2014-6500, or CVE-2014-6559 vulnerability.', function (done) { + const cache = createCache([describeDBInstances[0]]); + mysqlVulnerabilityCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('No vulnerabilities found in RDS MySQL instance'); + done(); + }); + }); + + it('should FAIL if RDS mysql instance contain the CVE-2014-6491, CVE-2014-6494, CVE-2014-6500, or CVE-2014-6559 vulnerability.', function (done) { + const cache = createCache([describeDBInstances[1]]); + mysqlVulnerabilityCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('RDS MySQL instance is vulnerable'); + done(); + }); + }); + + it('should PASS if no RDS instance found.', function (done) { + const cache = createCache([]); + mysqlVulnerabilityCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('No RDS instances found'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances', function (done) { + const cache = createErrorCache(); + mysqlVulnerabilityCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + mysqlVulnerabilityCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/overutilizedRDSInstance.js b/plugins/aws/rds/overutilizedRDSInstance.js new file mode 100644 index 000000000..09bab55ae --- /dev/null +++ b/plugins/aws/rds/overutilizedRDSInstance.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS CPU Alarm Threshold Exceeded', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure RDS instances do not exceed the alarm threshold for CPU utilization.', + more_info: 'High CPU usage may suggest that the databases on these servers lack sufficient hardware resources to operate at their best. Enhancing the performance of overburdened RDS instances by upgrading them can directly enhance the well-being and performance of the databases.', + link: 'https://docs.aws.amazon.com/prescriptive-guidance/latest/amazon-rds-monitoring-alerting/db-instance-cloudwatch-metrics.html', + recommended_action: 'Upgrade (upsize) the overused RDS database instances.', + apis: ['RDS:describeDBInstances', 'CloudWatch:getRdsMetricStatistics'], + settings: { + rds_cpu_threshold_fail: { + name: 'RDS Instance CPU Threshold Fail', + description: 'Return a failing result when consumed RDS instance cpu threshold equals or exceeds this percentage', + regex: '^(100|[1-9][0-9]?)$', + default: '90' + } + }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var rds_cpu_threshold_fail = settings.rds_cpu_threshold_fail || this.settings.rds_cpu_threshold_fail.default; + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult( + results, 3, + `Unable to query for RDS instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn) return; + + var getMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getRdsMetricStatistics', region, instance.DBInstanceIdentifier]); + + if (!getMetricStatistics || getMetricStatistics.err || !getMetricStatistics.data || !getMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3, + `Unable to query for CPU metric statistics: ${helpers.addError(getMetricStatistics)}`, region, instance.DBInstanceArn); + return; + } + + if (!getMetricStatistics.data.Datapoints.length) { + helpers.addResult(results, 0, + 'CPU metric statistics are not available', region, instance.DBInstanceArn); + } else { + var cpuDatapoints = getMetricStatistics.data.Datapoints; + var cpuUtilization = cpuDatapoints[cpuDatapoints.length - 1].Average; + if (cpuUtilization >= rds_cpu_threshold_fail) { + helpers.addResult(results, 2, + `RDS instance has current CPU utilization of ${cpuUtilization}% which exceeds the CPU threshold`, region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 0, + `RDS instance has current CPU utilization of ${cpuUtilization}% which does not exceed the CPU threshold`, region, instance.DBInstanceArn); + } + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/overutilizedRDSInstance.spec.js b/plugins/aws/rds/overutilizedRDSInstance.spec.js new file mode 100644 index 000000000..6e326f3d5 --- /dev/null +++ b/plugins/aws/rds/overutilizedRDSInstance.spec.js @@ -0,0 +1,218 @@ +const expect = require('chai').expect; +const overutilizedRDSInstance = require('./overutilizedRDSInstance'); + +const describeDBInstances=[ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + + }, + { + DBInstanceIdentifier: 'test-2', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'Fatima', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-2', + + }, + { + DBInstanceIdentifier: 'test-3', + DBInstanceClass: 'db.t3.micro', + Engine: 'mysql', + DBInstanceStatus: 'available', + MasterUsername: 'admin', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-3', + + } +] + +const rdsMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 4.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 3.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 6.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 2.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 1.333, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 94.99, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 90.70, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 99.20, + "Unit": "Percent" + }, + ] + } +] + +const createCache = (instance, metrics) => { + if (instance && instance.length) var id = instance[0].DBInstanceIdentifier; + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: instance, + }, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': { + [id]: { + data: metrics + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getRdsMetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('overutilizedRDSInstance', function () { + describe('run', function () { + it('should PASS if the RDS Instance cpu utilization is less than 90 percent', function (done) { + const cache = createCache([describeDBInstances[0]], rdsMetricStatistics[0]); + overutilizedRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if the RDS Instance cpu utilization is more than 90 percent', function (done) { + const cache = createCache([describeDBInstances[1]], rdsMetricStatistics[1]); + overutilizedRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + overutilizedRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No RDS instances found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe RDS Instance', function (done) { + const cache = createErrorCache(); + overutilizedRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for RDS instances: '); + done(); + }); + }); + + it('should not return any results if describe RDS Instance response not found', function (done) { + const cache = createNullCache(); + overutilizedRDSInstance.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsAutomatedBackups.js b/plugins/aws/rds/rdsAutomatedBackups.js index 8fc86ebdb..6577b439c 100644 --- a/plugins/aws/rds/rdsAutomatedBackups.js +++ b/plugins/aws/rds/rdsAutomatedBackups.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Automated Backups', category: 'RDS', + domain: 'Databases', + severity: 'High', description: 'Ensures automated backups are enabled for RDS instances', more_info: 'AWS provides a simple method of backing up RDS instances at a regular interval. This should be enabled to provide an option for restoring data in the event of a database compromise or hardware failure.', link: 'http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html', @@ -17,6 +19,7 @@ module.exports = { default: 6 } }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance','rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var config = { @@ -51,7 +54,7 @@ module.exports = { var db = describeDBInstances.data[i]; var dbResource = db.DBInstanceArn; - // skip if it is read only replica Source Indentifier for PostgreSQL + // skip if it is read only replica Source Identifier for PostgreSQL if (db.Engine === 'postgresql' && db.ReadReplicaSourceDBInstanceIdentifier){ continue; } diff --git a/plugins/aws/rds/rdsAutomatedBackups.spec.js b/plugins/aws/rds/rdsAutomatedBackups.spec.js new file mode 100644 index 000000000..9d8297830 --- /dev/null +++ b/plugins/aws/rds/rdsAutomatedBackups.spec.js @@ -0,0 +1,414 @@ +var expect = require('chai').expect; +var rdsAutomatedBackups = require('./rdsAutomatedBackups'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": false, + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgresql", + "ReadReplicaSourceDBInstanceIdentifier": "test-db-115", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + +const createCache = (rdsInstances) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + } + }; +}; + +describe('rdsAutomatedBackups', function () { + describe('run', function () { + it('should PASS if no RDS DB instance is found', function (done) { + const cache = createCache([]); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + done(); + }); + }); + + it('should PASS if automated backups are enabled and retention period is greater than 6 days', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('Automated backups are enabled with sufficient retention'); + done(); + }); + }); + + it('should PASS if automated backups are enabled and retention period is less than 6 days', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).includes('Automated backups are enabled but do not have sufficient retention'); + done(); + }); + }); + + it('should FAIL if automated backups are not enabled', function (done) { + const cache = createCache([describeDBInstances[2]]); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('Automated backups are not enabled'); + done(); + }); + }); + + it('should not return anything if the instance is a read only replica Source Identifier for PostgreSQL', function (done) { + const cache = createCache([describeDBInstances[3]]); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances', function (done) { + const cache = createErrorCache(); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + rdsAutomatedBackups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsCmkEncryptionEnabled.js b/plugins/aws/rds/rdsCmkEncryptionEnabled.js new file mode 100644 index 000000000..c131d5d1e --- /dev/null +++ b/plugins/aws/rds/rdsCmkEncryptionEnabled.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS CMK Encryption', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensures RDS instances are encrypted with KMS Customer Master Keys(CMKs).', + more_info: 'RDS instances should be encrypted with Customer Master Keys in order to have full control over data encryption and decryption.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html', + recommended_action: 'RDS does not currently allow modifications to encryption after the instance has been launched, so a new instance will need to be created with KMS CMK encryption enabled.', + apis: ['RDS:describeDBInstances', 'KMS:listAliases'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3', 'rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb){ + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query for RDS DB instances: ${helpers.addError(describeDBInstances)}`, region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instance found', region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var aliasId; + var kmsAliases = {}; + listAliases.data.forEach(function(alias){ + aliasId = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliases[aliasId] = alias.AliasName; + }); + + for (var i in describeDBInstances.data) { + var db = describeDBInstances.data[i]; + var dbResource = db.DBInstanceArn; + + if (db.StorageEncrypted && db.KmsKeyId) { + if (kmsAliases[db.KmsKeyId]) { + if (kmsAliases[db.KmsKeyId] === 'alias/aws/rds'){ + helpers.addResult(results, 2, + `RDS DB instance "${db.DBInstanceIdentifier}" is not using Customer Master Key for encryption`, + region, dbResource); + } else { + helpers.addResult(results, 0, + `RDS DB instance "${db.DBInstanceIdentifier}" is using Customer Master Key for encryption`, + region, dbResource); + } + } else { + helpers.addResult(results, 2, + `RDS DB instance encryption key "${db.KmsKeyId}" not found`, + region, dbResource); + } + } else { + helpers.addResult(results, 2, + `RDS instance "${db.DBInstanceIdentifier}" does not have encryption at rest enabled`, + region, dbResource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsCmkEncryptionEnabled.spec.js b/plugins/aws/rds/rdsCmkEncryptionEnabled.spec.js new file mode 100644 index 000000000..c95e3b9db --- /dev/null +++ b/plugins/aws/rds/rdsCmkEncryptionEnabled.spec.js @@ -0,0 +1,361 @@ +var expect = require('chai').expect; +var rdsCmkEncryptionEnabled = require('./rdsCmkEncryptionEnabled'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 0, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 0, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 0, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": false, + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + +const listAliases = [ + { + "AliasArn": "arn:aws:kms:us-east-1:012345678910:alias/example1", + "AliasName": "custom/key", + "TargetKeyId": "abcdef10-1517-49d8-b085-77c50b904149" + }, + { + "AliasArn": "arn:aws:kms:us-east-1:012345678910:alias/customRdsKey", + "AliasName": "alias/aws/rds", + "TargetKeyId": "abcdef10-1517-49d8-b085-77c50b904149" + } +]; + +const createCache = (rdsInstances, kmsAliases) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + }, + kms: { + listAliases: { + 'us-east-1': { + err: null, + data: kmsAliases + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + }, + kms: { + listAliases: { + 'us-east-1': { + err: { + message: 'error while listing KMS aliases' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + kms: { + listAliases: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rdsCmkEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if RDS DB instance is using Customer Master Key for encryption', function (done) { + const cache = createCache([describeDBInstances[0]], [listAliases[0]]); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if RDS DB instance is not using Customer Master Key for encryption', function (done) { + const cache = createCache([describeDBInstances[0]], [listAliases[1]]); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if RDS DB instance encryption key is not found', function (done) { + const cache = createCache([describeDBInstances[1]], [listAliases[1]]); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if RDS DB instance does not have encryption enabled', function (done) { + const cache = createCache([describeDBInstances[2]], [listAliases[1]]); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances', function (done) { + const cache = createErrorCache(); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + rdsCmkEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/rds/rdsDefaultPort.js b/plugins/aws/rds/rdsDefaultPort.js new file mode 100644 index 000000000..8b2494067 --- /dev/null +++ b/plugins/aws/rds/rdsDefaultPort.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Default Port', + category: 'RDS', + domain: 'Databases', + severity: 'Low', + description: 'Ensure RDS database instances are not using the default ports.', + more_info: 'Using default ports for running RDS instances can be a security risk. To protect publicly accessible RDS databases from brute force and dictionary attacks and add an additional layer of security, shift RDS instance ports to non-default ones.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_CommonTasks.Connect.html', + recommended_action: 'Change the default port number of the RDS instance to non-default port.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var defaultPorts = [ + { 'engine': 'mariadb', 'port': 3306 }, + { 'engine': 'mysql', 'port': 3306 }, + { 'engine': 'oracle', 'port': 1521 }, + { 'engine': 'postgres', 'port': 5432 }, + { 'engine': 'sqlserver', 'port': 1433 }, + ]; + + async.each(regions.rds, function(region, rcb){ + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + for (var instance of describeDBInstances.data) { + if (!instance.DBInstanceArn || !instance.Engine || + !(instance.Endpoint && instance.Endpoint.Port)) continue; + var defaultPort = defaultPorts.filter((d) => { + return instance.Engine.toLowerCase().includes(d.engine) && d.port == instance.Endpoint.Port; + }); + + if (defaultPort && defaultPort.length) { + helpers.addResult(results, 2, 'RDS instance is running on default port', + region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'RDS instance is not running on default port', + region, instance.DBInstanceArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsDefaultPort.spec.js b/plugins/aws/rds/rdsDefaultPort.spec.js new file mode 100644 index 000000000..25271de6c --- /dev/null +++ b/plugins/aws/rds/rdsDefaultPort.spec.js @@ -0,0 +1,339 @@ +var expect = require('chai').expect; +var rdsDefaultPort = require('./rdsDefaultPort'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "mysql", + "DBInstanceStatus": "creating", + "MasterUsername": "mysql", + "Endpoint": { + "Address": "eatee-db.cxvgidrqjubc.us-east-1.rds.amazonaws.com", + "Port": 5432, + "HostedZoneId": "Z2R2ITUGPM61AM" + }, + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 2)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "Endpoint": { + "Address": "eatee-db.cxvgidrqjubc.us-east-1.rds.amazonaws.com", + "Port": 5432, + "HostedZoneId": "Z2R2ITUGPM61AM" + }, + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 7)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 25)).toISOString(), + "BackupRetentionPeriod": 2, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": true, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + + +const createCache = (rdsInstances) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rdsDefaultPort', function () { + describe('run', function () { it('should PASS if RDS does not have a default port.', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('RDS instance is not running on default port'); + done(); + }); + }); + + + it('should FAIL if RDS instance has a default port.', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('RDS instance is running on default port'); + done(); + }); + }); + + it('should PASS if no RDS instance found.', function (done) { + const cache = createCache([]); + rdsDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('No RDS instances found'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances', function (done) { + const cache = createErrorCache(); + rdsDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + rdsDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/rds/rdsDefaultUsername.js b/plugins/aws/rds/rdsDefaultUsername.js new file mode 100644 index 000000000..b54b5a8a1 --- /dev/null +++ b/plugins/aws/rds/rdsDefaultUsername.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Instance Default Master Username', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures RDS instance does not have a default master username', + more_info: 'By default, RDS uses the default master username which has the maximum permissions for your instance. RDS instances should be configured to use unique username to ensure security.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateDBInstance.html', + recommended_action: 'Create a new RDS instance with the desired username, and migrate the database to the new instance.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + var defaultusername = ['admin','postgres']; + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn || !instance.MasterUsername) return; + + if (defaultusername.includes(instance.MasterUsername)) { + helpers.addResult(results, 2, 'RDS instance has a default master username', + region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'RDS instance does not have a default master username', + region, instance.DBInstanceArn); + } + }); + + rcb(); + },function(){ + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/aws/rds/rdsDefaultUsername.spec.js b/plugins/aws/rds/rdsDefaultUsername.spec.js new file mode 100644 index 000000000..d34bed731 --- /dev/null +++ b/plugins/aws/rds/rdsDefaultUsername.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var rdsDefaultUserName = require('./rdsDefaultUsername'); + +const describeDBInstances=[ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + + }, + { + DBInstanceIdentifier: 'test-2', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'Fatima', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-2', + + }, + { + DBInstanceIdentifier: 'test-3', + DBInstanceClass: 'db.t3.micro', + Engine: 'mysql', + DBInstanceStatus: 'available', + MasterUsername: 'admin', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-3', + + } +] +const createCache=(data)=>{ + return{ + rds:{ + describeDBInstances:{ + 'us-east-1':{ + data:data, + err:null + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds:{ + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error describing rds instances' + }, + }, + } + }, + }; +}; + +describe('rdsDefaultUserName',function(){ + describe('run',function(){ + + it('should give pass if the rds instance username is not default',function(done){ + const cache=createCache([describeDBInstances[1]]); + rdsDefaultUserName.run(cache,{},(err,results)=> { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS instance does not have a default master username'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should fail if the username is default of the rds instace',function(done){ + const cache=createCache([describeDBInstances[0]]); + rdsDefaultUserName.run(cache,{},(err,results)=>{ + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance has a default master username'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + rdsDefaultUserName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNWON unable to describe RDS Instances', function (done) { + const cache = createErrorCache(); + rdsDefaultUserName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).includes('Unable to query for RDS instances: '); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsDeletionProtectionEnabled.js b/plugins/aws/rds/rdsDeletionProtectionEnabled.js new file mode 100644 index 000000000..8f10b1409 --- /dev/null +++ b/plugins/aws/rds/rdsDeletionProtectionEnabled.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Deletion Protection Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures deletion protection is enabled for RDS database instances.', + more_info: 'Deletion protection prevents Amazon RDS instances from being deleted accidentally by any user.', + link: 'https://aws.amazon.com/about-aws/whats-new/2018/09/amazon-rds-now-provides-database-deletion-protection/', + recommended_action: 'Modify the RDS instances to enable deletion protection.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn) return; + + if (instance.DeletionProtection) { + helpers.addResult(results, 0, + 'RDS instance has deletion protection enabled', region, instance.DBInstanceArn); + } else { + helpers.addResult(results, 2, + 'RDS instance does not have deletion protection enabled', region, instance.DBInstanceArn); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsDeletionProtectionEnabled.spec.js b/plugins/aws/rds/rdsDeletionProtectionEnabled.spec.js new file mode 100644 index 000000000..b81e341d7 --- /dev/null +++ b/plugins/aws/rds/rdsDeletionProtectionEnabled.spec.js @@ -0,0 +1,106 @@ +const expect = require('chai').expect; +var rdsDeletionProtectionEnabled = require('./rdsDeletionProtectionEnabled'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "database-1", + "Engine": "mysql", + "DBInstanceStatus": "available", + "DBInstanceArn": "arn:aws:rds:us-east-1:560213429563:db:database-1", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "DeletionProtection": true, + "AssociatedRoles": [], + "TagList": [], + "CustomerOwnedIpEnabled": false + }, + { + "DBInstanceIdentifier": "database-1", + "Engine": "mysql", + "DBInstanceStatus": "available", + "DBInstanceArn": "arn:aws:rds:us-east-1:560213429563:db:database-1", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "DeletionProtection": false, + "AssociatedRoles": [], + "TagList": [], + "CustomerOwnedIpEnabled": false + } +]; + +const createCache = (instanceData, instanceErr) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: instanceData, + err: instanceErr + } + } + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null + } + } + }; +}; + +describe('rdsDeletionProtectionEnabled', function () { + describe('run', function () { + + it('should PASS if RDS instance has deletion protection enabled', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if RDS instance does not have deletion protection enabled', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS instances found', function (done) { + const cache = createCache([]); + rdsDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe RDS instances', function (done) { + const cache = createCache([], { message: 'Unable to describe instances' }); + rdsDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + + it('should not return anything if describe RDS instances response not found', function (done) { + const cache = createNullCache(); + rdsDeletionProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/rds/rdsEncryptionEnabled.js b/plugins/aws/rds/rdsEncryptionEnabled.js index 9415d850b..0406e2144 100644 --- a/plugins/aws/rds/rdsEncryptionEnabled.js +++ b/plugins/aws/rds/rdsEncryptionEnabled.js @@ -4,17 +4,25 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Encryption Enabled', category: 'RDS', + domain: 'Databases', + severity: 'High', description: 'Ensures at-rest encryption is setup for RDS instances', more_info: 'AWS provides at-read encryption for RDS instances which should be enabled to ensure the integrity of data stored within the databases.', link: 'http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html', recommended_action: 'RDS does not currently allow modifications to encryption after the instance has been launched, so a new instance will need to be created with encryption enabled.', - apis: ['RDS:describeDBInstances', 'KMS:listAliases'], + apis: ['RDS:describeDBInstances', 'KMS:listAliases', 'KMS:listKeys', 'KMS:describeKey'], settings: { rds_encryption_kms_alias: { name: 'RDS Encryption KMS Alias', description: 'If set, RDS encryption must be configured using the KMS key alias specified. Be sure to include the alias/ prefix.', regex: '^alias/[a-zA-Z0-9_/-]{0,256}$', - default: false + default: '' + }, + rds_encryption_level: { + name: 'RDS Minimum Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', } }, compliance: { @@ -26,12 +34,17 @@ module.exports = { 'encryption should be enabled for all instances storing this type ' + 'of data.' }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var config = { rds_encryption_kms_alias: settings.rds_encryption_kms_alias || this.settings.rds_encryption_kms_alias.default, + desiredEncryptionLevelString: settings.rds_encryption_level || this.settings.rds_encryption_level.default }; + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + var custom = helpers.isCustom(settings, this.settings); var results = []; @@ -62,7 +75,7 @@ module.exports = { if (config.rds_encryption_kms_alias) { if (!listAliases || listAliases.err || !listAliases.data) { - helpers.addResult(results, 3, 'RDS KMS alias setting is configured but KMS aliases could not be obtained: ' + helpers.addError(listAliases), region, null, custom); + helpers.addResult(results, 3, `RDS KMS alias setting is configured but KMS aliases could not be obtained: ${helpers.addError(listAliases)}`, region, null, custom); return rcb(); } @@ -78,7 +91,7 @@ module.exports = { }); if (!aliasId) { - helpers.addResult(results, 2, 'RDS KMS alias setting is configured but the specified alias (' + config.rds_encryption_kms_alias + ') was not found.', region, null, custom); + helpers.addResult(results, 2, `RDS KMS alias setting is configured but the specified alias (${config.rds_encryption_kms_alias}) was not found.`, region, null, custom); return rcb(); } } @@ -90,14 +103,46 @@ module.exports = { var kmsKey = db.KmsKeyId; if (db.StorageEncrypted) { + var keyId = kmsKey.split('/')[1]; + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, region); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; if (config.rds_encryption_kms_alias) { if (aliasId == kmsKey) { - helpers.addResult(results, 0, 'Encryption at rest is enabled via expected KMS key: ' + (kmsKey || 'Unknown'), region, dbResource, custom); + if (desiredEncryptionLevel <= currentEncryptionLevel) { + helpers.addResult(results, 0, + `Encryption at rest is enabled via expected KMS key: ${(kmsKey || 'Unknown')} at level ${currentEncryptionLevelString} which is ` + + `greater than or equal to the desired level ${config.desiredEncryptionLevelString}`, + region, dbResource, custom); + } else { + helpers.addResult(results, 2, + `Encryption at rest is enabled via expected KMS key: ${(kmsKey || 'Unknown')} at level ${currentEncryptionLevelString} which is ` + + `less than the desired level ${config.desiredEncryptionLevelString}`, + region, dbResource, custom); + } } else { - helpers.addResult(results, 2, 'Encryption at rest is enabled, but is not using expected KMS key: ' + aliasId + '. Using key: ' + (kmsKey || 'Unknown'), region, dbResource, custom); + helpers.addResult(results, 2, + `Encryption at rest is enabled, but is not using expected KMS key: ${aliasId}. Using key: ${(kmsKey || 'Unknown')}`, + region, dbResource, custom); } } else { - helpers.addResult(results, 0, 'Encryption at rest is enabled via KMS key: ' + (kmsKey || 'Unknown'), region, dbResource); + if (desiredEncryptionLevel <= currentEncryptionLevel) { + helpers.addResult(results, 0, + `Encryption at rest is enabled via KMS key: ${(kmsKey || 'Unknown')} at level ${currentEncryptionLevelString} which is ` + + `greater than or equal to the desired level ${config.desiredEncryptionLevelString}`, + region, dbResource); + } else { + helpers.addResult(results, 2, + `Encryption at rest is enabled via KMS key: ${(kmsKey || 'Unknown')} at level ${currentEncryptionLevelString} which is ` + + `less than the desired level ${config.desiredEncryptionLevelString}`, + region, dbResource); + } } } else { helpers.addResult(results, 2, 'Encryption at rest is not enabled', region, dbResource); diff --git a/plugins/aws/rds/rdsEncryptionEnabled.spec.js b/plugins/aws/rds/rdsEncryptionEnabled.spec.js index 17ff27bb9..22e0dfca6 100644 --- a/plugins/aws/rds/rdsEncryptionEnabled.spec.js +++ b/plugins/aws/rds/rdsEncryptionEnabled.spec.js @@ -2,7 +2,36 @@ var assert = require('assert'); var expect = require('chai').expect; var rds = require('./rdsEncryptionEnabled'); -const createCache = (rdsData, kmsData) => { +const listKeys = [ + { + KeyId: '60c4f21b-e271-4e97-86ae-6403618a9467', + KeyArn: 'arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467' + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "112233445566", + "KeyId": "60c4f21b-e271-4e97-86ae-6403618a9467", + "Arn": "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", + "CreationDate": "2020-03-25T14:05:09.299Z", + "Enabled": true, + "Description": "Used for S3 encryption", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (rdsData, kmsData, listKeys, describeKey) => { + var keyId = (listKeys && listKeys.length) ? listKeys[0].KeyId : null; return { rds: { describeDBInstances: { @@ -18,6 +47,18 @@ const createCache = (rdsData, kmsData) => { err: null, data: kmsData } + }, + listKeys: { + 'us-east-1': { + data: listKeys + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + } + } } } } @@ -53,10 +94,12 @@ describe('rdsEncryptionEnabled', function () { { Engine: 'mysql', StorageEncrypted: true, - KmsKeyId: "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + KmsKeyId: "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", } ], - [] + [], + listKeys, + describeKey[0] ); rds.run(cache, {}, callback); @@ -96,10 +139,12 @@ describe('rdsEncryptionEnabled', function () { { Engine: 'mysql', StorageEncrypted: true, - KmsKeyId: "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + KmsKeyId: "arn:aws:kms:us-east-1:112233445566:key/abcdef10-1517-49d8-b085-77c50b904149", } ], - [] + [], + listKeys, + describeKey[0] ); rds.run(cache, { @@ -120,16 +165,18 @@ describe('rdsEncryptionEnabled', function () { { Engine: 'mysql', StorageEncrypted: true, - KmsKeyId: "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + KmsKeyId: "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", } ], [ { - AliasArn: "arn:aws:kms:us-east-1:012345678910:alias/example1", + AliasArn: "arn:aws:kms:us-east-1:112233445566:alias/example1", AliasName: "alias/example1", TargetKeyId: "def1234a-62d0-46c5-a7c0-5f3a3d2f8046" } - ] + ], + listKeys, + describeKey[0] ); rds.run(cache, { @@ -150,16 +197,18 @@ describe('rdsEncryptionEnabled', function () { { Engine: 'mysql', StorageEncrypted: true, - KmsKeyId: "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + KmsKeyId: "arn:aws:kms:us-east-1:112233445566:key/60c4f21b-e271-4e97-86ae-6403618a9467", } ], [ { - AliasArn: "arn:aws:kms:us-east-1:012345678910:alias/example1", + AliasArn: "arn:aws:kms:us-east-1:112233445566:alias/example1", AliasName: "alias/example1", - TargetKeyId: "abcdef10-1517-49d8-b085-77c50b904149" + TargetKeyId: "60c4f21b-e271-4e97-86ae-6403618a9467" } - ] + ], + listKeys, + describeKey[0] ); rds.run(cache, { diff --git a/plugins/aws/rds/rdsInstanceGeneration.js b/plugins/aws/rds/rdsInstanceGeneration.js new file mode 100644 index 000000000..92cb828cd --- /dev/null +++ b/plugins/aws/rds/rdsInstanceGeneration.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Instance Generation', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that AWS RDS instance is not using older generation of EC2', + more_info: 'Amazon RDS instances running on older generation EC2 instances may not have access to the latest hardware capabilities and performance improvements. It is recommended to upgrade the RDS instance to its latest generation for optimal performance and security.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.DBInstanceClass.html', + recommended_action: 'Upgrade the instance to its latest generation.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var olderGenerations = [ + 'db.t1.micro', + 'db.m1.small', + 'db.m1.medium', + 'db.m1.large', + 'db.m1.xlarge', + 'db.m2.xlarge', + 'db.m2.2xlarge', + 'db.m2.4xlarge' + ]; + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + describeDBInstances.data.forEach(function(Rds){ + var dbInstanceClass = Rds.DBInstanceClass; + + if (olderGenerations.includes(dbInstanceClass)){ + helpers.addResult(results, 2, 'RDS instance is using an older generation of EC2: ' + dbInstanceClass, region, Rds.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'RDS instance is using current generation of EC2: ' + dbInstanceClass, region, Rds.DBInstanceArn); + } + }); + + rcb(); + },function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/rds/rdsInstanceGeneration.spec.js b/plugins/aws/rds/rdsInstanceGeneration.spec.js new file mode 100644 index 000000000..e710e28a5 --- /dev/null +++ b/plugins/aws/rds/rdsInstanceGeneration.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +const rdsInstanceGeneration = require('./rdsInstanceGeneration'); + +const describeDBInstances = [ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t1.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-112223344', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-112223344', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + TagList: [], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, + { + DBInstanceIdentifier: 'test2-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test2-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-112223344', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-112223344', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test2-1', + TagList: [{key: "Key", value: "value"}], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, +]; + +const createCache = (groups) => { + return { + rds:{ + describeDBInstances: { + 'us-east-1': { + data: groups, + err: null + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds:{ + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error describing rds instances' + }, + }, + } + }, + }; +}; + + +describe('rdsInstanceGeneration', function () { + describe('run', function () { + it('should PASS if RDS Instance is using current generation', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS instance is using current generation of EC2: '); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if RDS Instance is using older generation', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance is using an older generation of EC2: '); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + rdsInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNWON unable to describe RDS Instances', function (done) { + const cache = createErrorCache(); + rdsInstanceGeneration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).includes('Unable to query for RDS instances: '); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/rds/rdsInstanceHasTags.js b/plugins/aws/rds/rdsInstanceHasTags.js new file mode 100644 index 000000000..b57d406c5 --- /dev/null +++ b/plugins/aws/rds/rdsInstanceHasTags.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Instance Has Tags', + category: 'RDS', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that AWS RDS instance have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_Tagging.html', + recommended_action: 'Modify the RDS instance to add tags.', + apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:AddTagsToResource', 'rds:RemoveTagsToResource', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + for ( var rdsInstance of describeDBInstances.data){ + if (!rdsInstance.TagList || !rdsInstance.TagList.length){ + helpers.addResult(results, 2, 'RDS instance does not have any tags', + region, rdsInstance.DBInstanceArn); + } else { + helpers.addResult(results, 0, 'RDS instance has tags', region, rdsInstance.DBInstanceArn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsInstanceHasTags.spec.js b/plugins/aws/rds/rdsInstanceHasTags.spec.js new file mode 100644 index 000000000..b81279a60 --- /dev/null +++ b/plugins/aws/rds/rdsInstanceHasTags.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +const rdsInstanceHasTags = require('./rdsInstanceHasTags'); + +const describeDBInstances = [ + { + DBInstanceIdentifier: 'test-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-112223344', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-112223344', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test-1', + TagList: [], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, + { + DBInstanceIdentifier: 'test2-1', + DBInstanceClass: 'db.t3.micro', + Engine: 'postgres', + DBInstanceStatus: 'available', + MasterUsername: 'postgres', + Endpoint: { + Address: 'test2-1.cscif9l5pu36.us-east-1.rds.amazonaws.com', + Port: 5432, + HostedZoneId: 'Z2R2ITUGPM61AM' + }, + AvailabilityZone: 'us-east-1a', + DBSubnetGroup: { + DBSubnetGroupName: 'default-vpc-112223344', + DBSubnetGroupDescription: 'Created from the Neptune Management Console', + VpcId: 'vpc-112223344', + SubnetGroupStatus: 'Complete', + Subnets: [Array], + SupportedNetworkTypes: [] + }, + PreferredMaintenanceWindow: 'mon:07:45-mon:08:15', + PendingModifiedValues: {}, + StorageEncrypted: true, + DBInstanceArn: 'arn:aws:rds:us-east-1:5566441122:db:test2-1', + TagList: [{key: "Key", value: "value"}], + DBInstanceAutomatedBackupsReplications: [], + CustomerOwnedIpEnabled: false, + ActivityStreamStatus: 'stopped', + BackupTarget: 'region', + NetworkType: 'IPV4' + }, +]; + +const createCache = (groups) => { + return { + rds:{ + describeDBInstances: { + 'us-east-1': { + data: groups, + err: null + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds:{ + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error describing rds instances' + }, + }, + } + }, + }; +}; + + +describe('rdsInstanceHasTags', function () { + describe('run', function () { + it('should PASS if RDS Instance has Tags', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if RDS Instance does not Tags', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + rdsInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNWON unable to describe RDS Instances', function (done) { + const cache = createErrorCache(); + rdsInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/rds/rdsLoggingEnabled.js b/plugins/aws/rds/rdsLoggingEnabled.js index 125ee9089..cba510ef2 100755 --- a/plugins/aws/rds/rdsLoggingEnabled.js +++ b/plugins/aws/rds/rdsLoggingEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Logging Enabled', category: 'RDS', + domain: 'Databases', + severity: 'Low', description: 'Ensures logging is configured for RDS instances', more_info: 'Logging database level events enables teams to analyze events for the purpose diagnostics as well as audit tracking for compliance purposes.', link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_LogAccess.html', recommended_action: 'Modify the RDS instance to enable logging as required.', apis: ['RDS:describeDBInstances', 'RDS:describeDBEngineVersions'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var results = []; @@ -56,8 +59,7 @@ module.exports = { var matchingDBEngineVersions = eligibleDBEngineVersions.filter(function(dbEngine) { return dbEngine.Engine === db.Engine && dbEngine.EngineVersion === db.EngineVersion; }); - if (matchingDBEngineVersions.length) - { + if (matchingDBEngineVersions.length) { helpers.addResult(results, 2, 'Logging is not enabled', region, dbResource); } else { helpers.addResult(results, 0, 'Logging is not enabled, but cannot be enabled', region, dbResource); diff --git a/plugins/aws/rds/rdsLoggingEnabled.spec.js b/plugins/aws/rds/rdsLoggingEnabled.spec.js new file mode 100644 index 000000000..921951473 --- /dev/null +++ b/plugins/aws/rds/rdsLoggingEnabled.spec.js @@ -0,0 +1,531 @@ +var expect = require('chai').expect; +var rdsLoggingEnabled = require('./rdsLoggingEnabled'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [ "postgresql" ], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": false, + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgresql", + "ReadReplicaSourceDBInstanceIdentifier": "test-db-115", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + +const describeDBEngineVersions = [ + { + "Engine": "postgres", + "EngineVersion": "12.3", + "DBParameterGroupFamily": "postgres9.6", + "DBEngineDescription": "PostgreSQL", + "DBEngineVersionDescription": "PostgreSQL 9.6.1-R1", + "ValidUpgradeTarget": [ + { + "Engine": "postgres", + "EngineVersion": "10.18", + "Description": "PostgreSQL 10.18-R1", + "AutoUpgrade": false, + "IsMajorVersionUpgrade": true + } + ], + "SupportsLogExportsToCloudwatchLogs": true, + "SupportsReadReplica": true, + "SupportedFeatureNames": [], + "Status": "available", + "SupportsParallelQuery": false, + "SupportsGlobalDatabases": false, + "MajorEngineVersion": "9.6" + }, + { + "Engine": "postgres", + "EngineVersion": "9.6.2", + "DBParameterGroupFamily": "postgres9.6", + "DBEngineDescription": "PostgreSQL", + "DBEngineVersionDescription": "PostgreSQL 9.6.2-R1", + "ValidUpgradeTarget": [ + { + "Engine": "postgres", + "EngineVersion": "10.18", + "Description": "PostgreSQL 10.18-R1", + "AutoUpgrade": false, + "IsMajorVersionUpgrade": true + } + ], + "SupportsReadReplica": true, + "SupportedFeatureNames": [], + "Status": "available", + "SupportsParallelQuery": false, + "SupportsGlobalDatabases": false, + "MajorEngineVersion": "9.6" + }, + { + "Engine": "postgres", + "EngineVersion": "9.6.6", + "DBParameterGroupFamily": "postgres9.6", + "DBEngineDescription": "PostgreSQL", + "DBEngineVersionDescription": "PostgreSQL 9.6.6-R1", + "ValidUpgradeTarget": [ + { + "Engine": "postgres", + "EngineVersion": "9.6.8", + "Description": "PostgreSQL 9.6.8-R1", + "AutoUpgrade": false, + "IsMajorVersionUpgrade": false + } + ], + "ExportableLogTypes": [ + "postgresql", + "upgrade" + ], + "SupportsLogExportsToCloudwatchLogs": true, + "SupportsReadReplica": true, + "SupportedFeatureNames": [], + "Status": "available", + "SupportsParallelQuery": false, + "SupportsGlobalDatabases": false, + "MajorEngineVersion": "9.6" + }, + { + "Engine": "postgres", + "EngineVersion": "9.6.16", + "DBParameterGroupFamily": "postgres9.6", + "DBEngineDescription": "PostgreSQL", + "DBEngineVersionDescription": "PostgreSQL 9.6.16-R1", + "ValidUpgradeTarget": [ + { + "Engine": "postgres", + "EngineVersion": "9.6.17", + "Description": "PostgreSQL 9.6.17-R1", + "AutoUpgrade": false, + "IsMajorVersionUpgrade": false + }, + { + "Engine": "postgres", + "EngineVersion": "9.6.18", + "Description": "PostgreSQL 9.6.18-R1", + "AutoUpgrade": false, + "IsMajorVersionUpgrade": false + } + ], + "ExportableLogTypes": [ + "postgresql", + "upgrade" + ], + "SupportsLogExportsToCloudwatchLogs": true, + "SupportsReadReplica": true, + "SupportedFeatureNames": [], + "Status": "available", + "SupportsParallelQuery": false, + "SupportsGlobalDatabases": false, + "MajorEngineVersion": "9.6" + } +] + +const createCache = (rdsInstances, dbEngineVersions) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + describeDBEngineVersions: { + 'us-east-1': { + err: null, + data: dbEngineVersions + }, + } + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + describeDBEngineVersions: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + describeDBEngineVersions: { + 'us-east-1': null, + } + } + }; +}; + +describe('rdsLoggingEnabled', function () { + describe('run', function () { + it('should PASS if no RDS DB instance is found', function (done) { + const cache = createCache([]); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + done(); + }); + }); + + it('should PASS if RDS Logging is enabled', function (done) { + const cache = createCache([describeDBInstances[0]], [describeDBEngineVersions[0]]); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('Logging is enabled'); + done(); + }); + }); + + it('should PASS if RDS logging is not enabled and cannot be enabled', function (done) { + const cache = createCache([describeDBInstances[2]], [describeDBEngineVersions[1]]); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('Logging is not enabled, but cannot be enabled'); + done(); + }); + }); + + it('should FAIL if RDS logging is not enabled', function (done) { + const cache = createCache([describeDBInstances[2]], [describeDBEngineVersions[0]]); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('Logging is not enabled'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances or engines', function (done) { + const cache = createErrorCache(); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + rdsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsMinorVersionUpgrade.js b/plugins/aws/rds/rdsMinorVersionUpgrade.js index d2fae27dd..5213895d7 100644 --- a/plugins/aws/rds/rdsMinorVersionUpgrade.js +++ b/plugins/aws/rds/rdsMinorVersionUpgrade.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS DocumentDB Minor Version Upgrade', category: 'RDS', + domain: 'Databases', + severity: 'Medium', description: 'Ensures Auto Minor Version Upgrade is enabled on RDS and DocumentDB databases', more_info: 'RDS supports automatically upgrading the minor version of the database, which should be enabled to ensure security fixes are quickly deployed.', link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.Upgrading.html#USER_UpgradeDBInstance.Upgrading.AutoMinorVersionUpgrades', recommended_action: 'Enable automatic minor version upgrades on RDS and DocumentDB databases', apis: ['RDS:describeDBInstances'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/rds/rdsMultiAz.js b/plugins/aws/rds/rdsMultiAz.js index 18d723b13..749e0e18c 100644 --- a/plugins/aws/rds/rdsMultiAz.js +++ b/plugins/aws/rds/rdsMultiAz.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Multiple AZ', category: 'RDS', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that RDS instances are created to be cross-AZ for high availability.', more_info: 'Creating RDS instances in a single AZ creates a single point of failure for all systems relying on that database. All RDS instances should be created in multiple AZs to ensure proper failover.', link: 'http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html', @@ -12,11 +14,12 @@ module.exports = { settings: { rds_multi_az_ignore_replicas: { name: 'RDS Multiple AZ Ignore Replicas', - description: 'When true RDS read replicas will not require multi-AZ configuration', + description: 'When set to true, RDS read replicas which does not require multi-AZ configuration will PASS', regex: '^(true|false)$', default: 'false' } }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/aws/rds/rdsPublicSubnet.js b/plugins/aws/rds/rdsPublicSubnet.js new file mode 100644 index 000000000..20249149c --- /dev/null +++ b/plugins/aws/rds/rdsPublicSubnet.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Public Subnet', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensures RDS database instances are not deployed in public subnet.', + more_info: 'RDS instances should not be deployed in public subnets to prevent direct exposure to the internet and reduce the risk of unauthorized access.', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/rds-instance-subnet-igw-check.html', + recommended_action: 'Replace the subnet groups of rds instance with the private subnets.', + apis: ['RDS:describeDBInstances', 'EC2:describeRouteTables', 'EC2:describeSubnets'], + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb){ + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) return rcb(); + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS instances: ' + helpers.addError(describeDBInstances), region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS instances found', region); + return rcb(); + } + + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + + if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + return rcb(); + } + + if (!describeRouteTables || !describeRouteTables.data || describeRouteTables.err) { + helpers.addResult(results, 3, 'Unable to query for RouteTables: ' + helpers.addError(describeRouteTables), region); + return rcb(); + } + var subnetRouteTableMap; + var privateSubnets = []; + subnetRouteTableMap = helpers.getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = helpers.getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + + describeDBInstances.data.forEach(instance => { + if (!instance.DBInstanceArn ) return; + + const dbResource = instance.DBInstanceArn; + const subnetsData = instance.DBSubnetGroup.Subnets; + const allPrivate = subnetsData.every(subnet => privateSubnets.includes(subnet.SubnetIdentifier)); + + if (allPrivate) { + helpers.addResult(results, 0, 'RDS instance is not in a public subnet', region, dbResource); + } else { + helpers.addResult(results, 2, 'RDS instance is in a public subnet', region, dbResource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsPublicSubnet.spec.js b/plugins/aws/rds/rdsPublicSubnet.spec.js new file mode 100644 index 000000000..be7a2fe15 --- /dev/null +++ b/plugins/aws/rds/rdsPublicSubnet.spec.js @@ -0,0 +1,358 @@ +var expect = require('chai').expect; +var rdsPublicSubnet = require('./rdsPublicSubnet'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 2)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-123", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-123", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 7)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-234", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-234", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, +]; +const describeRouteTables = [ + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [], + "VpcId": "vpc-123", + "OwnerId": "000011112222" + }, + { + "Associations": [ + { + "Main": true, + "RouteTableAssociationId": "rtbassoc-79c7a000", + "RouteTableId": "rtb-f6522690", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "rtb-f6522690", + "Routes": [ + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + }, + { + "DestinationCidrBlock": "172.31.0.0/16", + "GatewayId": "igw-sedwednkq", + "Origin": "CreateRouteTable", + "State": "active" + } + + ], + "Tags": [], + "VpcId": "vpc-234", + "OwnerId": "000011112222" + } +]; + +const describeSubnets = [ + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-123", + "VpcId": "vpc-123", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + }, + { + "AvailabilityZone": "us-east-1c", + "AvailabilityZoneId": "use1-az4", + "AvailableIpAddressCount": 4091, + "CidrBlock": "172.31.16.0/20", + "DefaultForAz": true, + "MapPublicIpOnLaunch": true, + "MapCustomerOwnedIpOnLaunch": false, + "State": "available", + "SubnetId": "subnet-234", + "VpcId": "vpc-234", + "OwnerId": "000011112222", + "AssignIpv6AddressOnCreation": false, + "Ipv6CidrBlockAssociationSet": [], + "SubnetArn": "arn:aws:ec2:us-east-1:000011112222:subnet/subnet-aac6b3e7" + } +]; + +const createCache = (dbInstance, subnets, routeTables) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: dbInstance + }, + }, + }, + ec2: { + describeSubnets: { + 'us-east-1': { + data: subnets + } + }, + describeRouteTables: { + 'us-east-1': { + data: routeTables + } + } + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error describing db Instance' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rdsPublicSubnet', function () { + describe('run', function () { + it('should PASS if RDS instance has private subnets', function (done) { + const cache = createCache([describeDBInstances[0]], [describeSubnets[0]], [describeRouteTables[0]]); + rdsPublicSubnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS instance is not in a public subnet'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if RDS instance has public subnets', function (done) { + const cache = createCache([describeDBInstances[1]], [describeSubnets[1]], [describeRouteTables[1]]); + rdsPublicSubnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance is in a public subnet'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no RDS Instance found', function (done) { + const cache = createCache([]); + rdsPublicSubnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if there unable to query for RDS Innstance', function (done) { + const cache = createErrorCache(); + rdsPublicSubnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).includes('Unable to query for RDS instances: '); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return any results describe RDS Instance response not found', function (done) { + const cache = createNullCache(); + rdsPublicSubnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/rds/rdsPubliclyAccessible.js b/plugins/aws/rds/rdsPubliclyAccessible.js index d196ced5d..8937d450c 100644 --- a/plugins/aws/rds/rdsPubliclyAccessible.js +++ b/plugins/aws/rds/rdsPubliclyAccessible.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Publicly Accessible', category: 'RDS', + domain: 'Databases', + severity: 'Critical', description: 'Ensures RDS instances are not launched into the public cloud', more_info: 'Unless there is a specific business requirement, RDS instances should not have a public endpoint and should be accessed from within a VPC only.', link: 'http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.html', @@ -19,6 +21,7 @@ module.exports = { 'Ensure RDS instances are not accessible from the Internet ' + 'and use proper jump box access mechanisms.' }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/rds/rdsPubliclyAccessible.spec.js b/plugins/aws/rds/rdsPubliclyAccessible.spec.js new file mode 100644 index 000000000..c6dba00fd --- /dev/null +++ b/plugins/aws/rds/rdsPubliclyAccessible.spec.js @@ -0,0 +1,245 @@ +var expect = require('chai').expect; +var rdsPuliclyAccessible = require('./rdsPubliclyAccessible'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": true, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + } +]; + +const createCache = (rdsInstances) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + } + }; +}; + +describe('rdsPuliclyAccessible', function () { + describe('run', function () { + it('should PASS if no RDS instance is found', function (done) { + const cache = createCache([]); + rdsPuliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + done(); + }); + }); + + it('should PASS if RDS instance is not publicly accessible', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsPuliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS instance is not publicly accessible'); + done(); + }); + }); + + it('should FAIL if RDS instance is publicly accessible', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsPuliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance is publicly accessible'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB instances', function (done) { + const cache = createErrorCache(); + rdsPuliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB instances', function (done) { + const cache = createNullCache(); + rdsPuliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsRestorable.js b/plugins/aws/rds/rdsRestorable.js index 7a1f6894e..663a0adad 100644 --- a/plugins/aws/rds/rdsRestorable.js +++ b/plugins/aws/rds/rdsRestorable.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Restorable', category: 'RDS', + domain: 'Databases', + severity: 'Medium', description: 'Ensures RDS instances can be restored to a recent point', more_info: 'AWS will maintain a point to which the database can be restored. This point should not drift too far into the past, or else the risk of irrecoverable data loss may occur.', link: 'http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PIT.html', @@ -28,6 +30,7 @@ module.exports = { default: 6 } }, + realtime_triggers: ['rds:CreateDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], run: function(cache, settings, callback) { var config = { @@ -72,7 +75,7 @@ module.exports = { var dbResource = db.DBInstanceArn; if (db.LatestRestorableTime) { - var difference = helpers.daysAgo(db.LatestRestorableTime); + var difference = helpers.hoursBetween(new Date().toISOString(), db.LatestRestorableTime); var returnMsg = 'RDS instance restorable time is ' + difference + ' hours old'; if (difference > 24) { @@ -115,7 +118,7 @@ module.exports = { var dbResourceCluster = dbCluster.DBClusterArn; if (dbCluster.LatestRestorableTime) { - var differenceCluster = helpers.daysAgo(dbCluster.LatestRestorableTime); + var differenceCluster = helpers.hoursBetween(new Date().toISOString(), dbCluster.LatestRestorableTime); var returnMsgCluster = 'RDS cluster restorable time is ' + differenceCluster + ' hours old'; if (differenceCluster > config.rds_restorable_fail) { diff --git a/plugins/aws/rds/rdsRestorable.spec.js b/plugins/aws/rds/rdsRestorable.spec.js new file mode 100644 index 000000000..956b5dab9 --- /dev/null +++ b/plugins/aws/rds/rdsRestorable.spec.js @@ -0,0 +1,634 @@ +var expect = require('chai').expect; +var rdsRestorable = require('./rdsRestorable'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 2)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 7)).toISOString(), + "BackupRetentionPeriod": 7, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": false, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/abcdef10-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 25)).toISOString(), + "BackupRetentionPeriod": 2, + "DBSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.postgres12", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1f", + "DBSubnetGroup": { + "DBSubnetGroupName": "default-vpc-99de2fe4", + "DBSubnetGroupDescription": "Created from the RDS Management Console", + "VpcId": "vpc-99de2fe4", + "SubnetGroupStatus": "Complete", + "Subnets": [ + { + "SubnetIdentifier": "subnet-aac6b3e7", + "SubnetAvailabilityZone": { + "Name": "us-east-1c" + }, + "SubnetOutpost": {}, + "SubnetStatus": "Active" + } + ] + }, + "PreferredMaintenanceWindow": "fri:10:05-fri:10:35", + "PendingModifiedValues": { + "MasterUserPassword": "****", + "ProcessorFeatures": [] + }, + "MultiAZ": false, + "EngineVersion": "12.3", + "AutoMinorVersionUpgrade": false, + "ReadReplicaDBInstanceIdentifiers": [], + "ReadReplicaDBClusterIdentifiers": [], + "LicenseModel": "postgresql-license", + "OptionGroupMemberships": [ + { + "OptionGroupName": "default:postgres-12", + "Status": "in-sync" + } + ], + "PubliclyAccessible": true, + "StatusInfos": [], + "StorageType": "gp2", + "DbInstancePort": 0, + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:012345678910:key/88888828-1517-49d8-b085-77c50b904149", + "DbiResourceId": "db-ZLVMKFR7AS6SJYTQPXZ4SUH5ZU", + "CACertificateIdentifier": "rds-ca-2019", + "DomainMemberships": [], + "CopyTagsToSnapshot": false, + "MonitoringInterval": 0, + "DBInstanceArn": "arn:aws:rds:us-east-1:111122223333:db:test-db-115", + "IAMDatabaseAuthenticationEnabled": false, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [], + "ProcessorFeatures": [], + "DeletionProtection": false, + "AssociatedRoles": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "postgres", + "DBInstanceStatus": "creating", + "MasterUsername": "postgres", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "AvailabilityZone": "us-east-1f", + "EngineVersion": "12.3", + "PubliclyAccessible": true, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "docdb", + "DBInstanceStatus": "creating", + "MasterUsername": "docdb", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "AvailabilityZone": "us-east-1f", + "EngineVersion": "12.3", + "PubliclyAccessible": true, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [] + }, + { + "DBInstanceIdentifier": "test-db-115", + "DBInstanceClass": "db.m4.large", + "Engine": "aurora-mysql", + "DBInstanceStatus": "creating", + "MasterUsername": "aurora-mysql", + "DBName": "cloudexploit", + "AllocatedStorage": 20, + "PreferredBackupWindow": "09:07-09:37", + "BackupRetentionPeriod": 2, + "AvailabilityZone": "us-east-1f", + "EngineVersion": "5.7.mysql_aurora.2.07.2", + "PubliclyAccessible": true, + "PerformanceInsightsEnabled": false, + "EnabledCloudwatchLogsExports": [] + } +]; + +const describeDBClusters = [ + { + "AllocatedStorage": 1, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1f", + "us-east-1c" + ], + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "MultiAZ": false, + "Engine": "neptune", + "EngineVersion": "1.0.5.1", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 2)).toISOString(), + "Port": 8182, + "MasterUsername": "admin", + "PreferredBackupWindow": "03:20-03:50", + "PreferredMaintenanceWindow": "fri:09:21-fri:09:51", + "ReadReplicaIdentifiers": [], + "DBClusterMembers": [ + { + "DBInstanceIdentifier": "database-2-instance-1", + "IsClusterWriter": true, + "DBClusterParameterGroupStatus": "in-sync", + "PromotionTier": 1 + } + ], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "HostedZoneId": "ZUFXD4SLT2LS7", + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + "AssociatedRoles": [], + }, + { + "AllocatedStorage": 1, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1f", + "us-east-1c" + ], + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "MultiAZ": false, + "Engine": "neptune", + "EngineVersion": "1.0.5.1", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 7)).toISOString(), + "Port": 8182, + "MasterUsername": "admin", + "PreferredBackupWindow": "03:20-03:50", + "PreferredMaintenanceWindow": "fri:09:21-fri:09:51", + "DBClusterMembers": [ + { + "DBInstanceIdentifier": "database-2-instance-1", + "IsClusterWriter": true, + "DBClusterParameterGroupStatus": "in-sync", + "PromotionTier": 1 + } + ], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "HostedZoneId": "ZUFXD4SLT2LS7", + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + }, + { + "AllocatedStorage": 1, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1f", + "us-east-1c" + ], + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "MultiAZ": false, + "Engine": "neptune", + "EngineVersion": "1.0.5.1", + "LatestRestorableTime": new Date(new Date().setHours(new Date().getHours() - 25)).toISOString(), + "Port": 8182, + "MasterUsername": "admin", + "PreferredBackupWindow": "03:20-03:50", + "PreferredMaintenanceWindow": "fri:09:21-fri:09:51", + "DBClusterMembers": [ + { + "DBInstanceIdentifier": "database-2-instance-1", + "IsClusterWriter": true, + "DBClusterParameterGroupStatus": "in-sync", + "PromotionTier": 1 + } + ], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "HostedZoneId": "ZUFXD4SLT2LS7", + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + }, + { + "AllocatedStorage": 1, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1f", + "us-east-1c" + ], + "BackupRetentionPeriod": 1, + "DBClusterIdentifier": "database-2", + "DBClusterParameterGroup": "default.neptune1", + "DBSubnetGroup": "default-vpc-0f4f4575a74fac014", + "Status": "available", + "EarliestRestorableTime": "2021-11-16T09:01:51.536000+00:00", + "Endpoint": "database-2.cluster-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "ReaderEndpoint": "database-2.cluster-ro-cscif9l5pu36.us-east-1.neptune.amazonaws.com", + "MultiAZ": false, + "Engine": "neptune", + "EngineVersion": "1.0.5.1", + "Port": 8182, + "MasterUsername": "admin", + "PreferredBackupWindow": "03:20-03:50", + "PreferredMaintenanceWindow": "fri:09:21-fri:09:51", + "DBClusterMembers": [ + { + "DBInstanceIdentifier": "database-2-instance-1", + "IsClusterWriter": true, + "DBClusterParameterGroupStatus": "in-sync", + "PromotionTier": 1 + } + ], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0cb6c99daaa6b73c5", + "Status": "active" + } + ], + "HostedZoneId": "ZUFXD4SLT2LS7", + "StorageEncrypted": true, + "KmsKeyId": "arn:aws:kms:us-east-1:000111222333:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", + "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", + }, +]; + +const createCache = (rdsInstances, dBClusters) => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: null, + data: rdsInstances + }, + }, + describeDBClusters: { + 'us-east-1': { + err: null, + data: dBClusters + }, + }, + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + describeDBClusters: { + 'us-east-1': { + err: { + message: 'error while describing RDS instances' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': null, + }, + describeDBClusters: { + 'us-east-1': null, + } + } + }; +}; + +describe('rdsRestorable', function () { + describe('run', function () { + it('should PASS if no RDS instance is found', function (done) { + const cache = createCache([]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS instances found'); + done(); + }); + }); + + it('should PASS if RDS instance\'s restorable time is less than 6 hours', function (done) { + const cache = createCache([describeDBInstances[0]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS instance restorable time is'); + done(); + }); + }); + + it('should PASS with warning if RDS instance have a restorable time of greater than 6 hours but less then 24 hours', function (done) { + const cache = createCache([describeDBInstances[1]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).includes('RDS instance restorable time is'); + done(); + }); + }); + + it('should PASS if the db instance is of type docDB', function (done) { + const cache = createCache([describeDBInstances[4]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('DocumentDB engine uses incremental backups'); + done(); + }); + }); + + it('should PASS if RDS cluster\'s restorable time is less than 6 hours', function (done) { + const cache = createCache([describeDBInstances[5]], [describeDBClusters[0]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('RDS cluster restorable time is'); + done(); + }); + }); + + it('should PASS with warning if RDS cluster have a restorable time of greater than 6 hours but less then 24 hours', function (done) { + const cache = createCache([describeDBInstances[5]], [describeDBClusters[1]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).includes('RDS cluster restorable time is'); + done(); + }); + }); + + it('should FAIL if RDS instance have a restorable time of greater than 24 hours', function (done) { + const cache = createCache([describeDBInstances[2]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance restorable time is'); + done(); + }); + }); + + it('should FAIL if RDS instance does not have a restorable time', function (done) { + const cache = createCache([describeDBInstances[3]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS instance does not have a restorable time'); + done(); + }); + }); + + it('should FAIL if RDS db cluster have a restorable time of greater than 24 hours', function (done) { + const cache = createCache([describeDBInstances[5]], [describeDBClusters[2]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS cluster restorable time is'); + done(); + }); + }); + + it('should FAIL if RDS db cluster does not have a restorable time', function (done) { + const cache = createCache([describeDBInstances[5]], [describeDBClusters[3]]); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('RDS cluster does not have a restorable time'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS instances/clusters', function (done) { + const cache = createErrorCache(); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS instances/clusters', function (done) { + const cache = createNullCache(); + rdsRestorable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsSnapshotEncryption.js b/plugins/aws/rds/rdsSnapshotEncryption.js index 8d670645f..734764de1 100644 --- a/plugins/aws/rds/rdsSnapshotEncryption.js +++ b/plugins/aws/rds/rdsSnapshotEncryption.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'RDS Snapshot Encryption', category: 'RDS', + domain: 'Databases', + severity: 'High', description: 'Ensures encryption is enabled for RDS snapshots to ensure encryption of data at rest.', more_info: 'AWS provides encryption for RDS snapshots which should be enabled to ensure that all data at rest is encrypted.', link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html', recommended_action: 'Copy the snapshot to a new snapshot that is encrypted and delete the old snapshot.', apis: ['RDS:describeDBSnapshots'], + realtime_triggers: ['rds:CreateDBSnapshot', 'rds:CopyDBSnapshot', 'rds:DeleteDBSnapshot'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/rds/rdsSnapshotEncryption.spec.js b/plugins/aws/rds/rdsSnapshotEncryption.spec.js new file mode 100644 index 000000000..070ed6bc9 --- /dev/null +++ b/plugins/aws/rds/rdsSnapshotEncryption.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +var rdsSnapshotEncryption = require('./rdsSnapshotEncryption'); + +const describeDBSnapshots = [ + { + "DBSnapshotIdentifier": "rds:database-1-2021-12-12-15-38", + "DBInstanceIdentifier": "database-1", + "SnapshotCreateTime": "2021-12-12T15:39:59.704000+00:00", + "Engine": "mysql", + "AllocatedStorage": 20, + "Status": "available", + "Port": 3306, + "AvailabilityZone": "us-east-1b", + "VpcId": "vpc-0f4f4575a74fac014", + "InstanceCreateTime": "2021-12-12T15:38:41.792000+00:00", + "MasterUsername": "admin", + "EngineVersion": "8.0.23", + "LicenseModel": "general-public-license", + "SnapshotType": "automated", + "OptionGroupName": "default:mysql-8-0", + "PercentProgress": 100, + "StorageType": "gp2", + "Encrypted": true, + "DBSnapshotArn": "arn:aws:rds:us-east-1:112233445566:snapshot:rds:database-1-2021-12-12-15-38", + "IAMDatabaseAuthenticationEnabled": false, + "ProcessorFeatures": [], + "DbiResourceId": "db-SADO63QHGOLHYRTFW57ESZV3YY", + "TagList": [], + "OriginalSnapshotCreateTime": "2021-12-12T15:39:59.704000+00:00", + }, + { + "DBSnapshotIdentifier": "rds:database-1-2021-12-12-15-38", + "DBInstanceIdentifier": "database-1", + "SnapshotCreateTime": "2021-12-12T15:39:59.704000+00:00", + "Engine": "mysql", + "AllocatedStorage": 20, + "Status": "available", + "Port": 3306, + "AvailabilityZone": "us-east-1b", + "VpcId": "vpc-0f4f4575a74fac014", + "InstanceCreateTime": "2021-12-12T15:38:41.792000+00:00", + "MasterUsername": "admin", + "EngineVersion": "8.0.23", + "LicenseModel": "general-public-license", + "SnapshotType": "automated", + "OptionGroupName": "default:mysql-8-0", + "PercentProgress": 100, + "StorageType": "gp2", + "Encrypted": false, + "DBSnapshotArn": "arn:aws:rds:us-east-1:112233445566:snapshot:rds:database-1-2021-12-12-15-38", + "IAMDatabaseAuthenticationEnabled": false, + "ProcessorFeatures": [], + "DbiResourceId": "db-SADO63QHGOLHYRTFW57ESZV3YY", + "TagList": [], + "OriginalSnapshotCreateTime": "2021-12-12T15:39:59.704000+00:00" + } +]; + +const createCache = (dbSnapshots) => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': { + err: null, + data: dbSnapshots + }, + }, + } + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': { + err: { + message: 'error while describing RDS snapshots' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': null, + }, + } + }; +}; + +describe('rdsSnapshotEncryption', function () { + describe('run', function () { + it('should PASS if no RDS instance is found', function (done) { + const cache = createCache([]); + rdsSnapshotEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('No RDS snapshots found'); + done(); + }); + }); + + it('should PASS if RDS snapshot encryption is enabled', function (done) { + const cache = createCache([describeDBSnapshots[0]]); + rdsSnapshotEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).includes('Snapshot encryption is enabled via KMS key'); + done(); + }); + }); + + it('should FAIL if RDS snapshot encryption is not enabled', function (done) { + const cache = createCache([describeDBSnapshots[1]]); + rdsSnapshotEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).includes('Snapshot encryption not enabled'); + done(); + }); + }); + + it('should UNKNOWN if error while describing RDS DB snapshots', function (done) { + const cache = createErrorCache(); + rdsSnapshotEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe RDS DB snapshots', function (done) { + const cache = createNullCache(); + rdsSnapshotEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/rds/rdsSnapshotPubliclyAccessible.js b/plugins/aws/rds/rdsSnapshotPubliclyAccessible.js new file mode 100644 index 000000000..843a33be8 --- /dev/null +++ b/plugins/aws/rds/rdsSnapshotPubliclyAccessible.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Snapshot Publicly Accessible', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensure that Amazon RDS database snapshots are not publicly exposed.', + more_info: 'If an RDS snapshot is exposed to the public, any AWS account can copy the snapshot and create a new database instance from it. ' + + 'It is a best practice to ensure RDS snapshots are not exposed to the public to avoid any accidental leak of sensitive information.', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ShareSnapshot.html', + recommended_action: 'Ensure Amazon RDS database snapshot is not publicly accessible and available for any AWS account to copy or restore it.', + apis: ['RDS:describeDBSnapshots', 'RDS:describeDBSnapshotAttributes'], + realtime_triggers: ['rds:CreateDBSnapshot', 'rds:ModifyDBSnapshotAttribute','rds:DeleteDBSnapshot'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.rds, function(region, rcb){ + var describeDBSnapshots = helpers.addSource(cache, source, + ['rds', 'describeDBSnapshots', region]); + + if (!describeDBSnapshots) return rcb(); + + if (describeDBSnapshots.err || !describeDBSnapshots.data) { + helpers.addResult(results, 3, + 'Unable to query for RDS snapshots: ' + helpers.addError(describeDBSnapshots), region); + return rcb(); + } + + if (!describeDBSnapshots.data.length) { + helpers.addResult(results, 0, 'No RDS snapshots found', region); + return rcb(); + } + + describeDBSnapshots.data.forEach(snapshot => { + if (!snapshot.DBSnapshotIdentifier) return; + + var snapshotIdentifier = snapshot.DBSnapshotIdentifier; + var resource = snapshot.DBSnapshotArn; + + var describeDBSnapshotAttributes = helpers.addSource(cache, settings, + ['rds', 'describeDBSnapshotAttributes', region, snapshotIdentifier]); + + if (!describeDBSnapshotAttributes || + describeDBSnapshotAttributes.err || + !describeDBSnapshotAttributes.data || + !describeDBSnapshotAttributes.data.DBSnapshotAttributesResult) { + helpers.addResult(results, 3, + `Unable to describe Snapshot attributes "${snapshotIdentifier}": ${helpers.addError(describeDBSnapshotAttributes)}`, + region, resource); + + return; + } + + let publicSnapshot; + if (describeDBSnapshotAttributes.data.DBSnapshotAttributesResult.DBSnapshotAttributes) { + publicSnapshot = describeDBSnapshotAttributes.data.DBSnapshotAttributesResult.DBSnapshotAttributes.find( + attribute => attribute.AttributeValues && attribute.AttributeValues.includes('all') + ); + } + + if (publicSnapshot){ + helpers.addResult(results, 2, + 'RDS Snapshot is publicly exposed', + region, resource); + } else { + helpers.addResult(results, 0, + 'RDS Snapshot is not publicly exposed', + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/rds/rdsSnapshotPubliclyAccessible.spec.js b/plugins/aws/rds/rdsSnapshotPubliclyAccessible.spec.js new file mode 100644 index 000000000..9c3471b0b --- /dev/null +++ b/plugins/aws/rds/rdsSnapshotPubliclyAccessible.spec.js @@ -0,0 +1,170 @@ +var expect = require('chai').expect; +const rdsSnapshotPubliclyAccessible = require('./rdsSnapshotPubliclyAccessible'); + +const describeDBSnapshots = [ + { + "DBSnapshotIdentifier": "database-1-final-snapshot", + "DBInstanceIdentifier": "database-1", + "SnapshotCreateTime": "2022-01-24T15:41:26.234Z", + "Engine": "mariadb", + "AllocatedStorage": 20, + "Status": "available", + "Port": 3306, + "AvailabilityZone": "us-east-1a", + "VpcId": "vpc-0f4f4575a74fac014", + "InstanceCreateTime": "2022-01-24T15:27:38.423Z", + "MasterUsername": "admin", + "EngineVersion": "10.5.13", + "LicenseModel": "general-public-license", + "SnapshotType": "manual", + "OptionGroupName": "default:mariadb-10-5", + "PercentProgress": 100, + "StorageType": "gp2", + "Encrypted": false, + "DBSnapshotArn": "arn:aws:rds:us-east-1:000011112222:snapshot:database-1-final-snapshot", + "IAMDatabaseAuthenticationEnabled": false, + "ProcessorFeatures": [], + "DbiResourceId": "db-AVTEMNYVJCF3INR3EROOHGZXQQ", + "TagList": [], + "OriginalSnapshotCreateTime": "2022-01-24T15:41:26.234Z", + "SnapshotTarget": "region" + } +]; + +const describeDBSnapshotAttributes = [ + { + "ResponseMetadata": { + "RequestId": "ef458f54-cf41-4d69-b4aa-014b141138c6" + }, + "DBSnapshotAttributesResult": { + "DBSnapshotIdentifier": "database-1-final-snapshot", + "DBSnapshotAttributes": [ + { + "AttributeName": "restore", + "AttributeValues": [] + } + ] + } + }, + { + "ResponseMetadata": { + "RequestId": "ef458f54-cf41-4d69-b4aa-014b141138c6" + }, + "DBSnapshotAttributesResult": { + "DBSnapshotIdentifier": "database-1-final-snapshot", + "DBSnapshotAttributes": [ + { + "AttributeName": "restore", + "AttributeValues": ["all"] + } + ] + } + } +]; + +const createCache = (snapshot, attribute) => { + let snapshotIdentifier = snapshot && snapshot.length ? snapshot[0].DBSnapshotIdentifier : null + return { + rds:{ + describeDBSnapshots: { + 'us-east-1': { + data: snapshot + }, + }, + describeDBSnapshotAttributes: { + 'us-east-1': { + [snapshotIdentifier]: { + data: attribute + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': { + err: { + message: 'error describing snapshots' + }, + }, + }, + describeDBSnapshotAttributes: { + 'us-east-1': { + err: { + message: 'error describing snapshot attributes' + }, + }, + }, + } + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBSnapshots: { + 'us-east-1': null, + }, + describeDBSnapshotAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rdsSnapshotPubliclyAccessible', function () { + describe('run', function () { + it('should PASS if RDS nnapshot is not publicly accessible', function (done) { + const cache = createCache([describeDBSnapshots[0]], describeDBSnapshotAttributes[0]); + rdsSnapshotPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is not publicly exposed'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if RDS nnapshot is publicly accessible', function (done) { + const cache = createCache([describeDBSnapshots[0]], describeDBSnapshotAttributes[1]); + rdsSnapshotPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('is publicly exposed'); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no RDS nnapshots found', function (done) { + const cache = createCache([]); + rdsSnapshotPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].message).to.include('No RDS snapshots'); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while describing Snapshot attributes', function (done) { + const cache = createErrorCache(); + rdsSnapshotPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if unable to describe Snapshot attributes', function (done) { + const cache = createNullCache(); + rdsSnapshotPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); diff --git a/plugins/aws/rds/rdsTransportEncryption.js b/plugins/aws/rds/rdsTransportEncryption.js new file mode 100644 index 000000000..e6603b8e3 --- /dev/null +++ b/plugins/aws/rds/rdsTransportEncryption.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'RDS Transport Encryption Enabled', + category: 'RDS', + domain: 'Databases', + severity: 'High', + description: 'Ensures RDS SQL Server instances have Transport Encryption enabled.', + more_info: 'Parameter group associated with the RDS instance should have transport encryption enabled to handle encryption and decryption', + link: 'https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html', + recommended_action: 'Update the parameter group associated with the RDS instance to have rds.force_ssl set to true', + apis: ['RDS:describeDBInstances', 'RDS:describeDBParameters', 'RDS:describeDBParameterGroups'], + realtime_triggers: ['rds:CreateDBParameterGroup', 'rds:ModifyDBParameterGroup', 'rds:DeleteDBParameterGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var parameterMappings = { + 'sqlserver-ex': 'rds.force_ssl', + 'sqlserver-ee': 'rds.force_ssl', + 'sqlserver-se': 'rds.force_ssl', + 'sqlserver-web': 'rds.force_ssl', + 'postgres': 'rds.force_ssl' + }; + + async.each(regions.rds, function(region, rcb) { + var describeDBInstances = helpers.addSource(cache, source, + ['rds', 'describeDBInstances', region]); + + if (!describeDBInstances) { + return rcb(); + } + + if (describeDBInstances.err || !describeDBInstances.data) { + helpers.addResult(results, 3, + `Unable to query for RDS DB instances: ${helpers.addError(describeDBInstances)}`, + region); + return rcb(); + } + + if (!describeDBInstances.data.length) { + helpers.addResult(results, 0, 'No RDS DB instances found'); + return rcb(); + } + + async.each(describeDBInstances.data, function(db, cb){ + if (!db.DBInstanceArn || !db.Engine) return cb(); + + var resource = db.DBInstanceArn; + + if (parameterMappings[db.Engine.toLowerCase()]) { + if (!db.DBParameterGroups || !db.DBParameterGroups.length) { + helpers.addResult(results, 0, + `RDS DB instance "${db.DBInstanceIdentifier}" does not have any parameter groups associated`, + region, resource); + } + + var forceSslEnabled = false; + + for (var pg in db.DBParameterGroups) { + var dbParameterGroup = db.DBParameterGroups[pg]; + var groupName = dbParameterGroup.DBParameterGroupName; + + var parameters = helpers.addSource(cache, source, + ['rds', 'describeDBParameters', region, groupName]); + + if (!parameters || parameters.err || !parameters.data) { + helpers.addResult(results, 3, + `Unable to query for parameters: ${helpers.addError(parameters)}`, + region, resource); + return cb(); + } + + if (!parameters.data.Parameters || !parameters.data.Parameters.length) { + helpers.addResult(results, 3, + `No parameters found for RDS parameter group "${groupName}"`, + region, resource); + return cb(); + } + + for (var param of parameters.data.Parameters) { + if (param.ParameterName && param.ParameterName === parameterMappings[db.Engine] && + param.ParameterValue && param.ParameterValue !== '0') { + forceSslEnabled = true; + break; + } + } + + if (forceSslEnabled) break; + } + + if (forceSslEnabled) { + helpers.addResult(results, 0, + `RDS DB instance "${db.DBInstanceIdentifier}" has transport encryption enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `RDS DB instance "${db.DBInstanceIdentifier}" does not have transport encryption enabled`, + region, resource); + } + } else { + helpers.addResult(results, 0, + `TLS Enforcement is not supported on the ${db.DBInstanceIdentifier} database with ${db.Engine} engine`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/rds/rdsTransportEncryption.spec.js b/plugins/aws/rds/rdsTransportEncryption.spec.js new file mode 100644 index 000000000..af4503679 --- /dev/null +++ b/plugins/aws/rds/rdsTransportEncryption.spec.js @@ -0,0 +1,243 @@ +var expect = require('chai').expect; +var rdsTransportEncryption = require('./rdsTransportEncryption.js'); + +const describeDBInstances = [ + { + "DBInstanceIdentifier": "database-1", + "DBInstanceClass": "db.t2.micro", + "Engine": "sqlserver-ex", + "DBInstanceStatus": "available", + "MasterUsername": "admin", + "Endpoint": { + "Address": "database-2.csumzsa0neyf.us-east-1.rds.amazonaws.com", + "Port": 1433, + "HostedZoneId": "Z2R2ITUGPM61AM" + }, + "AllocatedStorage": 20, + "InstanceCreateTime": "2020-09-19T22:40:13.061Z", + "PreferredBackupWindow": "07:14-07:44", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "default.sqlserver-ex-14.0", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1b", + "DBInstanceArn": "arn:aws:rds:us-east-1:112233445566:db:database-1" + }, + { + "DBInstanceIdentifier": "database-2", + "DBInstanceClass": "db.t2.micro", + "Engine": "sqlserver-ex", + "DBInstanceStatus": "available", + "MasterUsername": "admin", + "Endpoint": { + "Address": "database-2.csumzsa0neyf.us-east-1.rds.amazonaws.com", + "Port": 1433, + "HostedZoneId": "Z2R2ITUGPM61AM" + }, + "AllocatedStorage": 20, + "InstanceCreateTime": "2020-09-19T22:40:13.061Z", + "PreferredBackupWindow": "07:14-07:44", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "custom-sql-server-group", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1b", + "DBInstanceArn": "arn:aws:rds:us-east-1:112233445566:db:database-2" + }, + { + "DBInstanceIdentifier": "database-3", + "DBInstanceClass": "db.t2.micro", + "Engine": "postgresql", + "DBInstanceStatus": "available", + "MasterUsername": "admin", + "Endpoint": { + "Address": "database-2.csumzsa0neyf.us-east-1.rds.amazonaws.com", + "Port": 1433, + "HostedZoneId": "Z2R2ITUGPM61AM" + }, + "AllocatedStorage": 20, + "InstanceCreateTime": "2020-09-19T22:40:13.061Z", + "PreferredBackupWindow": "07:14-07:44", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "DBParameterGroups": [ + { + "DBParameterGroupName": "custom-sql-server-group", + "ParameterApplyStatus": "in-sync" + } + ], + "AvailabilityZone": "us-east-1b", + "DBInstanceArn": "arn:aws:rds:us-east-1:112233445566:db:database-2" + } +]; + +const describeDBParameters = [ + { + "ParameterName": "rds.force_ssl", + "ParameterValue": "1", + "Description": "Force SSL connections.", + "Source": "system", + "ApplyType": "static", + "DataType": "boolean", + "AllowedValues": "0,1", + "IsModifiable": true, + "MinimumEngineVersion": "14.00.1000.169.v1", + "ApplyMethod": "pending-reboot" + }, + { + "ParameterName": "rds.force_ssl", + "ParameterValue": "0", + "Description": "Force SSL connections.", + "Source": "system", + "ApplyType": "static", + "DataType": "boolean", + "AllowedValues": "0,1", + "IsModifiable": true, + "MinimumEngineVersion": "14.00.1000.169.v1", + "ApplyMethod": "pending-reboot" + } +] + +const createCache = (dbInstances, dbParameters) => { + var dbParameterGroupName = (dbInstances[0] && dbInstances[0]) ? dbInstances[0].DBParameterGroups[0].DBParameterGroupName : null; + return { + rds: { + describeDBInstances: { + 'us-east-1': { + data: dbInstances + }, + }, + describeDBParameters: { + 'us-east-1': { + [dbParameterGroupName]: { + data: { + Parameters: dbParameters + } + } + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + rds: { + describeDBInstances: { + 'us-east-1': { + err: { + message: 'error describing DB instances' + }, + }, + }, + describeDBParameters: { + 'us-east-1': { + err: { + message: 'error describing DB parameter groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBParameterGroups: { + 'us-east-1': null, + }, + describeDBParameters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('rdsTransportEncryption', function () { + describe('run', function () { + it('should PASS if RDS DB instance has transport encryption enabled', function (done) { + const cache = createCache([describeDBInstances[0]], [describeDBParameters[0]]); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if RDS DB instance does not have transport encryption enabled', function (done) { + const cache = createCache([describeDBInstances[1]], [describeDBParameters[1]]); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no RDS DB instances found', function (done) { + const cache = createCache([]); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no RDS Sql Server instances found', function (done) { + const cache = createCache([describeDBInstances[2]], [describeDBParameters[1]]); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if no parameters found for RDS parameter group', function (done) { + const cache = createCache([describeDBInstances[0]], []); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for RDS DB instances', function (done) { + const cache = createErrorCache(); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe RDS DB instances response not found', function (done) { + const cache = createNullCache(); + rdsTransportEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); + \ No newline at end of file diff --git a/plugins/aws/rds/sqlServerTLSVersion.js b/plugins/aws/rds/sqlServerTLSVersion.js new file mode 100644 index 000000000..0d50073cf --- /dev/null +++ b/plugins/aws/rds/sqlServerTLSVersion.js @@ -0,0 +1,169 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQL Server TLS Version', + category: 'RDS', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures RDS SQL Servers do not allow outdated TLS certificate versions', + more_info: 'TLS 1.2 or higher should be used for all TLS connections to RDS. A parameter group can be used to enforce this connection type.', + link: 'https://aws.amazon.com/about-aws/whats-new/2020/07/amazon-rds-for-sql-server-supports-disabling-old-versions-of-tls-and-ciphers/', + recommended_action: 'Create a parameter group that contains the TLS version restriction and limit access to TLS 1.2 or higher', + apis: ['RDS:describeDBParameterGroups', 'RDS:describeDBParameters'], + remediation_description: 'TLS 1.2 will be enabled, TLS 1.0 and TLS 1.1 will be disabled', + remediation_min_version: '202012181130', + apis_remediate: ['RDS:describeDBParameterGroups'], + actions: { + remediate: ['RDS:modifyDBParameterGroup'], + rollback: ['RDS:modifyDBParameterGroup'] + }, + permissions: { + remediate: ['rds:ModifyDBParameterGroup'], + rollback: ['rds:ModifyDBParameterGroup'] + }, + realtime_triggers: ['rds:CreateDBParameterGroup', 'rds:ModifyDBParameterGroup','rds:DeleteDBParameterGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + async.each(regions.rds, function(region, rcb){ + var describeDBParameterGroups = helpers.addSource(cache, source, + ['rds', 'describeDBParameterGroups', region]); + + if (!describeDBParameterGroups) return rcb(); + + if (describeDBParameterGroups.err || !describeDBParameterGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for parameter groups: ' + helpers.addError(describeDBParameterGroups), region); + return rcb(); + } + + if (!describeDBParameterGroups.data.length) { + helpers.addResult(results, 0, 'No parameter groups found', region); + return rcb(); + } + + var sqlFound = false; + + async.each(describeDBParameterGroups.data, function(group, paramcb){ + if (!group.DBParameterGroupName) return paramcb(); + + var resource = group.DBParameterGroupArn; + + if (group.DBParameterGroupFamily && + (!group.DBParameterGroupFamily.startsWith('sqlserver') || + group.DBParameterGroupName.startsWith('default.'))) return paramcb(); + sqlFound = true; + + var parameters = helpers.addSource(cache, source, + ['rds', 'describeDBParameters', region, group.DBParameterGroupName]); + + if (!parameters || parameters.err || !parameters.data || !parameters.data.Parameters){ + helpers.addResult(results, 3, + 'Unable to query for parameters: ' + helpers.addError(parameters), + region, resource); + return paramcb(); + } + + var tls10; + var tls11; + var tls12; + + for (var param in parameters.data.Parameters) { + if (parameters.data.Parameters[param] && + parameters.data.Parameters[param].ParameterName && + parameters.data.Parameters[param].ParameterName === 'rds.tls10') { + tls10 = parameters.data.Parameters[param].ParameterValue; + } else if (parameters.data.Parameters[param] && + parameters.data.Parameters[param].ParameterName && + parameters.data.Parameters[param].ParameterName === 'rds.tls11') { + tls11 = parameters.data.Parameters[param].ParameterValue; + } else if (parameters.data.Parameters[param] && + parameters.data.Parameters[param].ParameterName && + parameters.data.Parameters[param].ParameterName === 'rds.tls12') { + tls12 = parameters.data.Parameters[param].ParameterValue; + } + if (!tls10 || !tls11 || !tls12) continue; + } + + if (tls10 === 'disabled' && tls11 === 'disabled' && tls12 != 'disabled') { + helpers.addResult(results, 0, + 'DB parameter group ' + (group.DBParameterGroupName) + ' uses TLS 1.2', + region, resource); + } else { + helpers.addResult(results, 2, + 'DB parameter group ' + (group.DBParameterGroupName) + ' does not require TLS 1.2', + region, resource); + } + + paramcb(); + }); + + if (!sqlFound) { + helpers.addResult(results, 0, 'No DB parameter groups for SQL servers found', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'sqlServerTLSVersion'; + var pgNameArr = resource.split(':'); + var pgName = pgNameArr[pgNameArr.length - 1]; + + var pgLocation = pgNameArr[3]; + + // add the location of the parameter group to the config + config.region = pgLocation; + var params = {}; + + // create the params necessary for the remediation + + params = { + 'DBParameterGroupName': pgName, + 'Parameters': [ + { + 'ApplyMethod': 'pending-reboot', + 'ParameterName': 'rds.tls10', + 'ParameterValue': 'disabled' + }, + { + 'ApplyMethod': 'pending-reboot', + 'ParameterName': 'rds.tls11', + 'ParameterValue': 'disabled' + } + ] + }; + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'TLS1.2': 'Disabled', + 'ParameterGroup': resource + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'TLS1.2ENABLED', + 'ParameterGroup': pgName + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); + } +}; diff --git a/plugins/aws/rds/sqlServerTLSVersion.spec.js b/plugins/aws/rds/sqlServerTLSVersion.spec.js new file mode 100644 index 000000000..8ad5ee733 --- /dev/null +++ b/plugins/aws/rds/sqlServerTLSVersion.spec.js @@ -0,0 +1,191 @@ +var expect = require('chai').expect; +var sqlServerTLSVersion = require('./sqlServerTLSVersion.js'); + +const parameterGroups = [ + { + "DBParameterGroupName": "default.sqlserver-ex-14.0", + "DBParameterGroupFamily": "sqlserver-ex-14.0", + "Description": "Default parameter group for sqlserver-ex-14.0", + "DBParameterGroupArn": "arn:aws:rds:us-east-1:23424531345:pg:default.sqlserver-ex-14.0" + }, + { + "DBParameterGroupName": "ex-g", + "DBParameterGroupFamily": "sqlserver-ex-14.0", + "Description": "abv", + "DBParameterGroupArn": "arn:aws:rds:us-east-1:23424531345:pg:ex-g" + } +]; + +const groupParameters = [ + [ + { + ParameterName: 'rds.tls10', + ParameterValue: 'disabled', + Description: 'TLS 1.0.', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: true, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + }, + { + ParameterName: 'rds.tls11', + ParameterValue: 'default', + Description: 'TLS 1.1.', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: true, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + }, + { + ParameterName: 'rds.tls12', + ParameterValue: 'default', + Description: 'TLS 1.2.', + Source: 'system', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: false, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + } + ], + [ + { + ParameterName: 'rds.tls10', + ParameterValue: 'disabled', + Description: 'TLS 1.0.', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: true, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + }, + { + ParameterName: 'rds.tls11', + ParameterValue: 'disabled', + Description: 'TLS 1.1.', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: true, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + }, + { + ParameterName: 'rds.tls12', + ParameterValue: 'default', + Description: 'TLS 1.2.', + Source: 'system', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'default, enabled, disabled', + IsModifiable: false, + ApplyMethod: 'pending-reboot', + SupportedEngineModes: [] + } + ] +]; + +const createCache = (parameterGroups, groupParameters) => { + var dbParameterGroupName = (parameterGroups.length)? parameterGroups[0]['DBParameterGroupName'] : null; + return { + rds: { + describeDBParameterGroups: { + 'us-east-1': { + data: parameterGroups + }, + }, + describeDBParameters: { + 'us-east-1': { + [dbParameterGroupName]: { + data: { + Parameters: groupParameters + } + } + }, + }, + }, + }; +}; + + +const createErrorCache = () => { + return { + rds: { + describeDBParameterGroups: { + 'us-east-1': { + err: { + message: 'error describing parameter groups' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + rds: { + describeDBParameterGroups: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('sqlServerTLSVersion', function () { + describe('run', function () { + it('should FAIL if parameter group does not use TLS version 1.2', function (done) { + const cache = createCache([parameterGroups[1]], groupParameters[0]); + sqlServerTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if parameter group uses TLS version 1.2', function (done) { + const cache = createCache([parameterGroups[1]], groupParameters[1]); + sqlServerTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no parameter groups found', function (done) { + const cache = createCache([]); + sqlServerTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should not return any results if unable to get parameter groups', function (done) { + const cache = createNullCache(); + sqlServerTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error occurs while fetching parameter groups', function (done) { + const cache = createErrorCache(); + sqlServerTLSVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/auditLoggingEnabled.js b/plugins/aws/redshift/auditLoggingEnabled.js new file mode 100644 index 000000000..27fa077fd --- /dev/null +++ b/plugins/aws/redshift/auditLoggingEnabled.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster Audit Logging Enabled', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure audit logging is enabled for Redshift clusters for security and troubleshooting purposes.', + more_info: 'Redshift clusters should be configured to enable audit logging to log cluster usage information.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/db-auditing-console.html', + recommended_action: 'Modify Redshift clusters to enable audit logging', + apis: ['Redshift:describeClusters', 'Redshift:describeLoggingStatus', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:EditLogging', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + async.each(describeClusters.data, function(cluster, ccb){ + if (!cluster.ClusterIdentifier) return ccb(); + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + var describeLoggingStatus = helpers.addSource(cache, settings, + ['redshift', 'describeLoggingStatus', region, clusterIdentifier]); + + if (!describeLoggingStatus || + describeLoggingStatus.err || + !describeLoggingStatus.data) { + helpers.addResult(results, 3, + `Unable to describe logging status for cluster "${clusterIdentifier}": ${helpers.addError(describeLoggingStatus)}`, + region, resource); + } else if (describeLoggingStatus.data.LoggingEnabled && + describeLoggingStatus.data.LoggingEnabled === true) { + helpers.addResult(results, 0, + `Redshift cluster "${clusterIdentifier}" has audit logging enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `Redshift cluster "${clusterIdentifier}" does not have audit logging enabled`, + region, resource); + } + + ccb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/auditLoggingEnabled.spec.js b/plugins/aws/redshift/auditLoggingEnabled.spec.js new file mode 100644 index 000000000..54395afef --- /dev/null +++ b/plugins/aws/redshift/auditLoggingEnabled.spec.js @@ -0,0 +1,227 @@ +var expect = require('chai').expect; +var auditLoggingEnabled = require('./auditLoggingEnabled'); + +const clusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-10-17T09:20:56.400Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.1.36", + "PublicIPAddress": "54.224.166.110" + } + ], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-10-17T19:00:00.000Z" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.dwdfwe32ed23.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-10-17T09:20:56.400Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterVersion": "1.0", + "AllowVersionUpgrade": false, + "NumberOfNodes": 1, + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.1.36", + "PublicIPAddress": "54.224.166.110" + } + ], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-10-17T19:00:00.000Z" + } +]; + +const describeLoggingStatus = [ + { + ResponseMetadata: { RequestId: '69c40fc7-0778-44b7-bec4-152e13890212' }, + LoggingEnabled: true, + BucketName: 'test-rs-audit-logging', + S3KeyPrefix: '' + }, + { + ResponseMetadata: { RequestId: '79291e75-f0e6-4813-b932-9a45683ebf08' }, + LoggingEnabled: false + } +]; + +const createCache = (clusters, loggingStatus) => { + var clusterIdentifier = (clusters && clusters.length) ? clusters[0].ClusterIdentifier : null; + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + } + }, + describeLoggingStatus: { + 'us-east-1': { + [clusterIdentifier]: { + data: loggingStatus + } + } + } + }, + } +}; + +const createErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing Redshift clusters' + }, + }, + } + } + }; +}; + +const createLoggingErrorCache = (clusters) => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing Redshift clusters' + }, + } + }, + describeLoggingStatus: { + 'us-east-1': { + [clusters[0].ClusterIdentifier]: { + err: { + message: 'error describing logging status' + } + } + } + } + } + }; +} + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null, + } + }, + }; +}; + +describe('auditLoggingEnabled', function () { + describe('run', function () { + it('should PASS if Redshift cluster is has audit logging enabled', function (done) { + const cache = createCache([clusters[0]], describeLoggingStatus[0]); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster does not have audit logging enabled', function (done) { + const cache = createCache([clusters[1]], describeLoggingStatus[1]); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query cluster logging status', function (done) { + const cache = createLoggingErrorCache([clusters[0]]); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Redshift clusters response not found', function (done) { + const cache = createNullCache(); + auditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/redshift/redshiftAllowVersionUpgrade.js b/plugins/aws/redshift/redshiftAllowVersionUpgrade.js new file mode 100644 index 000000000..d894b69f3 --- /dev/null +++ b/plugins/aws/redshift/redshiftAllowVersionUpgrade.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster Allow Version Upgrade', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that version upgrade is enabled for Redshift clusters to automatically receive upgrades during the maintenance window.', + more_info: 'Redshift clusters should be configured to allow version upgrades to get the newest features, bug fixes or the latest security patches released.', + link: 'https://docs.amazonaws.cn/en_us/redshift/latest/mgmt/redshift-mgmt.pdf', + recommended_action: 'Modify Redshift clusters to allow version upgrade', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot','redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + async.each(describeClusters.data, function(cluster, ccb){ + if (!cluster.ClusterIdentifier) return ccb(); + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.AllowVersionUpgrade) { + helpers.addResult(results, 0, + `Redshift cluster "${clusterIdentifier}" is configured to allow version upgrade`, + region, resource); + } else { + helpers.addResult(results, 2, + `Redshift cluster "${clusterIdentifier}" is not configured to allow version upgrade`, + region, resource); + } + ccb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftAllowVersionUpgrade.spec.js b/plugins/aws/redshift/redshiftAllowVersionUpgrade.spec.js new file mode 100644 index 000000000..ba32449d6 --- /dev/null +++ b/plugins/aws/redshift/redshiftAllowVersionUpgrade.spec.js @@ -0,0 +1,174 @@ +var expect = require('chai').expect; +var redshiftAllowVersionUpgrade = require('./redshiftAllowVersionUpgrade'); + +const clusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-10-17T09:20:56.400Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.1.36", + "PublicIPAddress": "54.224.166.110" + } + ], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-10-17T19:00:00.000Z" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.dwdfwe32ed23.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-10-17T09:20:56.400Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterVersion": "1.0", + "AllowVersionUpgrade": false, + "NumberOfNodes": 1, + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.1.36", + "PublicIPAddress": "54.224.166.110" + } + ], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-10-17T19:00:00.000Z" + } +]; + +const createCache = (clusters) => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + }, + } + }, + } +}; + +const createErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing Redshift clusters' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null, + } + }, + }; +}; + +describe('redshiftAllowVersionUpgrade', function () { + describe('run', function () { + it('should PASS if Redshift cluster is configured to allow version upgrade', function (done) { + const cache = createCache([clusters[0]]); + redshiftAllowVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not configured to allow version upgrade', function (done) { + const cache = createCache([clusters[1]]); + redshiftAllowVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftAllowVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + redshiftAllowVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Redshift clusters response not found', function (done) { + const cache = createNullCache(); + redshiftAllowVersionUpgrade.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/redshift/redshiftClusterCmkEncrypted.js b/plugins/aws/redshift/redshiftClusterCmkEncrypted.js new file mode 100644 index 000000000..eb13fb26c --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterCmkEncrypted.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster CMK Encryption', + category: 'Redshift', + domain: 'Databases', + severity: 'High', + description: 'Ensures Redshift clusters are encrypted using KMS customer master keys (CMKs)', + more_info: 'KMS CMKs should be used to encrypt redshift clusters in order to have full control over data encryption and decryption.', + link: 'http://docs.aws.amazon.com/redshift/latest/mgmt/working-with-db-encryption.html', + recommended_action: 'Update Redshift clusters encryption configuration to use KMS CMKs instead of AWS managed-keys.', + apis: ['Redshift:describeClusters', 'KMS:listAliases', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot','redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var aliasId; + var kmsAliases = {}; + //Create an object where key is kms key ARN and value is alias name + listAliases.data.forEach(function(alias){ + if (alias.AliasArn && alias.TargetKeyId) { + aliasId = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliases[aliasId] = alias.AliasName; + } + }); + + for (var c in describeClusters.data) { + var cluster = describeClusters.data[c]; + if (!cluster.ClusterIdentifier) continue; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.Encrypted && cluster.KmsKeyId) { + if (kmsAliases[cluster.KmsKeyId]) { + if (kmsAliases[cluster.KmsKeyId] === 'alias/aws/rds'){ + helpers.addResult(results, 2, + `Redshift cluster "${cluster.ClusterIdentifier}"is not encrypted using KMS customer master key(CMK)`, + region, resource); + } else { + helpers.addResult(results, 0, + `Redshift cluster "${cluster.ClusterIdentifier}"is not encrypted using KMS customer master key(CMK)`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `Redshift cluster encryption key "${cluster.KmsKeyId}" not found`, + region, resource); + } + } else { + helpers.addResult(results, 2, + `Redshift cluster "${cluster.ClusterIdentifier}" does not have encryption enabled`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftClusterCmkEncrypted.spec.js b/plugins/aws/redshift/redshiftClusterCmkEncrypted.spec.js new file mode 100644 index 000000000..b8fc241df --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterCmkEncrypted.spec.js @@ -0,0 +1,385 @@ +const expect = require('chai').expect; +const redshiftClusterCmkEncrypted = require('./redshiftClusterCmkEncrypted'); + +const clusters = [ + { + "ClusterIdentifier": "redshift-cluster1-124", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster1-124.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-09-13T18:27:58.725Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0bd369e5131079bf3", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterSubnetGroupName": "cluster-subnet-group-1", + "VpcId": "vpc-0b739af479bea9bff", + "AvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": true, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCU0vHJJGfRx4tSl6wVzTM2FEDeE9wyli+9bPuyADordK/tCakDGdkRgTIHzgr53bu3+AS4uwLMKzNrnPzl9iKycUgV29R2lPOQDzaIw4N6SXDqUUyCNQ7OHShjHlZcCtvYn0qC6FRQtVzzRgmbEXJYn1c19hXM/2CGFEcgeL97Bq+M5ut3aVDc+NZW43C9H3KL0GJBGsuJLedQVuZgcsbT+Wey7zbVRYQHe5DhGTkkgoad7P8JMbZo/ZzlK/6hIM1IBJCIAEbvKtCrYP6Z+N4XHBCyVjK7wd5OMZM0SVsz6zarILu3YR8fiBohxlfBF/gigOmL34/f0M3lZaQOgvZ9 Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "10.0.0.25", + "PublicIPAddress": "34.234.29.110" + } + ], + "ClusterRevisionNumber": "18861", + "Tags": [], + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/f0b4f5a7-a7b5-47b8-b0bf-73203f562886", + "EnhancedVpcRouting": false, + "IamRoles": [], + "PendingActions": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-09-20T00:00:00.000Z" + }, + { + "ClusterIdentifier": "redshift-cluster-124", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-124.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-09-13T18:27:58.725Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0bd369e5131079bf3", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterSubnetGroupName": "cluster-subnet-group-1", + "VpcId": "vpc-0b739af479bea9bff", + "AvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": true, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCU0vHJJGfRx4tSl6wVzTM2FEDeE9wyli+9bPuyADordK/tCakDGdkRgTIHzgr53bu3+AS4uwLMKzNrnPzl9iKycUgV29R2lPOQDzaIw4N6SXDqUUyCNQ7OHShjHlZcCtvYn0qC6FRQtVzzRgmbEXJYn1c19hXM/2CGFEcgeL97Bq+M5ut3aVDc+NZW43C9H3KL0GJBGsuJLedQVuZgcsbT+Wey7zbVRYQHe5DhGTkkgoad7P8JMbZo/ZzlK/6hIM1IBJCIAEbvKtCrYP6Z+N4XHBCyVjK7wd5OMZM0SVsz6zarILu3YR8fiBohxlfBF/gigOmL34/f0M3lZaQOgvZ9 Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "10.0.0.25", + "PublicIPAddress": "34.234.29.110" + } + ], + "ClusterRevisionNumber": "18861", + "Tags": [], + "KmsKeyId": "arn:aws:kms:us-east-1:112233445566:key/310a3184-b30f-4fc4-a18a-370549c676dc", + "EnhancedVpcRouting": false, + "IamRoles": [], + "PendingActions": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-09-20T00:00:00.000Z" + }, + { + "ClusterIdentifier": "redshift-cluster-124", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-124.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-09-13T18:27:58.725Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0bd369e5131079bf3", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterSubnetGroupName": "cluster-subnet-group-1", + "VpcId": "vpc-0b739af479bea9bff", + "AvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCU0vHJJGfRx4tSl6wVzTM2FEDeE9wyli+9bPuyADordK/tCakDGdkRgTIHzgr53bu3+AS4uwLMKzNrnPzl9iKycUgV29R2lPOQDzaIw4N6SXDqUUyCNQ7OHShjHlZcCtvYn0qC6FRQtVzzRgmbEXJYn1c19hXM/2CGFEcgeL97Bq+M5ut3aVDc+NZW43C9H3KL0GJBGsuJLedQVuZgcsbT+Wey7zbVRYQHe5DhGTkkgoad7P8JMbZo/ZzlK/6hIM1IBJCIAEbvKtCrYP6Z+N4XHBCyVjK7wd5OMZM0SVsz6zarILu3YR8fiBohxlfBF/gigOmL34/f0M3lZaQOgvZ9 Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "10.0.0.25", + "PublicIPAddress": "34.234.29.110" + } + ], + "ClusterRevisionNumber": "18861", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "PendingActions": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-09-20T00:00:00.000Z" + }, + { + "ClusterIdentifier": "redshift-cluster1-124", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster1-124.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-09-13T18:27:58.725Z", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-0bd369e5131079bf3", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync", + "ClusterParameterStatusList": [] + } + ], + "ClusterSubnetGroupName": "cluster-subnet-group-1", + "VpcId": "vpc-0b739af479bea9bff", + "AvailabilityZone": "us-east-1a", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": true, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCU0vHJJGfRx4tSl6wVzTM2FEDeE9wyli+9bPuyADordK/tCakDGdkRgTIHzgr53bu3+AS4uwLMKzNrnPzl9iKycUgV29R2lPOQDzaIw4N6SXDqUUyCNQ7OHShjHlZcCtvYn0qC6FRQtVzzRgmbEXJYn1c19hXM/2CGFEcgeL97Bq+M5ut3aVDc+NZW43C9H3KL0GJBGsuJLedQVuZgcsbT+Wey7zbVRYQHe5DhGTkkgoad7P8JMbZo/ZzlK/6hIM1IBJCIAEbvKtCrYP6Z+N4XHBCyVjK7wd5OMZM0SVsz6zarILu3YR8fiBohxlfBF/gigOmL34/f0M3lZaQOgvZ9 Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "10.0.0.25", + "PublicIPAddress": "34.234.29.110" + } + ], + "ClusterRevisionNumber": "18861", + "Tags": [], + "KmsKeyId": "arn:aws:kms:us-east-1:112233445565:key/f0b4f5a7-a7b5-47b8-b0bf-73203f562886", + "EnhancedVpcRouting": false, + "IamRoles": [], + "PendingActions": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-09-20T00:00:00.000Z" + } +]; + +const listAliases = [ + { + AliasName: 'alias/aws/rds', + AliasArn: 'arn:aws:kms:us-east-1:112233445566:alias/aws/rds', + TargetKeyId: '2cff2321-73c6-4bac-95eb-bc9633d3e8a9' + }, + { + AliasName: 'alias/aws/redshift', + AliasArn: 'arn:aws:kms:us-east-1:112233445566:alias/aws/redshift', + TargetKeyId: '310a3184-b30f-4fc4-a18a-370549c676dc' + }, +]; + +const createCache = (clusters, listAliases) => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + }, + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: listAliases + } + }, + } + }; +}; + +const createdescribeClustersErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error while describing clusters' + } + } + } + } + }; +}; + +const createListAliasesErrorCache = (clusters) => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + }, + } + }, + kms: { + listAliases: { + 'us-east-1': { + err: { + message: 'error while listing KMS aliases' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null + } + }, + kms: { + listAliases: { + 'us-east-1': null + } + } + }; +}; + + +describe('redshiftClusterCmkEncrypted', function () { + describe('run', function () { + it('should FAIL if Redshift cluster is not encrypted using KMS customer master key(CMK)', function (done) { + const cache = createCache([clusters[0]], [listAliases[0]]); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Redshift cluster is encrypted using KMS customer master key(CMK)', function (done) { + const cache = createCache([clusters[1]], [listAliases[1]]); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no redshift clusters found', function (done) { + const cache = createCache([]); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not encrypted', function (done) { + const cache = createCache([clusters[2]], listAliases); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Redshift cluster encyption key not found', function (done) { + const cache = createCache([clusters[3]], listAliases); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + it('should UNKNOWN if error while describing redshift clusters', function (done) { + const cache = createdescribeClustersErrorCache(); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if error while listing KMS aliases', function (done) { + const cache = createListAliasesErrorCache(clusters); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if unable to describe redshift clusters', function (done) { + const cache = createNullCache(); + redshiftClusterCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftClusterDefaultPort.js b/plugins/aws/redshift/redshiftClusterDefaultPort.js new file mode 100644 index 000000000..f1f2f0b2f --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterDefaultPort.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster Default Port', + category: 'Redshift', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Amazon Redshift clusters are not using port "5439" (default port) for database access.', + more_info: 'Amazon Redshift clusters should not use the default port for database access to ensure cluster security.', + link: 'https://docs.amazonaws.cn/en_us/redshift/latest/gsg/rs-gsg-launch-sample-cluster.html', + recommended_action: 'Update Amazon Redshift cluster endpoint port.', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot','redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.Endpoint && cluster.Endpoint.Port && cluster.Endpoint.Port === 5439) { + helpers.addResult(results, 2, + 'Redshift cluster is using default "5439" port', region, resource); + } else { + helpers.addResult(results, 0, + 'Redshift cluster is not using default "5439" port', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftClusterDefaultPort.spec.js b/plugins/aws/redshift/redshiftClusterDefaultPort.spec.js new file mode 100644 index 000000000..0b7967c80 --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterDefaultPort.spec.js @@ -0,0 +1,199 @@ +var expect = require('chai').expect; +const redshiftClusterDefaultPort = require('./redshiftClusterDefaultPort'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-2.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftClusterDefaultPort', function () { + describe('run', function () { + it('should PASS if Redshift cluster is using default "5439" port', function (done) { + const cache = createCache([describeClusters[0]]); + redshiftClusterDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not using default "5439" port', function (done) { + const cache = createCache([describeClusters[1]]); + redshiftClusterDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftClusterDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftClusterDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe clusters response not found', function (done) { + const cache = createNullCache(); + redshiftClusterDefaultPort.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftClusterInVpc.js b/plugins/aws/redshift/redshiftClusterInVpc.js new file mode 100644 index 000000000..a0b58eda2 --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterInVpc.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster In VPC', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Amazon Redshift clusters are launched within a Virtual Private Cloud (VPC).', + more_info: 'Amazon Redshift clusters should be launched within a Virtual Private Cloud (VPC) to ensure cluster security.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html#cluster-platforms', + recommended_action: 'Update Amazon Redshift cluster and attach it to VPC', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.VpcId && cluster.VpcId.length) { + helpers.addResult(results, 0, + 'Redshift cluster is launched within a VPC', region, resource); + } else { + helpers.addResult(results, 2, + 'Redshift cluster is not launched within a VPC', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftClusterInVpc.spec.js b/plugins/aws/redshift/redshiftClusterInVpc.spec.js new file mode 100644 index 000000000..262a2dee2 --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterInVpc.spec.js @@ -0,0 +1,198 @@ +var expect = require('chai').expect; +const redshiftClusterInVpc = require('./redshiftClusterInVpc'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftClusterInVpc', function () { + describe('run', function () { + it('should PASS if Redshift cluster is launched within a VPC', function (done) { + const cache = createCache([describeClusters[0]]); + redshiftClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not launched within a VPC', function (done) { + const cache = createCache([describeClusters[1]]); + redshiftClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftClusterInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftClusterMasterUsername.js b/plugins/aws/redshift/redshiftClusterMasterUsername.js new file mode 100644 index 000000000..3c8338b60 --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterMasterUsername.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Cluster Default Master Username', + category: 'Redshift', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Amazon Redshift clusters are not using "awsuser" (default master username) for database access.', + more_info: 'Amazon Redshift clusters should not use default master username for database access to ensure cluster security.', + link: 'https://docs.amazonaws.cn/en_us/redshift/latest/gsg/rs-gsg-launch-sample-cluster.html', + recommended_action: 'Update Amazon Redshift cluster master username.', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.MasterUsername && cluster.MasterUsername === 'awsuser') { + helpers.addResult(results, 2, + 'Redshift cluster is using default "awsuser" master username', region, resource); + } else { + helpers.addResult(results, 0, + 'Redshift cluster is not using default "awsuser" master username', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftClusterMasterUsername.spec.js b/plugins/aws/redshift/redshiftClusterMasterUsername.spec.js new file mode 100644 index 000000000..52d6e87bb --- /dev/null +++ b/plugins/aws/redshift/redshiftClusterMasterUsername.spec.js @@ -0,0 +1,199 @@ +var expect = require('chai').expect; +const redshiftClusterMasterUsername = require('./redshiftClusterMasterUsername'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-2.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftClusterMasterUsername', function () { + describe('run', function () { + it('should PASS if Redshift cluster is using default "awsuser" master username', function (done) { + const cache = createCache([describeClusters[0]]); + redshiftClusterMasterUsername.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not using default "awsuser" master username', function (done) { + const cache = createCache([describeClusters[1]]); + redshiftClusterMasterUsername.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftClusterMasterUsername.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftClusterMasterUsername.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftClusterMasterUsername.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftDesiredNodeType.js b/plugins/aws/redshift/redshiftDesiredNodeType.js new file mode 100644 index 000000000..e7a560dee --- /dev/null +++ b/plugins/aws/redshift/redshiftDesiredNodeType.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Desired Node Type', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Amazon Redshift cluster nodes are of given types.', + more_info: 'Amazon Redshift clusters nodes should be of the given types to ensure the internal compliance and prevent unexpected billing charges.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html#working-with-clusters-overview', + recommended_action: 'Take snapshot of the Amazon Redshift cluster and launch a new cluster of the desired node type using the snapshot.', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + settings: { + redshift_cluster_node_type: { + name: 'Redshift Cluster Node Type', + description: 'Desired Amazon Redshift cluster node type', + regex: '^.*$', + default: '' + }, + }, + realtime_triggers: ['redshift:CreateCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var redshift_cluster_node_type = settings.redshift_cluster_node_type || this.settings.redshift_cluster_node_type.default; + + if (!redshift_cluster_node_type.length) return callback(); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Redshift clusters: ' + helpers.addError(describeClusters), region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.NodeType && cluster.NodeType === redshift_cluster_node_type) { + helpers.addResult(results, 0, + 'Redshift cluster is using the desired node type', region, resource); + } else { + helpers.addResult(results, 2, + 'Redshift cluster is not using the desired node type', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftDesiredNodeType.spec.js b/plugins/aws/redshift/redshiftDesiredNodeType.spec.js new file mode 100644 index 000000000..4a5aafbd0 --- /dev/null +++ b/plugins/aws/redshift/redshiftDesiredNodeType.spec.js @@ -0,0 +1,202 @@ +var expect = require('chai').expect; +const redshiftDesiredNodeType = require('./redshiftDesiredNodeType'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftDesiredNodeType', function () { + describe('run', function () { + it('should PASS if Redshift cluster is of the desired node type', function (done) { + const cache = createCache([describeClusters[0]]); + + const settings = { redshift_cluster_node_type: 'ds2.xlarge' }; + redshiftDesiredNodeType.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster is not of the desired node type', function (done) { + const cache = createCache([describeClusters[1]]); + + const settings = { redshift_cluster_node_type: 'dc2.large' }; + redshiftDesiredNodeType.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftDesiredNodeType.run(cache, { redshift_cluster_node_type: 'ds2.xlarge' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftDesiredNodeType.run(cache, { redshift_cluster_node_type: 'ds2.xlarge' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftDesiredNodeType.run(cache, { redshift_cluster_node_type: 'ds2.xlarge' }, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftEncryptionEnabled.js b/plugins/aws/redshift/redshiftEncryptionEnabled.js index ee7192ab0..1aa6892b3 100644 --- a/plugins/aws/redshift/redshiftEncryptionEnabled.js +++ b/plugins/aws/redshift/redshiftEncryptionEnabled.js @@ -4,23 +4,31 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Redshift Encryption Enabled', category: 'Redshift', + domain: 'Databases', + severity: 'High', description: 'Ensures at-rest encryption is setup for Redshift clusters', more_info: 'AWS provides at-read encryption for Redshift clusters which should be enabled to ensure the integrity of data stored within the cluster.', link: 'http://docs.aws.amazon.com/redshift/latest/mgmt/working-with-db-encryption.html', recommended_action: 'Redshift does not currently allow modifications to encryption after the cluster has been launched, so a new cluster will need to be created with encryption enabled.', - apis: ['Redshift:describeClusters'], + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], compliance: { hipaa: 'All data in HIPAA environments must be encrypted, including ' + 'data at rest. Redshift encryption ensures that this HIPAA control ' + 'is implemented by providing KMS-backed encryption for all Redshift ' + 'data.' }, + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + async.each(regions.redshift, function(region, rcb){ var describeClusters = helpers.addSource(cache, source, ['redshift', 'describeClusters', region]); @@ -41,12 +49,13 @@ module.exports = { for (var i in describeClusters.data) { // For resource, attempt to use the endpoint address (more specific) but fallback to the instance identifier var cluster = describeClusters.data[i]; - var clusterResource = (cluster.Endpoint && cluster.Endpoint.Address) ? cluster.Endpoint.Address : cluster.ClusterIdentifier; + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; if (cluster.Encrypted) { - helpers.addResult(results, 0, 'Redshift cluster is encrypted', region, clusterResource); + helpers.addResult(results, 0, 'Redshift cluster is encrypted', region, resource); } else { - helpers.addResult(results, 1, 'Redshift cluster is not encrypted', region, clusterResource); + helpers.addResult(results, 1, 'Redshift cluster is not encrypted', region, resource); } } diff --git a/plugins/aws/redshift/redshiftEncryptionEnabled.spec.js b/plugins/aws/redshift/redshiftEncryptionEnabled.spec.js new file mode 100644 index 000000000..3d3469ae9 --- /dev/null +++ b/plugins/aws/redshift/redshiftEncryptionEnabled.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +const redshiftEncryptionEnabled = require('./redshiftEncryptionEnabled'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": true + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if Redshift cluster is encrypted', function (done) { + const cache = createCache([describeClusters[1]]); + redshiftEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Redshift cluster is encrypted'); + done(); + }); + }); + + it('should WARN if Redshift cluster is not encrypted', function (done) { + const cache = createCache([describeClusters[0]]); + redshiftEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Redshift cluster is not encrypted'); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Redshift clusters found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + redshiftEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Redshift clusters'); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftNodesCount.js b/plugins/aws/redshift/redshiftNodesCount.js new file mode 100644 index 000000000..e63319b30 --- /dev/null +++ b/plugins/aws/redshift/redshiftNodesCount.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Nodes Count', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that each AWS region has not reached the limit set for the number of Redshift cluster nodes.', + more_info: 'The number of provisioned Amazon Redshift cluster nodes must be less than the provided nodes limit to avoid reaching the limit and exceeding the set budget.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html#working-with-clusters-overview', + recommended_action: 'Remove Redshift clusters over defined limit', + apis: ['Redshift:describeClusters'], + settings: { + redshift_nodes_count: { + name: 'Amazon Redshift Nodes Count', + description: 'Maximum Amazon Redshift nodes count per region', + regex: '^[0-9]{1,4}', + default: '100' + }, + }, + realtime_triggers: ['redshift:CreateCluster', 'redshift:DeleteCluster', 'redshift:RestoreFromClusterSnapshot'], + + run: function(cache, settings, callback) { + var redshift_nodes_count = parseInt(settings.redshift_nodes_count || this.settings.redshift_nodes_count.default); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Redshift clusters: ' + helpers.addError(describeClusters), region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + var nodesCount = 0; + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + if (cluster.NumberOfNodes) { + nodesCount = nodesCount + cluster.NumberOfNodes; + } + }); + + if (nodesCount <= redshift_nodes_count) { + helpers.addResult(results, 0, + `Region contains "${nodesCount}" provisioned Redshift nodes of "${redshift_nodes_count}" limit`, region); + } else { + helpers.addResult(results, 2, + `Region contains "${nodesCount}" provisioned Redshift nodes of "${redshift_nodes_count}" limit`, region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftNodesCount.spec.js b/plugins/aws/redshift/redshiftNodesCount.spec.js new file mode 100644 index 000000000..b4f2233d2 --- /dev/null +++ b/plugins/aws/redshift/redshiftNodesCount.spec.js @@ -0,0 +1,206 @@ +var expect = require('chai').expect; +const redshiftNodesCount = require('./redshiftNodesCount'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 5, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftNodesCount', function () { + describe('run', function () { + it('should PASS if region contains provisioned Redshift nodes less than or equal to the limit', function (done) { + const cache = createCache([describeClusters[1]]); + const settings = { redshift_nodes_count: '5' }; + + redshiftNodesCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if region contains provisioned Redshift nodes more than the limit', function (done) { + const cache = createCache([describeClusters[0]]); + const settings = { redshift_nodes_count: '4' }; + + redshiftNodesCount.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftNodesCount.run(cache, { redshift_nodes_count: '5' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftNodesCount.run(cache, { redshift_nodes_count: '5' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftNodesCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftPubliclyAccessible.js b/plugins/aws/redshift/redshiftPubliclyAccessible.js index 7d32ce99c..9df279393 100644 --- a/plugins/aws/redshift/redshiftPubliclyAccessible.js +++ b/plugins/aws/redshift/redshiftPubliclyAccessible.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Redshift Publicly Accessible', category: 'Redshift', + domain: 'Databases', + severity: 'High', description: 'Ensures Redshift clusters are not launched into the public cloud', more_info: 'Unless there is a specific business requirement, Redshift clusters should not have a public endpoint and should be accessed from within a VPC only.', link: 'http://docs.aws.amazon.com/redshift/latest/mgmt/getting-started-cluster-in-vpc.html', recommended_action: 'Remove the public endpoint from the Redshift cluster', - apis: ['Redshift:describeClusters'], + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], compliance: { hipaa: 'Redshift instances should only be launched in VPC environments and ' + 'accessed through private endpoints. Exposing Redshift instances to ' + @@ -19,12 +21,18 @@ module.exports = { 'Ensure Redshift instances are not accessible from the Internet ' + 'and use proper jump box access mechanisms.' }, + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + async.each(regions.redshift, function(region, rcb){ var describeClusters = helpers.addSource(cache, source, ['redshift', 'describeClusters', region]); @@ -45,12 +53,13 @@ module.exports = { for (var i in describeClusters.data) { // For resource, attempt to use the endpoint address (more specific) but fallback to the instance identifier var cluster = describeClusters.data[i]; - var clusterResource = (cluster.Endpoint && cluster.Endpoint.Address) ? cluster.Endpoint.Address : cluster.ClusterIdentifier; + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; if (cluster.PubliclyAccessible) { - helpers.addResult(results, 1, 'Redshift cluster is publicly accessible', region, clusterResource); + helpers.addResult(results, 1, 'Redshift cluster is publicly accessible', region, resource); } else { - helpers.addResult(results, 0, 'Redshift cluster is not publicly accessible', region, clusterResource); + helpers.addResult(results, 0, 'Redshift cluster is not publicly accessible', region, resource); } } diff --git a/plugins/aws/redshift/redshiftPubliclyAccessible.spec.js b/plugins/aws/redshift/redshiftPubliclyAccessible.spec.js new file mode 100644 index 000000000..dfe9f8f68 --- /dev/null +++ b/plugins/aws/redshift/redshiftPubliclyAccessible.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +const redshiftPubliclyAccessible = require('./redshiftPubliclyAccessible'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": true, + "Encrypted": true + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftPubliclyAccessible', function () { + describe('run', function () { + it('should PASS if Redshift cluster is not publicly accessible', function (done) { + const cache = createCache([describeClusters[0]]); + redshiftPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Redshift cluster is not publicly accessible'); + done(); + }); + }); + + it('should WARN if Redshift cluster is publicly accessible', function (done) { + const cache = createCache([describeClusters[1]]); + redshiftPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Redshift cluster is publicly accessible'); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Redshift clusters found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + redshiftPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Redshift clusters'); + done(); + }); + }); + + it('should not return anything if describe clusters response is not found', function (done) { + const cache = createNullCache(); + redshiftPubliclyAccessible.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftSSLEnabled.js b/plugins/aws/redshift/redshiftSSLEnabled.js new file mode 100644 index 000000000..e23beca9f --- /dev/null +++ b/plugins/aws/redshift/redshiftSSLEnabled.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Parameter Group SSL Required', + category: 'Redshift', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures AWS Redshift non-default parameter group associated with Redshift cluster require SSL connection.', + more_info: 'Redshift parameter group associated with Redshift cluster should be configured to require SSL to secure data in transit.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/connecting-ssl-support.html', + recommended_action: 'Update Redshift parameter groups to have require-ssl parameter set to true.', + apis: ['Redshift:describeClusters', 'Redshift:describeClusterParameterGroups', 'Redshift:describeClusterParameters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyClusterParameterGroup', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + var describeClusterParameterGroups = helpers.addSource(cache, source, + ['redshift', 'describeClusterParameterGroups', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, + 'No Redshift clusters found', region); + return rcb(); + } + + if (!describeClusterParameterGroups || describeClusterParameterGroups.err || !describeClusterParameterGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift cluster parameter groups: ${helpers.addError(describeClusterParameterGroups)}`, region); + return rcb(); + } + + async.each(describeClusters.data, function(cluster, ccb){ + if (!cluster.ClusterIdentifier) return ccb(); + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + var requireSsl = false; + + for (var cg in cluster.ClusterParameterGroups) { + var clusterParameterGroup = cluster.ClusterParameterGroups[cg]; + var groupName = clusterParameterGroup.ParameterGroupName; + + if (!groupName.startsWith('default.redshift')) { + var describeClusterParameters = helpers.addSource(cache, source, + ['redshift', 'describeClusterParameters', region, groupName]); + + if (!describeClusterParameters || + describeClusterParameters.err || + !describeClusterParameters.data || + !describeClusterParameters.data.Parameters) { + helpers.addResult(results, 3, + `Unable to query parameter group "${groupName}": ${helpers.addError(describeClusterParameters)}`, + region, resource); + return ccb(); + } + + + for (var p in describeClusterParameters.data.Parameters) { + var param = describeClusterParameters.data.Parameters[p]; + + if (param.ParameterName && param.ParameterName === 'require_ssl' && + param.ParameterValue && param.ParameterValue === 'true') { + requireSsl = true; + break; + } + } + } + + if (requireSsl) break; + } + + if (requireSsl) { + helpers.addResult(results, 0, + `Parameter group associated with Redshift cluster "${clusterIdentifier}" requires SSL connection`, + region, resource); + } else { + helpers.addResult(results, 2, + `Parameter group associated with Redshift cluster "${clusterIdentifier}" does not require SSL connection`, + region, resource); + } + + ccb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftSSLEnabled.spec.js b/plugins/aws/redshift/redshiftSSLEnabled.spec.js new file mode 100644 index 000000000..215ece3da --- /dev/null +++ b/plugins/aws/redshift/redshiftSSLEnabled.spec.js @@ -0,0 +1,251 @@ +const expect = require('chai').expect; +const redshiftSSLEnabled = require('./redshiftSSLEnabled'); + +const clusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "test-124", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + }, + { + "ClusterIdentifier": "redshift-cluster-3", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "test1-124", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + } +]; + +const parameterGroups = [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Default parameter group for redshift-1.0", + "Tags": [] + }, + { + "ParameterGroupName": "test-124", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Cloudexploit plugin development", + "Tags": [] + }, + { + "ParameterGroupName": "test1-124", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Cloudexploit plugin development", + "Tags": [] + } +]; + +const parameters = [ + { + "Parameters": [ + { + "ParameterName": "require_ssl", + "ParameterValue": "false", + "Description": "require ssl for all databaseconnections", + "Source": "engine-default", + "DataType": "boolean", + "AllowedValues": "true,false", + "ApplyType": "static", + "IsModifiable": true + } + ] + }, + { + "Parameters": [ + { + "ParameterName": "require_ssl", + "ParameterValue": "true", + "Description": "require ssl for all databaseconnections", + "Source": "engine-default", + "DataType": "boolean", + "AllowedValues": "true,false", + "ApplyType": "static", + "IsModifiable": true + } + ] + } +]; + +const createCache = (clusters, parameterGroups, parameters) => { + var parameterGroupName = (clusters && clusters.length) ? clusters[0].ClusterParameterGroups[0].ParameterGroupName : null; + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeClusterParameterGroups: { + 'us-east-1': { + data: parameterGroups + }, + }, + describeClusterParameters: { + 'us-east-1': { + [parameterGroupName]: { + data: parameters + } + } + } + }, + } +}; + +const createErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing Redshift clusters' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null, + } + }, + }; +}; + +describe('redshiftSSLEnabled', function () { + describe('run', function () { + it('should PASS if parameter group associated with Redshift cluster requires SSL connection', function (done) { + const cache = createCache([clusters[1]], [parameterGroups[1]], parameters[1]); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if parameter group associated with Redshift cluster does not require SSL connection', function (done) { + const cache = createCache([clusters[2]], [parameterGroups[2]], parameters[0]); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Redshift cluster is using default parameter group', function (done) { + const cache = createCache([clusters[0]], [parameterGroups[0]], parameters[0]); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query parameter group', function (done) { + const cache = createCache([clusters[2]], [parameterGroups[2]]); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Redshift clusters response not found', function (done) { + const cache = createNullCache(); + redshiftSSLEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/redshiftUnusedReservedNodes.js b/plugins/aws/redshift/redshiftUnusedReservedNodes.js new file mode 100644 index 000000000..92c74f565 --- /dev/null +++ b/plugins/aws/redshift/redshiftUnusedReservedNodes.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Unused Reserved Nodes', + category: 'Redshift', + domain: 'Databases', + severity: 'High', + description: 'Ensures that Amazon Redshift Reserved Nodes are being utilized.', + more_info: 'Amazon Redshift reserved nodes must be utilized to avoid unnecessary billing.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/purchase-reserved-node-instance.html', + recommended_action: 'Provision new Redshift clusters matching the criteria of reserved nodes', + apis: ['Redshift:describeClusters', 'Redshift:describeReservedNodes', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + var describeReservedNodes = helpers.addSource(cache, source, + ['redshift', 'describeReservedNodes', region]); + + if (!describeReservedNodes) return rcb(); + + if (describeReservedNodes.err || !describeReservedNodes.data) { + helpers.addResult(results, 3, + 'Unable to query for Redshift reserved nodes: ' + helpers.addError(describeReservedNodes), region); + return rcb(); + } + + if (!describeReservedNodes.data.length) { + helpers.addResult(results, 0, 'No Redshift reserved nodes found', region); + return rcb(); + } + + if (!describeClusters || describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Redshift clusters: ' + helpers.addError(describeClusters), region); + return rcb(); + } + + var usedReservedNodes = []; + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + if (!usedReservedNodes.includes(cluster.NodeType)) { + usedReservedNodes.push(cluster.NodeType); + } + }); + + describeReservedNodes.data.forEach(node => { + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:reserved-node:${node.ReservedNodeId}`; + if (usedReservedNodes.includes(node.NodeType)) { + helpers.addResult(results, 0, + `Redshift reserved node "${node.ReservedNodeId}" is being used`, + region, resource); + } else { + helpers.addResult(results, 2, + `Redshift reserved node "${node.ReservedNodeId}" is not being used`, + region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/redshiftUnusedReservedNodes.spec.js b/plugins/aws/redshift/redshiftUnusedReservedNodes.spec.js new file mode 100644 index 000000000..3116c8855 --- /dev/null +++ b/plugins/aws/redshift/redshiftUnusedReservedNodes.spec.js @@ -0,0 +1,258 @@ +var expect = require('chai').expect; +const redshiftUnusedReservedNodes = require('./redshiftUnusedReservedNodes'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dw.hs1.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + +const describeReservedNodes = [ + { + "OfferingType": "Heavy Utilization", + "FixedPrice": "", + "NodeType": "dw.hs1.xlarge", + "ReservedNodeId": "1ba8e2e3-bc01-4d65-b35d-a4a3e931547e", + "UsagePrice": "", + "RecurringCharges": [ + { + "RecurringChargeAmount": "", + "RecurringChargeFrequency": "Hourly" + } ], + "NodeCount": 1, + "State": "payment-pending", + "StartTime": "2013-02-13T17:08:39.051Z", + "Duration": 31536000, + "ReservedNodeOfferingId": "ceb6a579-cf4c-4343-be8b-d832c45ab51c" + }, + { + "OfferingType": "Heavy Utilization", + "FixedPrice": "", + "NodeType": "ds2.xlarge", + "ReservedNodeId": "1ba8e2e3-bc01-4d65-b35d-a4a3e931547e", + "UsagePrice": "", + "RecurringCharges": [ + { + "RecurringChargeAmount": "", + "RecurringChargeFrequency": "Hourly" + } ], + "NodeCount": 1, + "State": "payment-pending", + "StartTime": "2013-02-13T17:08:39.051Z", + "Duration": 31536000, + "ReservedNodeOfferingId": "ceb6a579-cf4c-4343-be8b-d832c45ab51c" + } +]; + +const createCache = (clusters, nodes) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeReservedNodes: { + 'us-east-1': { + data: nodes + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error while describing redshift clusters' + }, + }, + }, + describeReservedNodes: { + 'us-east-1': { + err: { + message: 'error while describing redshift reserved nodes' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + describeReservedNodes: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('redshiftUnusedReservedNodes', function () { + describe('run', function () { + it('should PASS if Redshift reserved node is being used', function (done) { + const cache = createCache([describeClusters[0]], [describeReservedNodes[0]]); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift reserved node is not being used', function (done) { + const cache = createCache([describeClusters[0]], [describeReservedNodes[1]]); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift reserved nodes found', function (done) { + const cache = createCache([], []); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe reserved nodes', function (done) { + const cache = createCache([]); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if describe clusters response not found', function (done) { + const cache = createNullCache(); + redshiftUnusedReservedNodes.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/snapshotRetentionPeriod.js b/plugins/aws/redshift/snapshotRetentionPeriod.js new file mode 100644 index 000000000..17a460ef3 --- /dev/null +++ b/plugins/aws/redshift/snapshotRetentionPeriod.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift Automated Snapshot Retention Period', + category: 'Redshift', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that retention period is set for Amazon Redshift automated snapshots.', + more_info: 'Amazon Redshift clusters should have retention period set for automated snapshots for data protection and to avoid unexpected failures.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-snapshots.html', + recommended_action: 'Modify Amazon Redshift cluster to set snapshot retention period', + apis: ['Redshift:describeClusters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyCluster', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + + if (cluster.AutomatedSnapshotRetentionPeriod && cluster.AutomatedSnapshotRetentionPeriod > 0) { + helpers.addResult(results, 0, + 'Redshift cluster has retention period set', region, resource); + } else { + helpers.addResult(results, 2, + 'Redshift cluster does not have retention period set', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/snapshotRetentionPeriod.spec.js b/plugins/aws/redshift/snapshotRetentionPeriod.spec.js new file mode 100644 index 000000000..d2a9e5857 --- /dev/null +++ b/plugins/aws/redshift/snapshotRetentionPeriod.spec.js @@ -0,0 +1,199 @@ +var expect = require('chai').expect; +const snapshotRetentionPeriod = require('./snapshotRetentionPeriod'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-2.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + +const createCache = (clusters) => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing redshift clusters' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + redshift:{ + describeClusters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('snapshotRetentionPeriod', function () { + describe('run', function () { + it('should PASS if Redshift cluster has retention period set', function (done) { + const cache = createCache([describeClusters[0]]); + snapshotRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Redshift cluster does not have retention period set', function (done) { + const cache = createCache([describeClusters[1]]); + snapshotRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + snapshotRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to describe clusters', function (done) { + const cache = createErrorCache(); + snapshotRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe clusters response not found', function (done) { + const cache = createNullCache(); + snapshotRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/underutilizedRedshiftCluster.js b/plugins/aws/redshift/underutilizedRedshiftCluster.js new file mode 100644 index 000000000..7c18db00e --- /dev/null +++ b/plugins/aws/redshift/underutilizedRedshiftCluster.js @@ -0,0 +1,98 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Underutilized Redshift Cluster Check', + category: 'Redshift', + domain: 'Databases', + severity: 'High', + description: 'Ensure Redshift clusters are not underutilized', + more_info: 'Underutilized clusters are good candidates to reduce your monthly AWS costs and avoid accumulating unnecessary usage charges.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/metrics-listing.html#redshift-metrics', + recommended_action: 'Resize the underused Redshift cluster to optimize costs and resource utilization.', + apis: ['Redshift:describeClusters', 'CloudWatch:getredshiftMetricStatistics', 'STS:getCallerIdentity'], + settings: { + redshift_cluster_cpu_threshold: { + name: 'Redshift CPU Threshold', + description: 'The CPU utilization threshold in percentage below which a cluster is considered underutilized.', + regex: '^(100|[1-9][0-9]?)$', + default: '5' + } + }, + realtime_triggers: ['redshift:CreateCluster','redshift:CreateClusterSnapshot', 'redshift:RestoreFromClusterSnapshot','redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var cpuThreshold = settings.redshift_cluster_cpu_threshold || this.settings.redshift_cluster_cpu_threshold.default; + + async.each(regions.redshift, function(region, rcb) { + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult( + results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, 'No Redshift clusters found', region); + return rcb(); + } + + describeClusters.data.forEach(cluster => { + if (!cluster.ClusterIdentifier) return; + + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${cluster.clusterIdentifier}`; + + var getMetricStatistics = helpers.addSource(cache, source, + ['cloudwatch', 'getredshiftMetricStatistics', region, cluster.ClusterIdentifier]); + + if (!getMetricStatistics || getMetricStatistics.err || + !getMetricStatistics.data || !getMetricStatistics.data.Datapoints) { + helpers.addResult(results, 3, + `Unable to query for CPU metric statistics: ${helpers.addError(getMetricStatistics)}`, region, resource); + return; + } + + if (!getMetricStatistics.data.Datapoints.length) { + helpers.addResult(results, 0, + 'CPU metric statistics are not available', region, resource); + } else { + var cpuDatapoints = getMetricStatistics.data.Datapoints; + var utilizationCount = 0; + + for (var i = cpuDatapoints.length - 1; i >= 0; i--) { + if (cpuDatapoints[i].Average < cpuThreshold) { + utilizationCount++; + } + } + + var utilizationPercentage = (utilizationCount / cpuDatapoints.length) * 100; + + if (utilizationPercentage >= 99) { + helpers.addResult(results, 2, + `Redshift cluster has had less than ${cpuThreshold} cluster-wide average CPU utilization for 99% of the last 7 days`, region, resource); + } else { + helpers.addResult(results, 0, + 'Redshift cluster is not underutilized', region, resource); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/underutilizedRedshiftCluster.spec.js b/plugins/aws/redshift/underutilizedRedshiftCluster.spec.js new file mode 100644 index 000000000..4cd7945cc --- /dev/null +++ b/plugins/aws/redshift/underutilizedRedshiftCluster.spec.js @@ -0,0 +1,280 @@ +const expect = require('chai').expect; +const underutilizedredshiftCluster = require('./underutilizedRedshiftCluster'); + +const describeClusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dw.hs1.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "customuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5555 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 1, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + }, + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "ds2.xlarge", + "ClusterStatus": "available", + "ClusterAvailabilityStatus": "Available", + "MasterUsername": "awsuser", + "DBName": "dev", + "Endpoint": { + "Address": "redshift-cluster-1.cks44thktt7l.us-east-1.redshift.amazonaws.com", + "Port": 5439 + }, + "ClusterCreateTime": "2020-11-25T00:37:51.472000+00:00", + "AutomatedSnapshotRetentionPeriod": 0, + "ManualSnapshotRetentionPeriod": -1, + "ClusterSecurityGroups": [], + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "AvailabilityZone": "us-east-1c", + "PreferredMaintenanceWindow": "sun:00:00-sun:00:30", + "PendingModifiedValues": {}, + "ClusterVersion": "1.0", + "AllowVersionUpgrade": true, + "NumberOfNodes": 1, + "PubliclyAccessible": false, + "Encrypted": false, + "ClusterPublicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfPK8qflrCru2M5kL3A7i0tIj+FAPOVLdrDm7vPwhAWBNKQlfqmt4+a8ob+Ql7Hrlu+pu8eYdFFjzcmRtsI9m3onlbQ6jIKiW6WwsqYvPSucPq/78rFYGcxrGc213OL2XF1xZnZTpGleeH/BH1q/7hTiwYVmZ17k3ZL320jRUTFm2WEvcQoDWu8DderPPjllJ7Zz/JtJx1x3XM5kP9e4zSSWaUfAG3kKKxDeHbNUAq5JRk/yYA8iel1I7qIbl6NZpDgOOgLI9fUmICwH0u740PEDVoSrh2qFepQgMnRg1sPgdvoPFaSIpiQzNwUNqQiZhNstZDWu73Fjyqzv9m7ZxH Amazon-Redshift\n", + "ClusterNodes": [ + { + "NodeRole": "SHARED", + "PrivateIPAddress": "172.31.22.110", + "PublicIPAddress": "52.73.49.144" + } + ], + "ClusterRevisionNumber": "21262", + "Tags": [], + "EnhancedVpcRouting": false, + "IamRoles": [], + "MaintenanceTrackName": "current", + "DeferredMaintenanceWindows": [], + "NextMaintenanceWindowStartTime": "2020-11-29T00:00:00+00:00", + "ClusterNamespaceArn": "arn:aws:redshift:us-east-1:111122223333:namespace:f862b236-268d-4e86-afd3-ef91e96a97c4" + } +]; + + + +const redshiftMetricStatistics = [ + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 7.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 40.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 16.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 24.333, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 14.333, + "Unit": "Percent" + }, + ] + }, + { + "Datapoints": [ + { + "Timestamp": "2018-12-16T17:03:10Z", + "Average": 2.99, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T18:03:10Z", + "Average": 1.70, + "Unit": "Percent" + }, + { + "Timestamp": "2018-12-16T19:03:10Z", + "Average": 2.20, + "Unit": "Percent" + }, + ] + } +] + +const createCache = (cluster, metrics) => { + if (cluster && cluster.length) var id = cluster[0].ClusterIdentifier; + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: cluster, + }, + }, + }, + cloudwatch: { + getredshiftMetricStatistics: { + 'us-east-1': { + [id]: { + data: metrics + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error desribing cache clusters' + }, + }, + }, + }, + cloudwatch: { + getredshiftMetricStatistics: { + 'us-east-1': { + err: { + message: 'error getting metric stats' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null, + }, + }, + cloudwatch: { + getredshiftMetricStatistics: { + 'us-east-1': null + }, + }, + }; +}; + +describe('underutilizesredshiftCluster', function () { + describe('run', function () { + it('should PASS if the Redshift cluster cpu utilization is more than 5 percent', function (done) { + const cache = createCache([describeClusters[0]], redshiftMetricStatistics[0]); + underutilizedredshiftCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Redshift cluster is not underutilized'); + done(); + }); + }); + + it('should FAIL if the Redshift cpu utilization is less than 5 percent', function (done) { + const cache = createCache([describeClusters[1]], redshiftMetricStatistics[1]); + underutilizedredshiftCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Redshift clusterfound', function (done) { + const cache = createCache([]); + underutilizedredshiftCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No Redshift clusters found'); + done(); + }); + }); + + it('should UNKNOWN if unable to describe Redshift cluster', function (done) { + const cache = createErrorCache(); + underutilizedredshiftCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Redshift clusters: '); + done(); + }); + }); + + it('should not return any results if describe EC2 Instance response not found', function (done) { + const cache = createNullCache(); + underutilizedredshiftCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/redshift/userActivityLoggingEnabled.js b/plugins/aws/redshift/userActivityLoggingEnabled.js new file mode 100644 index 000000000..3928eb4a6 --- /dev/null +++ b/plugins/aws/redshift/userActivityLoggingEnabled.js @@ -0,0 +1,115 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Redshift User Activity Logging Enabled', + category: 'Redshift', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that user activity logging is enabled for your Amazon Redshift clusters.', + more_info: 'Redshift clusters associated parameter groups should have user activity logging enabled in order to log user activities performed.', + link: 'https://docs.aws.amazon.com/redshift/latest/mgmt/db-auditing.html#db-auditing-enable-logging', + recommended_action: 'Update Redshift parameter groups to enable user activity logging', + apis: ['Redshift:describeClusters', 'Redshift:describeClusterParameterGroups', 'Redshift:describeClusterParameters', 'STS:getCallerIdentity'], + realtime_triggers: ['redshift:CreateCluster', 'redshift:ModifyClusterParameterGroup', 'redshift:RestoreFromClusterSnapshot', 'redshift:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.redshift, function(region, rcb){ + var describeClusters = helpers.addSource(cache, source, + ['redshift', 'describeClusters', region]); + + var describeClusterParameterGroups = helpers.addSource(cache, source, + ['redshift', 'describeClusterParameterGroups', region]); + + if (!describeClusters) return rcb(); + + if (describeClusters.err || !describeClusters.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift clusters: ${helpers.addError(describeClusters)}`, region); + return rcb(); + } + + if (!describeClusters.data.length) { + helpers.addResult(results, 0, + 'No Redshift clusters found', region); + return rcb(); + } + + if (!describeClusterParameterGroups || describeClusterParameterGroups.err || !describeClusterParameterGroups.data) { + helpers.addResult(results, 3, + `Unable to query for Redshift cluster parameter groups: ${helpers.addError(describeClusterParameterGroups)}`, region); + return rcb(); + } + + async.each(describeClusters.data, function(cluster, ccb){ + if (!cluster.ClusterIdentifier) return ccb(); + + var clusterIdentifier = cluster.ClusterIdentifier; + var resource = `arn:${awsOrGov}:redshift:${region}:${accountId}:cluster:${clusterIdentifier}`; + var loggingEnabled = false; + + if (cluster.ClusterParameterGroups.length) { + for (var cg in cluster.ClusterParameterGroups) { + var clusterParameterGroup = cluster.ClusterParameterGroups[cg]; + + if (!clusterParameterGroup.ParameterGroupName) continue; + + var groupName = clusterParameterGroup.ParameterGroupName; + + if (!groupName.startsWith('default.redshift')) { + var describeClusterParameters = helpers.addSource(cache, source, + ['redshift', 'describeClusterParameters', region, groupName]); + + if (!describeClusterParameters || + describeClusterParameters.err || + !describeClusterParameters.data || + !describeClusterParameters.data.Parameters) { + helpers.addResult(results, 3, + `Unable to query parameter group "${groupName}": ${helpers.addError(describeClusterParameters)}`, + region, resource); + return ccb(); + } + + for (var p in describeClusterParameters.data.Parameters) { + var param = describeClusterParameters.data.Parameters[p]; + + if (param.ParameterName && param.ParameterName === 'enable_user_activity_logging' && + param.ParameterValue && param.ParameterValue === 'true') { + loggingEnabled = true; + break; + } + } + } + + if (loggingEnabled) break; + } + } + + if (loggingEnabled) { + helpers.addResult(results, 0, + `Parameter group associated with Redshift cluster "${clusterIdentifier}" has user logging enabled`, + region, resource); + } else { + helpers.addResult(results, 2, + `Parameter group associated with Redshift cluster "${clusterIdentifier}" does not have user logging enabled`, + region, resource); + } + + ccb(); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/redshift/userActivityLoggingEnabled.spec.js b/plugins/aws/redshift/userActivityLoggingEnabled.spec.js new file mode 100644 index 000000000..756504482 --- /dev/null +++ b/plugins/aws/redshift/userActivityLoggingEnabled.spec.js @@ -0,0 +1,251 @@ +const expect = require('chai').expect; +const userActivityLoggingEnabled = require('./userActivityLoggingEnabled'); + +const clusters = [ + { + "ClusterIdentifier": "redshift-cluster-1", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + }, + { + "ClusterIdentifier": "redshift-cluster-2", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "test-124", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + }, + { + "ClusterIdentifier": "redshift-cluster-3", + "NodeType": "dc2.large", + "ClusterStatus": "creating", + "ClusterAvailabilityStatus": "Modifying", + "MasterUsername": "awsuser", + "DBName": "dev", + "VpcSecurityGroups": [ + { + "VpcSecurityGroupId": "sg-aa941691", + "Status": "active" + } + ], + "ClusterParameterGroups": [ + { + "ParameterGroupName": "test1-124", + "ParameterApplyStatus": "in-sync" + } + ], + "ClusterSubnetGroupName": "default", + "VpcId": "vpc-99de2fe4", + "PendingModifiedValues": { + "MasterUserPassword": "****" + }, + "ClusterVersion": "1.0" + } +]; + +const parameterGroups = [ + { + "ParameterGroupName": "default.redshift-1.0", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Default parameter group for redshift-1.0", + "Tags": [] + }, + { + "ParameterGroupName": "test-124", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Cloudexploit plugin development", + "Tags": [] + }, + { + "ParameterGroupName": "test1-124", + "ParameterGroupFamily": "redshift-1.0", + "Description": "Cloudexploit plugin development", + "Tags": [] + } +]; + +const parameters = [ + { + "Parameters": [ + { + "ParameterName": "enable_user_activity_logging", + "ParameterValue": "false", + "Description": "parameter for audit logging purpose", + "Source": "user", + "DataType": "boolean", + "AllowedValues": "true,false", + "ApplyType": "static", + "IsModifiable": true + } + ] + }, + { + "Parameters": [ + { + "ParameterName": "enable_user_activity_logging", + "ParameterValue": "true", + "Description": "parameter for audit logging purpose", + "Source": "user", + "DataType": "boolean", + "AllowedValues": "true,false", + "ApplyType": "static", + "IsModifiable": true + } + ] + } +]; + +const createCache = (clusters, parameterGroups, parameters) => { + var parameterGroupName = (clusters && clusters.length) ? clusters[0].ClusterParameterGroups[0].ParameterGroupName : null; + return { + redshift: { + describeClusters: { + 'us-east-1': { + data: clusters + }, + }, + describeClusterParameterGroups: { + 'us-east-1': { + data: parameterGroups + }, + }, + describeClusterParameters: { + 'us-east-1': { + [parameterGroupName]: { + data: parameters + } + } + } + }, + } +}; + +const createErrorCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': { + err: { + message: 'error describing Redshift clusters' + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + redshift: { + describeClusters: { + 'us-east-1': null, + } + }, + }; +}; + +describe('userActivityLoggingEnabled', function () { + describe('run', function () { + it('should PASS if parameter group associated with Redshift cluster has user activity logging enabled', function (done) { + const cache = createCache([clusters[1]], [parameterGroups[1]], parameters[1]); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if parameter group associated with Redshift cluster does not have user activity logging enabled', function (done) { + const cache = createCache([clusters[2]], [parameterGroups[2]], parameters[0]); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Redshift cluster is using default parameter group', function (done) { + const cache = createCache([clusters[0]], [parameterGroups[0]], parameters[0]); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Redshift clusters found', function (done) { + const cache = createCache([]); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query parameter group', function (done) { + const cache = createCache([clusters[2]], [parameterGroups[2]]); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Redshift clusters', function (done) { + const cache = createErrorCache(); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe Redshift clusters response not found', function (done) { + const cache = createNullCache(); + userActivityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/route53/danglingDnsRecords.js b/plugins/aws/route53/danglingDnsRecords.js new file mode 100644 index 000000000..fb3b1c67d --- /dev/null +++ b/plugins/aws/route53/danglingDnsRecords.js @@ -0,0 +1,146 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Route53 Dangling DNS Records', + category: 'Route53', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensures that AWS Route53 DNS records are not pointing to invalid/deleted EIPs.', + more_info: 'AWS Route53 DNS records should not point to invalid/deleted EIPs to prevent malicious activities.', + link: 'https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-aws-resources.html', + recommended_action: 'Delete invalid/dangling AWS Route53 DNS records', + apis: ['Route53:listHostedZones', 'Route53:listResourceRecordSets', 'EC2:describeAddresses', 'S3:listBuckets'], + settings: { + dns_allow_private_ips: { + name: 'DNS Allow Private IPs', + description: 'When true, allows Route53 DNS records to point to IP addresses outside AWS', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['route53:CreateHostedZone','route53:ChangeResourceRecordSets', 'route53:DeleteHostedZone'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + dns_allow_private_ips: settings.dns_allow_private_ips || this.settings.dns_allow_private_ips.default + }; + + var allowPrivateIps = (config.dns_allow_private_ips == 'true'); + + var listHostedZones = helpers.addSource(cache, source, + ['route53', 'listHostedZones', region]); + + if (!listHostedZones) return callback(null, results, source); + + if (listHostedZones.err || !listHostedZones.data) { + helpers.addResult(results, 3, + `Unable to query for hosted zones: ${helpers.addError(listHostedZones)}`, + region); + return callback(null, results, source); + } + + if (!listHostedZones.data.length) { + helpers.addResult(results, 0, 'No Route53 Hosted Zones found', region); + return callback(null, results, source); + } + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets || listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + var bucketNames = []; + listBuckets.data.forEach(bucket => { + bucketNames.push(`${bucket.Name}.`); + }); + + var addresses = []; + if (!allowPrivateIps) { + async.each(helpers.regions(settings).ec2, function(region, rcb) { + var describeAddresses = helpers.addSource(cache, source, + ['ec2', 'describeAddresses', region]); + + if (!describeAddresses) return rcb(); + + if (describeAddresses.err || !describeAddresses.data) { + helpers.addResult(results, 3, + `Unable to query for elastic IP addresses: ${helpers.addError(describeAddresses)}`, + region); + return rcb(); + } + + if (describeAddresses.data.length) { + describeAddresses.data.forEach(address => { + if (address.PublicIp) { + addresses.push(address.PublicIp); + } + }); + } + + rcb(); + }); + } + + async.each(listHostedZones.data, function(zone, cb){ + var resource = `arn:${awsOrGov}:route53:::${zone.Id}`; + + var listResourceRecordSets = helpers.addSource(cache, source, + ['route53', 'listResourceRecordSets', region, zone.Id]); + + if (!listResourceRecordSets || listResourceRecordSets.err || !listResourceRecordSets.data) { + helpers.addResult(results, 3, + `Unable to query for resource record sets: ${helpers.addError(listResourceRecordSets)}`, + region, resource); + return cb(); + } + + if (!listResourceRecordSets.data.ResourceRecordSets || !listResourceRecordSets.data.ResourceRecordSets.length) { + helpers.addResult(results, 0, + 'No resource record sets found', + region, resource); + return cb(); + } + + var danglingDnsRecords = []; + listResourceRecordSets.data.ResourceRecordSets.forEach(recordSet => { + if (recordSet.Type && recordSet.Type === 'A' && recordSet.ResourceRecords && recordSet.ResourceRecords.length) { + recordSet.ResourceRecords.forEach(record => { + if (!allowPrivateIps && record.Value && !addresses.includes(record.Value)) { + danglingDnsRecords.push(record.Value); + } + }); + } + + if (recordSet.Type && recordSet.Type === 'A' && + recordSet.Name && !bucketNames.includes(recordSet.Name) && + recordSet.AliasTarget && recordSet.AliasTarget.DNSName && recordSet.AliasTarget.DNSName.startsWith('s3-website')) { + danglingDnsRecords.push(recordSet.Name); + } + }); + + if (!danglingDnsRecords.length) { + helpers.addResult(results, 0, + `Hosted Zone "${zone.Name}" does not have any dangling DNS records`, + region, resource); + } else { + helpers.addResult(results, 2, + `Hosted Zone "${zone.Name}" has these dangling DNS records: ${danglingDnsRecords.join(', ')}`, + region, resource); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/route53/danglingDnsRecords.spec.js b/plugins/aws/route53/danglingDnsRecords.spec.js new file mode 100644 index 000000000..3c3d7b220 --- /dev/null +++ b/plugins/aws/route53/danglingDnsRecords.spec.js @@ -0,0 +1,246 @@ +var expect = require('chai').expect; +const danglingDnsRecords = require('./danglingDnsRecords'); + +const listHostedZones = [ + { + "Id": "/hostedzone/Z0959845393J2LOUSNVSK", + "Name": "testfr.com.", + "CallerReference": "d042e53d-7b8b-4974-94e9-8305af0c6acb", + "Config": { + "Comment": "", + "PrivateZone": false + }, + "ResourceRecordSetCount": 4 + } +]; + +const listResourceRecordSets = [ + { + ResourceRecordSets: [ + { + "Name": "eip.testfr.com.", + "Type": "A", + "TTL": 300, + "ResourceRecords": [ + { + "Value": "55.90.14.155" + } + ] + } + ] + }, + { + ResourceRecordSets: [ + { + "Name": "ec2.testfr.com.", + "Type": "A", + "TTL": 300, + "ResourceRecords": [ + { + "Value": "172.31.28.00" + } + ] + }, + ] + }, + { + ResourceRecordSets: [ + { + "Name": "s3rec.testfr.com.", + "Type": "A", + "ResourceRecords": [], + "AliasTarget": { + "HostedZoneId": 'Z3AQBSTGFYJSTF', + "DNSName": 's3-website-us-east-1.amazonaws.com.', + "EvaluateTargetHealth": true + } + }, + ] + }, +]; + +const describeAddresses = [ + { + "PublicIp": "55.90.14.155", + "AllocationId": "eipalloc-02fbee66ba40a5920", + "Domain": "vpc", + "PublicIpv4Pool": "amazon", + "NetworkBorderGroup": "us-east-1" + } +]; + +const createCache = (zones, recordSets, addresses, listBuckets) => { + var zoneId = (zones && zones.length && zones[0].Id) ? zones[0].Id : null; + return { + route53: { + listHostedZones: { + 'us-east-1': { + data: zones + }, + }, + listResourceRecordSets: { + 'us-east-1': { + [zoneId]: { + data: recordSets + }, + }, + }, + }, + ec2: { + describeAddresses: { + 'us-east-1': { + data: addresses + }, + }, + }, + s3: { + listBuckets: { + 'us-east-1': { + data: listBuckets + } + } + } + }; +}; + +const createErrorCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': { + err: { + message: 'error listing Route 53 hosted zones' + }, + }, + }, + listResourceRecordSets: { + 'us-east-1': { + err: { + message: 'error listing resource record sets' + }, + }, + }, + }, + ec2: { + describeAddresses: { + 'us-east-1': { + err: { + message: 'error describing elastic IP addresses' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': null, + }, + listResourceRecordSets: { + 'us-east-1': null, + }, + }, + ec2: { + describeAddresses: { + 'us-east-1': null + }, + }, + }; +}; + +describe('danglingDnsRecords', function () { + describe('run', function () { + it('should PASS if Hosted Zone does not have any dangling DNS records', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[0], [describeAddresses[0]], []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Hosted Zone has dangling DNS records', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[1], [describeAddresses[0]], []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Hosted Zone has dangling DNS records', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[1], [], []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Hosted Zone has dangling DNS records pointing to deleted S3 bucket', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[1], [], []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no hosted zones found', function (done) { + const cache = createCache([]); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no resource record sets found', function (done) { + const cache = createCache([listHostedZones[0]], {}, [], []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list hosted zones', function (done) { + const cache = createErrorCache(); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list resource record sets', function (done) { + const cache = createCache([listHostedZones[0]], null, []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe elastic IP addresses', function (done) { + const cache = createCache([listHostedZones[0]], [], null, []); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list hosted zones response not found', function (done) { + const cache = createNullCache(); + danglingDnsRecords.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/route53/domainAutoRenew.js b/plugins/aws/route53/domainAutoRenew.js index c21031333..3ffa91dd0 100644 --- a/plugins/aws/route53/domainAutoRenew.js +++ b/plugins/aws/route53/domainAutoRenew.js @@ -3,11 +3,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Domain Auto Renew', category: 'Route53', + domain: 'Content Delivery', + severity: 'High', description: 'Ensures domains are set to auto renew through Route53', more_info: 'Domains purchased through Route53 should be set to auto renew. Domains that are not renewed can quickly be acquired by a third-party and cause loss of access for customers.', link: 'http://docs.aws.amazon.com/Route53/latest/APIReference/api-enable-domain-auto-renew.html', recommended_action: 'Enable auto renew for the domain', apis: ['Route53Domains:listDomains'], + realtime_triggers: ['route53domains:RegisterDomain','route53domains:EnableAutoRenew', 'route53domians:DisableDomainAutoRenew', 'route53domians:DeleteDomain'], + run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/route53/domainAutoRenew.spec.js b/plugins/aws/route53/domainAutoRenew.spec.js new file mode 100644 index 000000000..3c9a9fab6 --- /dev/null +++ b/plugins/aws/route53/domainAutoRenew.spec.js @@ -0,0 +1,81 @@ +const expect = require('chai').expect; +var domainAutoRenew = require('./domainAutoRenew'); + +const domains = [ + { + "DomainName": "test-domain.com", + "AutoRenew" : true + }, + { + "DomainName": "test-domain.com", + "AutoRenew" : false + } +] + + +const createCache = (domain, domainErr) => { + return { + route53domains: { + listDomains: { + 'us-east-1': { + data: domain, + err: domainErr + } + }, + } + }; +}; + +const createNullCache = () => { + return { + route53domains: { + listDomains: { + 'us-east-1': null + } + } + }; +}; + +describe('domainAutoRenew', function () { + describe('run', function () { + + it('should PASS if Domain has auto renew enabled', function (done) { + const cache = createCache([domains[0]]); + domainAutoRenew.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has auto renew enabled'); + done(); + }); + }); + + it('should FAIL if Domain does not have auto renew enabled', function (done) { + const cache = createCache([domains[1]]); + domainAutoRenew.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('does not have auto renew enabled'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for domains', function (done) { + const cache = createCache([], {}, { message: 'Unable to query for domains' }); + domainAutoRenew.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for domains'); + done(); + }); + }); + + + it('should not return anything if list domains response not found', function (done) { + const cache = createNullCache(); + domainAutoRenew.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/route53/domainExpiry.js b/plugins/aws/route53/domainExpiry.js index 0c27a9cd7..0fd5ba80e 100644 --- a/plugins/aws/route53/domainExpiry.js +++ b/plugins/aws/route53/domainExpiry.js @@ -3,11 +3,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Domain Expiry', category: 'Route53', + domain: 'Content Delivery', + severity: 'High', description: 'Ensures domains are not expiring too soon', more_info: 'Expired domains can be lost and reregistered by a third-party.', link: 'http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/registrar.html', recommended_action: 'Reregister the expiring domain', apis: ['Route53Domains:listDomains'], + realtime_triggers: ['route53domains:RegisterDomain','route53domains:RenewDomain','route53domians:DeleteDomain'], + run: function(cache, settings, callback) { var results = []; @@ -31,17 +35,15 @@ module.exports = { return callback(null, results, source); } - for (var i in listDomains.data) { - var domain = listDomains.data[i]; - + for (var domain of listDomains.data) { if (domain.Expiry) { - var difference = helpers.daysAgo(domain.Expiry); + var difference = Math.round((new Date(domain.Expiry).getTime() - new Date().getTime())/(24*60*60*1000)); var returnMsg = 'Domain: ' + domain.DomainName + ' expires in ' + difference + ' days'; - if (difference > 45) { + if (difference > 35) { + helpers.addResult(results, 0, returnMsg, 'global', domain.DomainName); + } else if (['.com.ar', '.com.br', '.jp'].some(suffix => domain.DomainName.endsWith(suffix)) && difference > 30){ helpers.addResult(results, 0, returnMsg, 'global', domain.DomainName); - } else if (difference > 30) { - helpers.addResult(results, 1, returnMsg, 'global', domain.DomainName); } else if (difference > 0) { helpers.addResult(results, 2, returnMsg, 'global', domain.DomainName); } else { diff --git a/plugins/aws/route53/domainExpiry.spec.js b/plugins/aws/route53/domainExpiry.spec.js new file mode 100644 index 000000000..784213814 --- /dev/null +++ b/plugins/aws/route53/domainExpiry.spec.js @@ -0,0 +1,125 @@ +const expect = require('chai').expect; +var domainExpiry = require('./domainExpiry'); + + +var domainWarn = new Date(); +domainWarn.setMonth(domainWarn.getMonth() + 1); +var domainPass = new Date(); +domainPass.setMonth(domainPass.getMonth() + 2); +var domainFail = new Date(); +domainFail.setMonth(domainFail.getMonth() - 1); + +const domains = [ + { + "DomainName": "example.com", + "AutoRenew": true, + "TransferLock": true, + "Expiry": domainPass + }, + { + "DomainName": "example.com.ar", + "AutoRenew": true, + "TransferLock": true, + "Expiry": domainPass + }, + { + "DomainName": "example.com", + "AutoRenew": true, + "TransferLock": false, + "Expiry": domainFail + }, + { + "DomainName": "example.com.uk", + "AutoRenew": true, + "TransferLock": true + }, +]; + + +const createCache = (domain, domainErr) => { + return { + route53domains: { + listDomains: { + 'us-east-1': { + data: domain, + err: domainErr + } + }, + } + }; +}; + +const createNullCache = () => { + return { + route53domains: { + listDomains: { + 'us-east-1': null + } + } + }; +}; + +describe('domainExpiry', function () { + describe('run', function () { + + it('should PASS if Domain will expire', function (done) { + const cache = createCache([domains[0]]); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('expires in'); + done(); + }); + }); + + it('should FAIL if Domain expired', function (done) { + const cache = createCache([domains[2]]); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('expired'); + done(); + }); + }); + + it('should PASS if Domain will expire', function (done) { + const cache = createCache([domains[1]]); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('expires in'); + done(); + }); + }); + + + it('should UNKNOWN if Expiration for domain could not be determined', function (done) { + const cache = createCache([domains[3]]); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('could not be determined'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for domains', function (done) { + const cache = createCache([], {}, { message: 'Unable to query for domains' }); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for domains'); + done(); + }); + }); + + + it('should not return anything if list domains response not found', function (done) { + const cache = createNullCache(); + domainExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/route53/domainTransferLock.js b/plugins/aws/route53/domainTransferLock.js index 1341fb188..4a671f32e 100644 --- a/plugins/aws/route53/domainTransferLock.js +++ b/plugins/aws/route53/domainTransferLock.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Domain Transfer Lock', category: 'Route53', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures domains have the transfer lock set', more_info: 'To avoid having a domain maliciously transferred to a third-party, all domains should enable the transfer lock unless actively being transferred.', link: 'http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-transfer-from-route-53.html', recommended_action: 'Enable the transfer lock for the domain', apis: ['Route53Domains:listDomains'], + realtime_triggers: ['route53domains:RegisterDomain', 'route53domain:EnableDomainTransferLock', 'route53domain:DisableDomainTransferLock','route53domians:DeleteDomain'], run: function(cache, settings, callback) { var results = []; @@ -31,12 +34,39 @@ module.exports = { return callback(null, results, source); } + var dtlUnsupportedDomains= [ + '.za', + '.cl', + '.ar', + '.au', + '.nz', + '.au', + '.jp', + '.qa', + '.ru', + '.ch', + '.de', + '.es', + '.eu', + 'fi', + '.it', + '.nl', + '.se', + ]; + var unsupported = false; + for (var i in listDomains.data) { var domain = listDomains.data[i]; + if (!domain.DomainName) continue; - // Skip .uk and .co.uk domains - if (domain.DomainName.indexOf('.uk') > -1) { + dtlUnsupportedDomains.forEach((region) => { + if (domain.DomainName.includes(region)) { + unsupported = true; + } + }); + // Skip the unsupported domains + if (unsupported) { helpers.addResult(results, 0, 'Domain: ' + domain.DomainName + ' does not support transfer locks', 'global', domain.DomainName); diff --git a/plugins/aws/route53/domainTransferLock.spec.js b/plugins/aws/route53/domainTransferLock.spec.js new file mode 100644 index 000000000..441a0548e --- /dev/null +++ b/plugins/aws/route53/domainTransferLock.spec.js @@ -0,0 +1,101 @@ +const expect = require('chai').expect; +var domainTransferLock = require('./domainTransferLock'); + +const domains = [ + { + "DomainName": "example.com", + "AutoRenew": true, + "TransferLock": true, + "Expiry": 1602712345.0 + }, + { + "DomainName": "example.com.", + "AutoRenew": true, + "TransferLock": false, + "Expiry": 1602712345.0 + }, + { + "DomainName": "example.com.jp", + "AutoRenew": true, + "TransferLock": true, + "Expiry": 1602712345.0 + } +] + + +const createCache = (domain, domainErr) => { + return { + route53domains: { + listDomains: { + 'us-east-1': { + data: domain, + err: domainErr + } + }, + } + }; +}; + +const createNullCache = () => { + return { + route53domains: { + listDomains: { + 'us-east-1': null + } + } + }; +}; + +describe('domainTransferLock', function () { + describe('run', function () { + + it('should PASS if Domain has the transfer lock enabled', function (done) { + const cache = createCache([domains[0]]); + domainTransferLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has the transfer lock enabled'); + done(); + }); + }); + + it('should FAIL if Domain does not have the transfer lock enabled', function (done) { + const cache = createCache([domains[1]]); + domainTransferLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have the transfer lock enabled'); + done(); + }); + }); + + it('should PASS if Domain does not support transfer locks', function (done) { + const cache = createCache([domains[2]]); + domainTransferLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not support transfer locks'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for domains', function (done) { + const cache = createCache([], {}, { message: 'Unable to query for domains' }); + domainTransferLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for domains'); + done(); + }); + }); + + + it('should not return anything if list domains response not found', function (done) { + const cache = createNullCache(); + domainTransferLock.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/route53/privacyProtection.js b/plugins/aws/route53/privacyProtection.js new file mode 100644 index 000000000..b656dc7d5 --- /dev/null +++ b/plugins/aws/route53/privacyProtection.js @@ -0,0 +1,57 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Domain Privacy Protection', + category: 'Route53', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Privacy Protection feature is enabled for your Amazon Route 53 domains.', + more_info: 'Enabling the Privacy Protection feature protects against receiving spams and sharing contact information in response of WHOIS queries.', + link: 'https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-privacy-protection.html', + recommended_action: 'Enable Privacy Protection for Domain', + apis: ['Route53Domains:listDomains', 'Route53Domains:getDomainDetail'], + realtime_triggers: ['route53domains:RegisterDomain', 'route53domains:UpdateDomainContactPrivacy','route53domians:DeleteDomain'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listDomains = helpers.addSource(cache, source, + ['route53domains', 'listDomains', region]); + + if (!listDomains) return callback(null, results, source); + + if (listDomains.err || !listDomains.data) { + helpers.addResult(results, 3, + 'Unable to query for domains: ' + helpers.addError(listDomains)); + return callback(null, results, source); + } + + if (!listDomains.data.length) { + helpers.addResult(results, 0, 'No domains registered through Route53'); + return callback(null, results, source); + } + + listDomains.data.forEach(domain => { + if (!domain.DomainName) return; + + var domainDetail = helpers.addSource(cache, source, + ['route53domains', 'getDomainDetail', region, domain.DomainName]); + + if (!domainDetail || domainDetail.err || !domainDetail.data) { + helpers.addResult(results, 3, + 'Unable to query for domain details: ' + helpers.addError(domainDetail)); + return; + } + + const status = domainDetail.data.RegistrantPrivacy ? 0 : 2; + helpers.addResult(results, status, + 'Domain: ' + domain.DomainName + ` ${status == 0 ? 'has': 'does not have'} privacy protection enabled`, + 'global', domain.DomainName); + + }); + + callback(null, results, source); + } +}; diff --git a/plugins/aws/route53/privacyProtection.spec.js b/plugins/aws/route53/privacyProtection.spec.js new file mode 100644 index 000000000..46ed752f7 --- /dev/null +++ b/plugins/aws/route53/privacyProtection.spec.js @@ -0,0 +1,104 @@ +const expect = require('chai').expect; +var privacyProtection = require('./privacyProtection'); + +const domains = [ + { + "DomainName": "test-domain.com" + } +] + +const domainDetails = [ + { + "DomainName": "test-domain.com", + "RegistrantPrivacy": true + }, + { + "DomainName": "test-domain.com", + "RegistrantPrivacy": false + }, + { + "DomainName": "test-domain.com", + } +] + +const createCache = (domain, detail, domainErr, detailErr) => { + if (domain && domain.length) var name = domain[0].DomainName; + return { + route53domains: { + listDomains: { + 'us-east-1': { + data: domain, + err: domainErr + } + }, + getDomainDetail: { + 'us-east-1': { + [name]: { + data: detail, + err: detailErr + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + route53domains: { + listDomains: { + 'us-east-1': null + } + } + }; +}; + +describe('privacyProtection', function () { + describe('run', function () { + + it('should PASS if privacy protection enabled', function (done) { + const cache = createCache([domains[0]], domainDetails[0]); + privacyProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if privacy protection disabled', function (done) { + const cache = createCache([domains[0]], domainDetails[1]); + privacyProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if privacy property not found', function (done) { + const cache = createCache([domains[0]], domainDetails[2]); + privacyProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if unable to list domains', function (done) { + const cache = createCache([], {}, { message: 'Unable to list domains' }); + privacyProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + + it('should not return anything if list domains response not found', function (done) { + const cache = createNullCache(); + privacyProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/route53/route53InUse.js b/plugins/aws/route53/route53InUse.js new file mode 100644 index 000000000..ee3d994db --- /dev/null +++ b/plugins/aws/route53/route53InUse.js @@ -0,0 +1,40 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Route 53 In Use', + category: 'Route53', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that AWS Route 53 Domain Name System (DNS) service is used within your AWS account.', + more_info: 'AWS Route 53 simplifies DNS management, ensuring reliable and efficient routing for end users to your website through globally-dispersed DNS servers, enhancing accessibility and performance. It facilitates domain registration, DNS routing, and health checking, allowing seamless management of your web infrastructure.', + link: 'https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html', + recommended_action: 'Register your domain with AWS Route 53 for seamless DNS management.', + apis: ['Route53:listHostedZones'], + realtime_triggers: ['route53:CreateHostedZone','route53:DeleteHostedZone'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + + var listHostedZones = helpers.addSource(cache, source, + ['route53', 'listHostedZones', region]); + + if (!listHostedZones) return callback(null, results, source); + + if (listHostedZones.err || !listHostedZones.data) { + helpers.addResult(results, 3, + `Unable to query for hosted zones: ${helpers.addError(listHostedZones)}`, + region); + return callback(null, results, source); + } + + if (!listHostedZones.data.length) { + helpers.addResult(results, 2, 'Route53 DNS service is not in use', region); + } else { + helpers.addResult(results, 0, 'Route53 DNS service is in use', region); + } + + return callback(null, results, source); + } +}; diff --git a/plugins/aws/route53/route53InUse.spec.js b/plugins/aws/route53/route53InUse.spec.js new file mode 100644 index 000000000..8ef7278e4 --- /dev/null +++ b/plugins/aws/route53/route53InUse.spec.js @@ -0,0 +1,70 @@ +var expect = require('chai').expect; +var route53InUse = require('./route53InUse'); + +const createCache = (listHostedZonesData, error = null) => { + return { + route53: { + listHostedZones: { + 'us-east-1': { + err: error, + data: listHostedZonesData + } + } + } + }; +}; + +describe('Route 53 In Use', function () { + describe('run', function () { + it('should return passing result if Route53 DNS service is in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Route53 DNS service is in use'); + done(); + }; + + const cache = createCache([ + { + "Id": "/hostedzone/Z0959845393J2LOUSNVSK", + "Name": "testfr.com.", + "CallerReference": "d042e53d-7b8b-4974-94e9-8305af0c6acb", + "Config": { + "Comment": "", + "PrivateZone": false + }, + "ResourceRecordSetCount": 4 + } + ]); + + route53InUse.run(cache, {}, callback); + }); + + it('should return failing result if Route53 DNS service is not in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Route53 DNS service is not in use'); + done(); + }; + + const cache = createCache([]); + + route53InUse.run(cache, {}, callback); + }); + + it('should return error result if unable to query for hosted zones', function (done) { + const errorMessage = 'Error occurred while querying hosted zones'; + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include(`Unable to query for hosted zones: ${errorMessage}`); + done(); + }; + + const cache = createCache([], errorMessage); + + route53InUse.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/aws/route53/senderPolicyFwInUse.js b/plugins/aws/route53/senderPolicyFwInUse.js new file mode 100644 index 000000000..6abd07485 --- /dev/null +++ b/plugins/aws/route53/senderPolicyFwInUse.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Sender Policy Framework In Use', + category: 'Route53', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Sender Policy Framework (SPF) is used to stop spammers from spoofing your AWS Route 53 domain.', + more_info: 'The Sender Policy Framework enables AWS Route 53 registered domain to publicly state the mail servers that are authorized to send emails on its behalf.', + link: 'https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/rrsets-working-with.html', + recommended_action: 'Updated the domain records to have SPF.', + apis: ['Route53:listHostedZones', 'Route53:listResourceRecordSets'], + realtime_triggers: ['route53:CreateHostedZone','route53:ChangeResourceRecordSets','route53:DeleteHostedZone'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var listHostedZones = helpers.addSource(cache, source, + ['route53', 'listHostedZones', region]); + + if (!listHostedZones) return callback(null, results, source); + + if (listHostedZones.err || !listHostedZones.data) { + helpers.addResult(results, 3, + `Unable to query for hosted zones: ${helpers.addError(listHostedZones)}`, + region); + return callback(null, results, source); + } + + + if (!listHostedZones.data.length) { + helpers.addResult(results, 0, 'No Route53 Hosted Zones found', region); + return callback(null, results, source); + } + + async.each(listHostedZones.data, function(zone, cb){ + if (!zone.Id) return cb(); + + var resource = `arn:${awsOrGov}:route53:::${zone.Id}`; + + var listResourceRecordSets = helpers.addSource(cache, source, + ['route53', 'listResourceRecordSets', region, zone.Id]); + + if (!listResourceRecordSets || listResourceRecordSets.err || !listResourceRecordSets.data) { + helpers.addResult(results, 3, + `Unable to query for resource record sets: ${helpers.addError(listResourceRecordSets)}`, + region, resource); + return cb(); + } + + if (!listResourceRecordSets.data.ResourceRecordSets || !listResourceRecordSets.data.ResourceRecordSets.length) { + helpers.addResult(results, 0, + 'No resource record sets found', + region, resource); + return cb(); + } + + let enabled = listResourceRecordSets.data.ResourceRecordSets.find(recordSet => + recordSet.Type && recordSet.Type.toUpperCase() == 'TXT' && + recordSet.ResourceRecords.find(record => record.Value && record.Value.toLowerCase().includes('v=spf1'))); + + if (!enabled) { + helpers.addResult(results, 2, + 'Hosted Zone has does not have SPF enabled', + region, resource); + } else { + helpers.addResult(results, 0, + 'Hosted Zone has SPF enabled', + region, resource); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/route53/senderPolicyFwInUse.spec.js b/plugins/aws/route53/senderPolicyFwInUse.spec.js new file mode 100644 index 000000000..31c407f04 --- /dev/null +++ b/plugins/aws/route53/senderPolicyFwInUse.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +const senderPolicyFwInUse = require('./senderPolicyFwInUse'); + +const listHostedZones = [ + { + "Id": "/hostedzone/Z0959845393J2LOUSNVSK", + "Name": "testfr.com.", + "CallerReference": "d042e53d-7b8b-4974-94e9-8305af0c6acb", + "Config": { + "Comment": "", + "PrivateZone": false + }, + "ResourceRecordSetCount": 4 + } +]; + +const listResourceRecordSets = [ + { + ResourceRecordSets: [ + { + "Name": "eip.testfr.com.", + "Type": "TXT", + "TTL": 300, + "ResourceRecords": [ + { + "Value": "v=spf1 ip4:192.168.0.5/16 -all" + } + ] + } + ] + }, + { + ResourceRecordSets: [ + { + "Name": "ec2.testfr.com.", + "Type": "TXT", + "TTL": 300, + "ResourceRecords": [ + { + "Value": "172.31.28.00" + } + ] + }, + ] + } +]; + + +const createCache = (zones, recordSets) => { + var zoneId = (zones && zones.length && zones[0].Id) ? zones[0].Id : null; + return { + route53: { + listHostedZones: { + 'us-east-1': { + data: zones + }, + }, + listResourceRecordSets: { + 'us-east-1': { + [zoneId]: { + data: recordSets + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': { + err: { + message: 'error listing Route 53 hosted zones' + }, + }, + }, + listResourceRecordSets: { + 'us-east-1': { + err: { + message: 'error listing resource record sets' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': null, + }, + listResourceRecordSets: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('senderPolicyFwInUse', function () { + describe('run', function () { + it('should PASS if Hosted Zone has SPF', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[0]); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Hosted Zone does not have SPF', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[1]); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no hosted zones found', function (done) { + const cache = createCache([]); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no resource record sets found', function (done) { + const cache = createCache([listHostedZones[0]], {}); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list hosted zones', function (done) { + const cache = createErrorCache(); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list resource record sets', function (done) { + const cache = createCache([listHostedZones[0]], null); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list hosted zones response not found', function (done) { + const cache = createNullCache(); + senderPolicyFwInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/route53/senderPolicyFwRecordPresent.js b/plugins/aws/route53/senderPolicyFwRecordPresent.js new file mode 100644 index 000000000..3c1d37661 --- /dev/null +++ b/plugins/aws/route53/senderPolicyFwRecordPresent.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Sender Privacy Framework Record Present', + category: 'Route53', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Route 53 hosted zones have a DNS record containing Sender Policy Framework (SPF) value set for each MX record available.', + more_info: 'The SPF record enables Route 53 registered domains to publicly state the mail servers that are authorized to send emails on its behalf.', + link: 'https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-creating.html', + recommended_action: 'Add SPF records to the DNS records.', + apis: ['Route53:listHostedZones', 'Route53:listResourceRecordSets'], + realtime_triggers: ['route53:CreateHostedZone','route53:ChangeResourceRecordSets','route53:DeleteHostedZone'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listHostedZones = helpers.addSource(cache, source, + ['route53', 'listHostedZones', region]); + + if (!listHostedZones) return callback(null, results, source); + + if (listHostedZones.err || !listHostedZones.data) { + helpers.addResult(results, 3, + `Unable to query for hosted zones: ${helpers.addError(listHostedZones)}`, + region); + return callback(null, results, source); + } + + if (!listHostedZones.data.length) { + helpers.addResult(results, 0, 'No Route53 Hosted Zones found', region); + return callback(null, results, source); + } + + async.each(listHostedZones.data, function(zone, cb){ + if (!zone.Id) return cb(); + var resource = `arn:${awsOrGov}:route53:::${zone.Id}`; + + var listResourceRecordSets = helpers.addSource(cache, source, + ['route53', 'listResourceRecordSets', region, zone.Id]); + + if (!listResourceRecordSets || listResourceRecordSets.err || !listResourceRecordSets.data) { + helpers.addResult(results, 3, + `Unable to query for resource record sets: ${helpers.addError(listResourceRecordSets)}`, + region, resource); + return cb(); + } + + if (!listResourceRecordSets.data.ResourceRecordSets || !listResourceRecordSets.data.ResourceRecordSets.length) { + helpers.addResult(results, 0, + 'No resource record sets found', + region, resource); + return cb(); + } + + var spfRecordExists = listResourceRecordSets.data.ResourceRecordSets.find( + recordSet => recordSet.Type && recordSet.Type.toUpperCase() === 'SPF'); + const status = spfRecordExists ? 0 : 2; + + helpers.addResult(results, status, + `Hosted Zone '${zone.Name}' ${spfRecordExists ? 'has' : 'does not have any'} SPF record.`, + region, resource); + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/route53/senderPolicyFwRecordPresent.spec.js b/plugins/aws/route53/senderPolicyFwRecordPresent.spec.js new file mode 100644 index 000000000..0826fe48b --- /dev/null +++ b/plugins/aws/route53/senderPolicyFwRecordPresent.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +const senderPolicyFwRecordPresent = require('./senderPolicyFwRecordPresent'); + +const listHostedZones = [ + { + "Id": "/hostedzone/Z0959845393J2LOUSNVSK", + "Name": "testfr.com.", + "CallerReference": "d042e53d-7b8b-4974-94e9-8305af0c6acb", + "Config": { + "Comment": "", + "PrivateZone": false + }, + "ResourceRecordSetCount": 4 + } +]; + +const listResourceRecordSets = [ + { + ResourceRecordSets: [ + { + "Name": "eip.testfr.com.", + "Type": "TXT", + "TTL": 300, + } + ] + }, + { + ResourceRecordSets: [ + { + "Name": "ec2.testfr.com.", + "Type": "SPF", + "TTL": 300, + }, + ] + }, + { + ResourceRecordSets: [ + { + "Name": "ec2.testfr.com.", + "TTL": 300, + }, + ] + } +]; + + +const createCache = (zones, recordSets) => { + var zoneId = (zones && zones.length && zones[0].Id) ? zones[0].Id : null; + return { + route53: { + listHostedZones: { + 'us-east-1': { + data: zones + }, + }, + listResourceRecordSets: { + 'us-east-1': { + [zoneId]: { + data: recordSets + }, + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': { + err: { + message: 'error listing Route 53 hosted zones' + }, + }, + }, + listResourceRecordSets: { + 'us-east-1': { + err: { + message: 'error listing resource record sets' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + route53: { + listHostedZones: { + 'us-east-1': null, + }, + listResourceRecordSets: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('senderPolicyFwRecordPresent', function () { + describe('run', function () { + it('should PASS if Hosted Zone has SPF record', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[1]); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Hosted Zone does not have SPF', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[0]); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Hosted Zone record set does not have type property', function (done) { + const cache = createCache([listHostedZones[0]], listResourceRecordSets[2]); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no hosted zones found', function (done) { + const cache = createCache([]); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no resource record sets found', function (done) { + const cache = createCache([listHostedZones[0]], {}); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list hosted zones', function (done) { + const cache = createErrorCache(); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list resource record sets', function (done) { + const cache = createCache([listHostedZones[0]], null); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list hosted zones response not found', function (done) { + const cache = createNullCache(); + senderPolicyFwRecordPresent.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/s3/bucketAllUsersAcl.js b/plugins/aws/s3/bucketAllUsersAcl.js index 4ad39ef12..1e580b91a 100644 --- a/plugins/aws/s3/bucketAllUsersAcl.js +++ b/plugins/aws/s3/bucketAllUsersAcl.js @@ -6,22 +6,37 @@ var ACL_AUTHENTICATED_USERS = 'http://acs.amazonaws.com/groups/global/Authentica module.exports = { title: 'S3 Bucket All Users ACL', category: 'S3', + domain: 'Storage', + severity: 'Medium', description: 'Ensures S3 buckets do not allow global write, delete, or read ACL permissions', more_info: 'S3 buckets can be configured to allow anyone, regardless of whether they are an AWS user or not, to write objects to a bucket or delete objects. This option should not be configured unless there is a strong business requirement.', recommended_action: 'Disable global all users policies on all S3 buckets and ensure both the bucket ACL is configured with least privileges.', link: 'http://docs.aws.amazon.com/AmazonS3/latest/UG/EditingBucketPermissions.html', - apis: ['S3:listBuckets', 'S3:getBucketAcl'], + apis: ['S3:listBuckets', 'S3:getBucketAcl', 'S3:getBucketLocation'], compliance: { pci: 'PCI requires that cardholder data can only be accessed by those with ' + 'a legitimate business need. If PCI-restricted data is stored in S3, ' + 'those buckets should not enable global user access.' }, + remediation_description: 'Bucket ACL will be modified to be private to bucket owner.', + remediation_min_version: '202201131602', + apis_remediate: ['S3:listBuckets', 'S3:getBucketLocation'], + actions: { + remediate: ['S3:putBucketAcl'], + rollback: ['S3:putBucketAcl'] + }, + permissions: { + remediate: ['s3:PutBucketAcl'], + rollback: ['s3:PutBucketAcl'] + }, + realtime_triggers: ['s3:PutBucketAcl', 's3:CreateBucket', 's3:DeleteBucket'], run: function(cache, settings, callback) { var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -43,7 +58,8 @@ module.exports = { var bucket = listBuckets.data[i]; if (!bucket.Name) continue; - var bucketResource = 'arn:aws:s3:::' + bucket.Name; + var bucketResource = `arn:${awsOrGov}:s3:::` + bucket.Name; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); var getBucketAcl = helpers.addSource(cache, source, ['s3', 'getBucketAcl', region, bucket.Name]); @@ -56,7 +72,7 @@ module.exports = { helpers.addResult(results, 3, 'Error querying for bucket ACL for bucket: ' + bucket.Name + ': ' + helpers.addError(getBucketAcl), - 'global', bucketResource); + bucketLocation, bucketResource); } else { for (var g in getBucketAcl.data.Grants) { var grant = getBucketAcl.data.Grants[g]; @@ -89,15 +105,68 @@ module.exports = { if (!bucketIssues.length) { helpers.addResult(results, 0, 'Bucket ACL does not contain any insecure allow statements', - 'global', bucketResource); + bucketLocation, bucketResource); } else { helpers.addResult(results, bucketResult, bucketIssues.join(' '), - 'global', bucketResource); + bucketLocation, bucketResource); } } } callback(null, results, source); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'bucketAllUsersAcl'; + var bucketNameArr = resource.split(':'); + var bucketName = bucketNameArr[bucketNameArr.length - 1]; + + // find the location of the bucket needing to be remediated + var bucketLocations = cache['s3']['getBucketLocation']; + var bucketLocation; + + for (var key in bucketLocations) { + if (bucketLocations[key][bucketName]) { + bucketLocation = key; + break; + } + } + + // add the location of the bucket to the config + config.region = bucketLocation; + + // create the params necessary for the remediation + var params = { + Bucket: bucketName, + ACL: 'private' + }; + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'ACL': 'Public', + 'Bucket': bucketName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'ACL': 'Private', + 'Bucket': bucketName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; \ No newline at end of file diff --git a/plugins/aws/s3/bucketAllUsersAcl.spec.js b/plugins/aws/s3/bucketAllUsersAcl.spec.js new file mode 100644 index 000000000..ace56d64e --- /dev/null +++ b/plugins/aws/s3/bucketAllUsersAcl.spec.js @@ -0,0 +1,213 @@ +var expect = require('chai').expect; +const bucketAllUsersAcl = require('./bucketAllUsersAcl'); + +const listBuckets = [ + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + } +]; + +const getBucketAcl = [ + { + "Owner": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5" + }, + "Grants": [ + { + "Grantee": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "WRITE" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "READ_ACP" + } + ] + }, + { + "Owner": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5" + }, + "Grants": [ + { + "Grantee": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "READ" + } + ] + }, + { + "Owner": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5" + }, + "Grants": [ + { + "Grantee": { + "DisplayName": "aws-test", + "ID": "91bfbca30b5e6d86faa17a9fdd05ebb1dbbd2c27e6175aeba6ad00bff680d9f5", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "WRITE" + } + ] + }, +]; + +const createCache = (buckets, acl) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketAcl: { + 'us-east-1': { + [bucketName]: { + data: acl + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing s3 buckets' + }, + }, + }, + getBucketAcl: { + 'us-east-1': { + err: { + message: 'error while getting bucket ACL' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketAcl: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketAllUsersAcl', function () { + describe('run', function () { + it('should PASS if S3 bucket does not allow global permissions', function (done) { + const cache = createCache([listBuckets[0]], getBucketAcl[0]); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should WARN if S3 bucket allows any of the global read permissions', function (done) { + const cache = createCache([listBuckets[0]], getBucketAcl[1]); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket allows any of the global write permissions', function (done) { + const cache = createCache([listBuckets[0]], getBucketAcl[2]); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 buckets to check', function (done) { + const cache = createCache([]); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list s3 buckets', function (done) { + const cache = createErrorCache(); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if s3 list buckets response is not found', function (done) { + const cache = createNullCache(); + bucketAllUsersAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketAllUsersPolicy.js b/plugins/aws/s3/bucketAllUsersPolicy.js index 1c9b418b3..2dd95ec96 100644 --- a/plugins/aws/s3/bucketAllUsersPolicy.js +++ b/plugins/aws/s3/bucketAllUsersPolicy.js @@ -3,22 +3,37 @@ var helpers = require('../../../helpers/aws/'); module.exports = { title: 'S3 Bucket All Users Policy', category: 'S3', + domain: 'Storage', + severity: 'Critical', description: 'Ensures S3 bucket policies do not allow global write, delete, or read permissions', more_info: 'S3 buckets can be configured to allow the global principal to access the bucket via the bucket policy. This policy should be restricted only to known users or accounts.', recommended_action: 'Remove wildcard principals from the bucket policy statements.', link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/using-iam-policies.html', - apis: ['S3:listBuckets', 'S3:getBucketPolicy'], + apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketLocation'], compliance: { pci: 'PCI requires that cardholder data can only be accessed by those with ' + 'a legitimate business need. If PCI-restricted data is stored in S3, ' + 'those buckets should not enable global user access.' }, + remediation_description: 'Bucket policy will be deleted for affected buckets.', + remediation_min_version: '202201131602', + apis_remediate: ['S3:listBuckets', 'S3:getBucketLocation'], + actions: { + remediate: ['S3:deleteBucketPolicy'], + rollback: ['S3:putBucketPolicy'] + }, + permissions: { + remediate: ['s3:DeleteBucketPolicy'], + rollback: ['s3:PutBucketPolicy'] + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:DeleteBucketPolicy','s3:DeleteBucket'], run: function(cache, settings, callback) { var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -40,7 +55,8 @@ module.exports = { var bucket = listBuckets.data[i]; if (!bucket.Name) continue; - var bucketResource = 'arn:aws:s3:::' + bucket.Name; + var bucketResource = `arn:${awsOrGov}:s3:::` + bucket.Name; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); var getBucketPolicy = helpers.addSource(cache, source, ['s3', 'getBucketPolicy', region, bucket.Name]); @@ -50,13 +66,13 @@ module.exports = { getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { helpers.addResult(results, 0, 'No additional bucket policy found', - 'global', bucketResource); + bucketLocation, bucketResource); } else if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data || !getBucketPolicy.data.Policy) { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + bucket.Name + ': ' + helpers.addError(getBucketPolicy), - 'global', bucketResource); + bucketLocation, bucketResource); } else { try { var policyJson = JSON.parse(getBucketPolicy.data.Policy); @@ -66,11 +82,11 @@ module.exports = { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + bucket.Name + ': Policy JSON is invalid or does not contain valid statements.', - 'global', bucketResource); + bucketLocation, bucketResource); } else if (!policyJson.Statement.length) { helpers.addResult(results, 0, 'Bucket policy does not contain any statements', - 'global', bucketResource); + bucketLocation, bucketResource); } else { var policyMessage = []; var policyResult = 0; @@ -115,22 +131,74 @@ module.exports = { if (!policyMessage.length) { helpers.addResult(results, 0, 'Bucket policy does not contain any insecure allow statements', - 'global', bucketResource); + bucketLocation, bucketResource); } else { helpers.addResult(results, policyResult, policyMessage.join(' '), - 'global', bucketResource); + bucketLocation, bucketResource); } } - } catch(e) { + } catch (e) { helpers.addResult(results, 3, 'Error querying for bucket policy for bucket: ' + bucket.Name + ': Policy JSON could not be parsed.', - 'global', bucketResource); + bucketLocation, bucketResource); } } } callback(null, results, source); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'bucketAllUsersPolicy'; + var bucketNameArr = resource.split(':'); + var bucketName = bucketNameArr[bucketNameArr.length - 1]; + + // find the location of the bucket needing to be remediated + var bucketLocations = cache['s3']['getBucketLocation']; + var bucketLocation; + + for (var key in bucketLocations) { + if (bucketLocations[key][bucketName]) { + bucketLocation = key; + break; + } + } + + // add the location of the bucket to the config + config.region = bucketLocation; + + // create the params necessary for the remediation + var params = { + Bucket: bucketName + }; + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Policy': 'Enabled', + 'Bucket': bucketName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Policy': 'Disabled', + 'Bucket': bucketName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; \ No newline at end of file diff --git a/plugins/aws/s3/bucketAllUsersPolicy.spec.js b/plugins/aws/s3/bucketAllUsersPolicy.spec.js new file mode 100644 index 000000000..b6f451ee0 --- /dev/null +++ b/plugins/aws/s3/bucketAllUsersPolicy.spec.js @@ -0,0 +1,188 @@ +var expect = require('chai').expect; +const bucketAllUsersPolicy = require('./bucketAllUsersPolicy'); + +const listBuckets = [ + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + } +]; + +const getBucketPolicy = [ + { + Policy: '{"Version":"2012-10-17","Statement":[{"Sid":"PublicReadGetObject","Effect":"Allow","Principal":"111122223333","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucketallusersacl/*"}]}' + }, + { + Policy: '{"Version":"2012-10-17","Statement":[{"Sid":"PublicReadGetObject","Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucketallusersacl/*"}]}' + }, + { + Policy: '{"Version":"2012-10-17","Statement":[]}' + }, + { + Policy: '{"Version":"2012-10-17"}' + }, +]; + +const createCache = (buckets, policy) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketPolicy: { + 'us-east-1': { + [bucketName]: { + data: policy + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing s3 buckets' + }, + }, + }, + getBucketPolicy: { + 'us-east-1': { + err: { + message: 'error while getting bucket policy' + }, + }, + }, + }, + }; +}; + +const createPolicyErrorCache = (buckets) => { + var bucketName = buckets[0].Name; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + } + }, + getBucketPolicy: { + 'us-east-1': { + [bucketName]: { + err: { + code: 'NoSuchBucketPolicy' + } + } + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketAllUsersPolicy', function () { + describe('run', function () { + it('should PASS if S3 bucket policy does not contain any insecure allow statements', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[0]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket policy contains insecure allow statements', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[1]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 buckets to check', function (done) { + const cache = createCache([]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if bucket policy does not contain any statements', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[2]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no additional bucket policy found', function (done) { + const cache = createPolicyErrorCache([listBuckets[0]]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if bucket policy is invalid JSON or does not contain valid statements', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[3]); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to list s3 buckets', function (done) { + const cache = createErrorCache(); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if s3 list buckets response is not found', function (done) { + const cache = createNullCache(); + bucketAllUsersPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/s3/bucketDnsCompliantName.js b/plugins/aws/s3/bucketDnsCompliantName.js new file mode 100644 index 000000000..d4d3fb914 --- /dev/null +++ b/plugins/aws/s3/bucketDnsCompliantName.js @@ -0,0 +1,54 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 DNS Compliant Bucket Names', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensures that S3 buckets have DNS complaint bucket names.', + more_info: 'S3 bucket names must be DNS-compliant and not contain period "." to enable S3 Transfer Acceleration and to use buckets over SSL.', + recommended_action: 'Recreate S3 bucket to use "-" instead of "." in S3 bucket names.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html', + apis: ['S3:listBuckets', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 's3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + `Unable to query for S3 buckets: ${helpers.addError(listBuckets)}`); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets found'); + return callback(null, results, source); + } + + for (var bucket of listBuckets.data) { + var resource = `arn:${awsOrGov}:s3:::${bucket.Name}`; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + if (bucket.Name && bucket.Name.indexOf('.') === -1) { + helpers.addResult(results, 0, + 'S3 bucket name is compliant with DNS naming requirements', + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + 'S3 bucket name is not compliant with DNS naming requirements', + bucketLocation, resource); + } + } + + callback(null, results, source); + }, +}; \ No newline at end of file diff --git a/plugins/aws/s3/bucketDnsCompliantName.spec.js b/plugins/aws/s3/bucketDnsCompliantName.spec.js new file mode 100644 index 000000000..c504afa98 --- /dev/null +++ b/plugins/aws/s3/bucketDnsCompliantName.spec.js @@ -0,0 +1,104 @@ +const expect = require('chai').expect; +var bucketDnsCompliantName = require('./bucketDnsCompliantName'); + +const listBuckets = [ + { + "Name": "s3buckettest", + "CreationDate": "2021-01-09T02:56:31+00:00" + }, + { + "Name": "s3.bucket.test", + "CreationDate": "2021-01-09T02:56:31+00:00" + }, +]; + +const createCache = (listBuckets, listBucketsErr) => { + var bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + err: listBucketsErr, + data: listBuckets + } + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + getBucketPolicy: { + 'us-east-1': { + [bucketName]: { + err: { + code: 'NoSuchBucketPolicy' + } + } + }, + } + } + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null + } + } + }; +}; + +describe('bucketDnsCompliantName', function () { + describe('run', function () { + it('should PASS if S3 bucket name is compliant with DNS naming requirements', function (done) { + const cache = createCache([listBuckets[0]], null); + bucketDnsCompliantName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket name is not complaint with DNS naming requirements', function (done) { + const cache = createCache([listBuckets[1]], null); + bucketDnsCompliantName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no S3 buckets found', function (done) { + const cache = createCache([]); + bucketDnsCompliantName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createCache(null, { message: "Unable to list buckets" }, null); + bucketDnsCompliantName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list S3 buckets response not found', function (done) { + const cache = createNullCache(); + bucketDnsCompliantName.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketEncryption.js b/plugins/aws/s3/bucketEncryption.js index 666f38341..664e62940 100644 --- a/plugins/aws/s3/bucketEncryption.js +++ b/plugins/aws/s3/bucketEncryption.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'S3 Bucket Encryption', category: 'S3', + domain: 'Storage', + severity: 'High', description: 'Ensures object encryption is enabled on S3 buckets', more_info: 'S3 object encryption provides fully-managed encryption of all objects uploaded to an S3 bucket.', recommended_action: 'Enable CMK KMS-based encryption for all S3 buckets.', link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/bucket-encryption.html', - apis: ['S3:listBuckets', 'S3:getBucketEncryption', 'KMS:listKeys', 'KMS:describeKey', 'KMS:listAliases', 'CloudFront:listDistributions'], + apis: ['S3:listBuckets', 'S3:getBucketEncryption', 'KMS:listKeys', 'KMS:describeKey', + 'KMS:listAliases', 'CloudFront:listDistributions', 'AppConfig:listApplications', 'AppConfig:listConfigurationProfiles', 'S3:getBucketWebsite', 'S3:getBucketLocation', 'STS:getCallerIdentity'], remediation_description: 'The impacted bucket will be configured to use either AES-256 encryption, or CMK-based encryption if a KMS key ID is provided.', remediation_min_version: '202006020730', apis_remediate: ['S3:listBuckets', 'S3:getBucketEncryption', 'S3:getBucketLocation'], @@ -20,11 +23,19 @@ module.exports = { remediate: ['s3:PutEncryptionConfiguration'], rollback: ['s3:PutEncryptionConfiguration'] }, - realtime_triggers: ['s3:DeleteBucketEncryption', 's3:CreateBucket'], + remediation_inputs: { + kmsKeyId: { + name: '(Optional) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + realtime_triggers: ['s3:DeleteBucketEncryption', 's3:CreateBucket', 's3:putBucketEncryption','s3:DeleteBucket'], settings: { s3_encryption_require_cmk: { name: 'S3 Encryption Require CMK', - description: 'When set to true S3 encryption using default KMS keys or AES will be marked as failing', + description: 'When set to true, S3 encryption using default KMS keys or AES will be marked as failing', regex: '^(true|false)$', default: 'false' }, @@ -32,17 +43,29 @@ module.exports = { name: 'S3 Encryption Allow Pattern', description: 'When set, whitelists buckets matching the given pattern. Useful for overriding buckets outside the account control.', regex: '^.{1,255}$', - default: false + default: '' }, s3_encryption_kms_alias: { name: 'S3 Encryption KMS Alias', description: 'If set, S3 encryption must be configured using the KMS key alias specified. Be sure to include the alias/ prefix. Comma-delimited.', - regex: '^alias/[a-zA-Z0-9_/-,]{0,256}$', - default: false + regex: '^alias/[a-zA-Z0-9_\\/,\\-]{0,256}$', + default: '' }, s3_encryption_allow_cloudfront: { name: 'S3 Encryption Allow CloudFront', - description: 'When set to true buckets that serve as CloudFront origins will not be required to have CMK encryption (which is unsupported by CloudFront).', + description: 'When set to true, buckets that serve as CloudFront origins will not be required to have CMK encryption (which is unsupported by CloudFront).', + regex: '^(true|false)$', + default: 'false' + }, + s3_allow_unencrypted_static_websites: { + name: 'S3 Allow Unencrypted Static Websites', + description: 'Allow buckets having static website enabled to skip encryption', + regex: '^(true|false)$', + default: 'false', + }, + whitelist_appconfig_s3_buckets: { + name: 'Whitelist AppConfig S3 Buckets', + description: 'When set to true, whitelists buckets which are source to AppConfig configuration profiles', regex: '^(true|false)$', default: 'false' } @@ -53,11 +76,16 @@ module.exports = { s3_encryption_require_cmk: settings.s3_encryption_require_cmk || this.settings.s3_encryption_require_cmk.default, s3_encryption_allow_pattern: settings.s3_encryption_allow_pattern || this.settings.s3_encryption_allow_pattern.default, s3_encryption_kms_alias: settings.s3_encryption_kms_alias || this.settings.s3_encryption_kms_alias.default, - s3_encryption_allow_cloudfront: settings.s3_encryption_allow_cloudfront || this.settings.s3_encryption_allow_cloudfront.default + s3_encryption_allow_cloudfront: settings.s3_encryption_allow_cloudfront || this.settings.s3_encryption_allow_cloudfront.default, + s3_allow_unencrypted_static_websites: settings.s3_allow_unencrypted_static_websites || this.settings.s3_allow_unencrypted_static_websites.default, + whitelist_appconfig_s3_buckets: settings.whitelist_appconfig_s3_buckets || this.settings.whitelist_appconfig_s3_buckets.default }; config.s3_encryption_require_cmk = (config.s3_encryption_require_cmk == 'true'); config.s3_encryption_allow_cloudfront = (config.s3_encryption_allow_cloudfront == 'true'); + config.s3_allow_unencrypted_static_websites = (config.s3_allow_unencrypted_static_websites == 'true'); + config.whitelist_appconfig_s3_buckets = (config.whitelist_appconfig_s3_buckets == 'true'); + var custom = helpers.isCustom(settings, this.settings); @@ -65,9 +93,14 @@ module.exports = { var source = {}; var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', defaultRegion, 'data']); + var cloudfrontOrigins = []; var aliasKeyIds = []; var defaultKeyIds = []; + var appConfigBuckets = []; var defaultKeyDesc = 'Default master key that protects my S3 objects'; async.series([ @@ -174,9 +207,57 @@ module.exports = { cb(); }, + function(cb){ + if (!config.whitelist_appconfig_s3_buckets) return cb(); + + async.each(regions.appconfig, function(region, rcb) { + var listApplications = helpers.addSource(cache, source, + ['appconfig', 'listApplications', region]); + + if (!listApplications) return rcb(); + + if (listApplications.err || !listApplications.data) { + helpers.addResult(results, 3, + 'Unable to query for AppConfig applications: ' + helpers.addError(listApplications), region); + return rcb(); + } + + if (listApplications.data.length) { + listApplications.data.forEach(function(application){ + if (!application.Id) return; + let resource = `arn:${awsOrGov}:appconfig:${region}:${accountId}:application/${application.Id}`; + + var listConfigurationProfiles = helpers.addSource(cache, source, + ['appconfig', 'listConfigurationProfiles', region, application.Id]); + + if (!listConfigurationProfiles || listConfigurationProfiles.err || + !listConfigurationProfiles.data || !listConfigurationProfiles.data.Items) { + helpers.addResult(results, 3, + `Unable to get configuration profiles description: ${helpers.addError(listConfigurationProfiles)}`, + region, resource); + return; + } + + if (listConfigurationProfiles.data.Items.length) { + for (let config of listConfigurationProfiles.data.Items){ + if (config.LocationUri && config.LocationUri.startsWith('s3://')) { + let bucketName = config.LocationUri.split('/')[2]; + if (!appConfigBuckets.includes(bucketName)) appConfigBuckets.push(bucketName); + } + } + } + }); + } + + rcb(); + }, function(){ + cb(); + }); + }, // Check the S3 buckets for encryption function(cb) { var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -198,11 +279,34 @@ module.exports = { config.s3_encryption_allow_pattern.length) ? new RegExp(config.s3_encryption_allow_pattern) : false; listBuckets.data.forEach(function(bucket){ + let bucketResource = `arn:${awsOrGov}:s3:::` + bucket.Name; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + if (config.whitelist_appconfig_s3_buckets && appConfigBuckets.includes(bucket.Name)) { + helpers.addResult(results, 0, + 'Bucket is a source to AppConfig configuration profile', + bucketLocation, bucketResource); + return; + } + if (allowRegex && allowRegex.test(bucket.Name)) { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' is whitelisted via custom setting.', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name, custom); } else { + if (config.s3_allow_unencrypted_static_websites) { + var getBucketWebsite = helpers.addSource(cache, source, ['s3', 'getBucketWebsite', region, bucket.Name]); + if (getBucketWebsite && getBucketWebsite.err && getBucketWebsite.err.code && getBucketWebsite.err.code === 'NoSuchWebsiteConfiguration') { + // do nothing + } else if (!getBucketWebsite || getBucketWebsite.err || !getBucketWebsite.data) { + helpers.addResult(results, 3, `Error querying for bucket website: ${bucket.Name}: ${helpers.addError(getBucketWebsite)}`, 'global', bucketResource); + return; + } else { + helpers.addResult(results, 0, + 'Bucket has static website hosting enabled', 'global', bucketResource, custom); + return; + } + } + var getBucketEncryption = helpers.addSource(cache, source, ['s3', 'getBucketEncryption', region, bucket.Name]); @@ -210,12 +314,12 @@ module.exports = { getBucketEncryption.err.code && getBucketEncryption.err.code == 'ServerSideEncryptionConfigurationNotFoundError') { helpers.addResult(results, 2, 'Bucket: ' + bucket.Name + ' has encryption disabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, bucketResource); } else if (!getBucketEncryption || getBucketEncryption.err || !getBucketEncryption.data) { helpers.addResult(results, 3, 'Error querying bucket encryption for: ' + bucket.Name + ': ' + helpers.addError(getBucketEncryption), - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, bucketResource); } else if (getBucketEncryption.data.ServerSideEncryptionConfiguration && getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules && getBucketEncryption.data.ServerSideEncryptionConfiguration.Rules[0] && @@ -230,11 +334,11 @@ module.exports = { cloudfrontOrigins.indexOf(bucket.Name) > -1) { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' has ' + algo + ' encryption enabled without a CMK but is a CloudFront origin', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } else { helpers.addResult(results, 2, 'Bucket: ' + bucket.Name + ' has ' + algo + ' encryption enabled but is not using a CMK', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } } else { if (config.s3_encryption_kms_alias) { @@ -242,15 +346,15 @@ module.exports = { cloudfrontOrigins.indexOf(bucket.Name) > -1) { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' has ' + algo + ' encryption enabled but is a CloudFront origin', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } else if (!aliasKeyIds.length) { helpers.addResult(results, 2, 'Bucket: ' + bucket.Name + ' has encryption enabled but matching KMS key alias ' + config.s3_encryption_kms_alias + ' could not be found in the account', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } else if (algo == 'aws:kms' && aliasKeyIds.indexOf(keyArn) > -1) { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' has ' + algo + ' encryption enabled using required KMS key: ' + keyArn, - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } else { var msg; if (algo !== 'aws:kms') { @@ -259,18 +363,18 @@ module.exports = { msg = 'Bucket: ' + bucket.Name + ' encryption (' + algo + ' with key: ' + keyArn + ') is not configured to use required KMS key'; } - helpers.addResult(results, 2, msg,'global', 'arn:aws:s3:::' + bucket.Name, custom); + helpers.addResult(results, 2, msg,bucketLocation, bucketResource, custom); } } else { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' has ' + algo + ' encryption enabled', - 'global', 'arn:aws:s3:::' + bucket.Name, custom); + bucketLocation, bucketResource, custom); } } } else { helpers.addResult(results, 2, 'Bucket: ' + bucket.Name + ' has encryption disabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, bucketResource); } } }); @@ -383,3 +487,4 @@ module.exports = { }); } }; + diff --git a/plugins/aws/s3/bucketEncryption.spec.js b/plugins/aws/s3/bucketEncryption.spec.js index cd8c97fc0..4791b66f5 100644 --- a/plugins/aws/s3/bucketEncryption.spec.js +++ b/plugins/aws/s3/bucketEncryption.spec.js @@ -114,7 +114,21 @@ const createCache = (cmk, bucketErr, sseAes, kms, cfMatching) => { "us-east-1": { "bucket1": bucketObj } - } + }, + "getBucketLocation": { + 'us-east-1': { + "bucket1": { + data: { + LocationConstraint: 'us-east-1' + } + }, + }, + }, + "getBucketWebsite": { + "us-east-1": { + "bucket1": bucketObj + } + }, }, "cloudfront": { "listDistributions": { @@ -130,6 +144,38 @@ const createCache = (cmk, bucketErr, sseAes, kms, cfMatching) => { ] } } + }, + "appconfig": { + "listApplications": { + "us-east-1": { + data: [ + { + "Id": "wt4b09t", + "Name": "appBucket" + } + ] + } + }, + "listConfigurationProfiles": { + "us-east-1": { + "wt4b09t": { + data: { + "Items": [ + { + "ApplicationId": "wt4b09t", + "Id": "nt17dsn", + "Name": "testConfig", + "LocationUri": "s3://bucket1/mine/AWSLogs/000011112222/Config/test", + "ValidatorTypes": [ + "LAMBDA" + ], + "Type": "AWS.Freeform" + } + ] + } + } + } + } } } }; @@ -141,6 +187,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('has encryption disabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -154,6 +201,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has AES256 encryption enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -162,11 +210,26 @@ describe('bucketEncryption', function () { s3.run(cache, {}, callback); }) + it('should give passing result if S3 bucket is a source to AppConfig configuration profile', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Bucket is a source to AppConfig configuration profile') + expect(results[0].region).to.equal('us-east-1'); + done() + }; + + const cache = createCache(false, false, true, false); + + s3.run(cache, { whitelist_appconfig_s3_buckets: 'true' }, callback); + }) + it('should give passing result if S3 bucket has AWS KMS encryption', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has aws:kms encryption enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -175,11 +238,25 @@ describe('bucketEncryption', function () { s3.run(cache, {}, callback); }) + it('should give passing result if s3_unencrypted_static_websites setting is enabled and bucket has static website hosting enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Bucket has static website hosting enabled') + done() + }; + + const cache = createCache(false, false, false, true); + + s3.run(cache, { s3_allow_unencrypted_static_websites: 'true' }, callback); + }) + it('should give passing result if S3 bucket has CMK KMS encryption', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has aws:kms encryption enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -193,6 +270,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('is whitelisted via custom setting') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -208,6 +286,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('has encryption disabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -223,6 +302,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('but is not using a CMK') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -236,6 +316,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('but is not using a CMK') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -249,6 +330,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has aws:kms encryption enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -262,6 +344,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has aws:kms encryption enabled using required KMS key') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -275,6 +358,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has aws:kms encryption enabled using required KMS key') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -288,6 +372,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('but matching KMS key alias alias/my-unknown-alias could not be found in the account') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -301,6 +386,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has AES256 encryption enabled without a CMK but is a CloudFront origin') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -317,6 +403,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('has AES256 encryption enabled but is a CloudFront origin') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -333,6 +420,7 @@ describe('bucketEncryption', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('encryption (AES256) is not configured to use required KMS key') + expect(results[0].region).to.equal('us-east-1'); done() }; diff --git a/plugins/aws/s3/bucketEncryptionInTransit.js b/plugins/aws/s3/bucketEncryptionInTransit.js index 577e9a2c9..9c3e5bdcb 100644 --- a/plugins/aws/s3/bucketEncryptionInTransit.js +++ b/plugins/aws/s3/bucketEncryptionInTransit.js @@ -3,7 +3,7 @@ var helpers = require('../../../helpers/aws'); function statementDeniesInsecureTransport(statement, bucketResource) { if (!statement) return false; return (statement.Effect === 'Deny') && - (statement.Principal === '*') && + helpers.globalPrincipal(statement.Principal) && (Array.isArray(statement.Action) ? statement.Action.find(action => action === '*' || action === 's3:*') : (statement.Action === '*' || statement.Action === 's3:*')) && @@ -21,17 +21,43 @@ function statementDeniesInsecureTransport(statement, bucketResource) { module.exports = { title: 'S3 Bucket Encryption In Transit', category: 'S3', + domain: 'Storage', + severity: 'High', description: 'Ensures S3 buckets have bucket policy statements that deny insecure transport', more_info: 'S3 bucket policies can be configured to deny access to the bucket over HTTP.', recommended_action: 'Add statements to the bucket policy that deny all S3 actions when SecureTransport is false. Resources must be list of bucket ARN and bucket ARN with wildcard.', link: 'https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/', - apis: ['S3:listBuckets', 'S3:getBucketPolicy'], + apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketWebsite', 'S3:getBucketLocation'], + remediation_description: 'The policy that deny all S3 actions when SecureTransport is false will be added in the impacted buckets.', + remediation_min_version: '202006020730', + apis_remediate: ['S3:listBuckets', 'S3:getBucketPolicy'], + actions: { + remediate: ['S3:putBucketPolicy'], + rollback: ['S3:putBucketPolicy'] + }, + permissions: { + remediate: ['s3:PutBucketPolicy'], + rollback: ['s3:PutBucketPolicy '] + }, + realtime_triggers: ['s3:putBucketPolicy', 's3:CreateBucket','s3:DeleteBucket'], + settings: { + s3_allow_unencrypted_static_websites: { + name: 'S3 Allow Unencrypted Static Websites', + description: 'Allow buckets having static website enabled to skip encryption', + regex: '^(true|false)$', + default: 'false', + } + }, run: function(cache, settings, callback) { var results = []; var source = {}; + var s3_allow_unencrypted_static_websites = settings.s3_allow_unencrypted_static_websites || this.settings.s3_allow_unencrypted_static_websites.default; + var allowSkipEncryption = (s3_allow_unencrypted_static_websites == 'true'); + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -47,15 +73,30 @@ module.exports = { } for (let bucket of listBuckets.data) { - var bucketResource = `arn:aws:s3:::${bucket.Name}`; + var bucketResource = `arn:${awsOrGov}:s3:::${bucket.Name}`; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + if (allowSkipEncryption) { + var getBucketWebsite = helpers.addSource(cache, source, ['s3', 'getBucketWebsite', region, bucket.Name]); + if (getBucketWebsite && getBucketWebsite.err && getBucketWebsite.err.code && getBucketWebsite.err.code === 'NoSuchWebsiteConfiguration') { + // do nothing + } else if (!getBucketWebsite || getBucketWebsite.err || !getBucketWebsite.data) { + helpers.addResult(results, 3, `Error querying for bucket website: ${bucket.Name}: ${helpers.addError(getBucketWebsite)}`, 'global', bucketResource); + continue; + } else { + helpers.addResult(results, 0, + 'Bucket has static website hosting enabled', 'global', bucketResource); + continue; + } + } var getBucketPolicy = helpers.addSource(cache, source, ['s3', 'getBucketPolicy', region, bucket.Name]); if (getBucketPolicy && getBucketPolicy.err && getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { - helpers.addResult(results, 2, 'No bucket policy found; encryption in transit not enforced', 'global', bucketResource); + helpers.addResult(results, 2, 'No bucket policy found; encryption in transit not enforced', bucketLocation, bucketResource); continue; } if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data || !getBucketPolicy.data.Policy) { - helpers.addResult(results, 3, `Error querying for bucket policy on bucket: ${bucket.Name}: ${helpers.addError(getBucketPolicy)}`, 'global', bucketResource); + helpers.addResult(results, 3, `Error querying for bucket policy on bucket: ${bucket.Name}: ${helpers.addError(getBucketPolicy)}`, bucketLocation, bucketResource); continue; } try { @@ -66,25 +107,123 @@ module.exports = { } else { policyJson = getBucketPolicy.data.Policy; } - } catch(e) { - helpers.addResult(results, 3, `Bucket policy on bucket ${bucket.Name} could not be parsed.`, 'global', bucketResource); + } catch (e) { + helpers.addResult(results, 3, `Bucket policy on bucket ${bucket.Name} could not be parsed.`, bucketLocation, bucketResource); continue; } if (!policyJson || !policyJson.Statement) { - helpers.addResult(results, 3, `Error querying for bucket policy for bucket: ${bucket.Name}: Policy JSON is invalid or does not contain valid statements.`, 'global', bucketResource); + helpers.addResult(results, 3, `Error querying for bucket policy for bucket: ${bucket.Name}: Policy JSON is invalid or does not contain valid statements.`, bucketLocation, bucketResource); continue; } if (!policyJson.Statement.length) { - helpers.addResult(results, 2, 'Bucket policy does not contain any statements; encryption in transit not enforced', 'global', bucketResource); + helpers.addResult(results, 2, 'Bucket policy does not contain any statements; encryption in transit not enforced', bucketLocation, bucketResource); continue; } if (policyJson.Statement.find(statement => statementDeniesInsecureTransport(statement, bucketResource))) { - helpers.addResult(results, 0, 'Bucket policy enforces encryption in transit', 'global', bucketResource); + helpers.addResult(results, 0, 'Bucket policy enforces encryption in transit', bucketLocation, bucketResource); } else { - helpers.addResult(results, 2, 'Bucket does not enforce encryption in transit', 'global', bucketResource); + helpers.addResult(results, 2, 'Bucket does not enforce encryption in transit', bucketLocation, bucketResource); } } callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'bucketEncryptionInTransit'; + var bucketNameArr = resource.split(':'); + var bucketName = bucketNameArr[bucketNameArr.length - 1]; + + // find the location of the bucket needing to be remediated + var bucketPolicies = cache['s3']['getBucketPolicy']; + var bucketLocation; + var err; + if ( !bucketPolicies || bucketPolicies.err || Object.keys(bucketPolicies).length){ + err = bucketLocation.err || 'Unable to get bucket location'; + return callback(err, null); + } + + for (var key in bucketPolicies) { + if (bucketPolicies[key][bucketName]) { + bucketLocation = key; + break; + } + } + var policy = bucketPolicies[key][bucketName]; + // add the location of the bucket to the config + if (!bucketLocation) { + err = 'Unable to get bucket location'; + return callback(err, null); + } + + // create the params necessary for the remediation + var params = {}; + var SecureTransport = { + 'Sid':'DenyInSecureTransport', + 'Effect': 'Deny', + 'Principal': '*', + 'Action': 's3:*', + 'Resource': [resource,resource+'/*'], + 'Condition': { + 'Bool': { + 'aws:SecureTransport': 'false' + } + } + }; + var policyBody = { + 'Version': '2012-10-17' + }; + if (policy.err && policy.err.code && + policy.err.code === 'NoSuchBucketPolicy'){ + policyBody['Statement'] = SecureTransport; + params = { + 'Bucket': bucketName, + 'Policy': JSON.stringify(policyBody) + }; + } else { + if (policy.data && policy.data.Policy){ + var policyJson; + if (typeof policy.data.Policy === 'string') { + policyJson = JSON.parse(policy.data.Policy); + } else { + policyJson = policy.data.Policy; + } + policyJson.Statement.push(SecureTransport); + params = { + 'Bucket': bucketName, + 'Policy': JSON.stringify(policyJson) + }; + } + } + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'VersioningConfiguration': 'Suspended', + 'Bucket': bucketName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'VersioningConfiguration': 'Enabled', + 'Bucket': bucketName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + rollback: function(config, cache, settings, resource, callback){ + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); } }; diff --git a/plugins/aws/s3/bucketEncryptionInTransit.spec.js b/plugins/aws/s3/bucketEncryptionInTransit.spec.js index a832f73b7..955e95fd0 100644 --- a/plugins/aws/s3/bucketEncryptionInTransit.spec.js +++ b/plugins/aws/s3/bucketEncryptionInTransit.spec.js @@ -23,6 +23,15 @@ const createCacheUnparsed = (statement) => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, }; } @@ -48,6 +57,26 @@ const createCache = (statement) => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + getBucketWebsite: { + 'us-east-1': { + mybucket: { + data: { + "IndexDocument": { + "Suffix": "index.html" + } + }, + }, + }, + }, }, }; }; @@ -121,6 +150,15 @@ describe('bucketEncryptionInTransit', function () { }); }); + it('should PASS if s3_unencrypted_static_websites if enabled and bucket has static website hosting enabled', function (done) { + const cache = createCache(); + bucketEncryptionInTransit.run(cache, { s3_allow_unencrypted_static_websites: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + it('should PASS when there are no buckets', function (done) { const cache = createCacheNoBuckets(); bucketEncryptionInTransit.run(cache, {}, (err, results) => { @@ -146,6 +184,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -180,6 +219,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -197,6 +237,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -214,6 +255,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -231,6 +273,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -248,6 +291,7 @@ describe('bucketEncryptionInTransit', function () { bucketEncryptionInTransit.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); diff --git a/plugins/aws/s3/bucketEnforceEncryption.js b/plugins/aws/s3/bucketEnforceEncryption.js index b683ba53f..ac4140cb1 100644 --- a/plugins/aws/s3/bucketEnforceEncryption.js +++ b/plugins/aws/s3/bucketEnforceEncryption.js @@ -3,11 +3,13 @@ var helpers = require('../../../helpers/aws/'); module.exports = { title: 'S3 Bucket Enforce Object Encryption', category: 'S3', + domain: 'Storage', + severity: 'High', description: 'Ensures S3 bucket policies do not allow uploads of unencrypted objects', more_info: 'S3 bucket policies can be configured to block uploads of objects that are not encrypted.', recommended_action: 'Set the S3 bucket policy to deny uploads of unencrypted objects.', link: 'https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/', - apis: ['S3:listBuckets', 'S3:getBucketPolicy'], + apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketLocation'], settings: { s3_enforce_encryption_require_cmk: { name: 'S3 Enforce Encryption Require CMK', @@ -19,9 +21,10 @@ module.exports = { name: 'S3 Enforce Encryption Allow Pattern', description: 'When set, whitelists buckets matching the given pattern. Useful for overriding buckets outside the account control.', regex: '^.{1,255}$', - default: false + default: '' } }, + realtime_triggers: ['s3:CreateBucket' , 's3:PutBucketPolicy','s3:DeleteBucketPolicy','s3:DeleteBucket'], run: function(cache, settings, callback) { var config = { @@ -37,6 +40,7 @@ module.exports = { var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -60,98 +64,122 @@ module.exports = { for (var i in listBuckets.data) { var bucket = listBuckets.data[i]; if (!bucket.Name) continue; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); - var bucketResource = 'arn:aws:s3:::' + bucket.Name; + var bucketResource = `arn:${awsOrGov}:s3:::` + bucket.Name; if (allowRegex && allowRegex.test(bucket.Name)) { helpers.addResult(results, 0, 'Bucket: ' + bucket.Name + ' is whitelisted via custom setting.', - 'global', bucketResource, custom); + bucketLocation, bucketResource, custom); continue; } - var getBucketPolicy = helpers.addSource(cache, source, - ['s3', 'getBucketPolicy', region, bucket.Name]); - - // Check the bucket policy - if (getBucketPolicy && getBucketPolicy.err && - getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { - helpers.addResult(results, 2, - 'No bucket policy found', - 'global', bucketResource); - } else if (!getBucketPolicy || getBucketPolicy.err || - !getBucketPolicy.data || !getBucketPolicy.data.Policy) { - helpers.addResult(results, 3, - 'Error querying for bucket policy for bucket: ' + bucket.Name + - ': ' + helpers.addError(getBucketPolicy), - 'global', bucketResource); - } else { - try { - var policyJson = JSON.parse(getBucketPolicy.data.Policy); - getBucketPolicy.data.Policy = policyJson; + if (config.s3_enforce_encryption_require_cmk) { - if (!policyJson || !policyJson.Statement) { - helpers.addResult(results, 3, - 'Error querying for bucket policy for bucket: ' + bucket.Name + - ': Policy JSON is invalid or does not contain valid statements.', - 'global', bucketResource); - } else if (!policyJson.Statement.length) { - helpers.addResult(results, 2, - 'Bucket policy does not contain any statements', - 'global', bucketResource); - } else { - var encryptionType; - var nullCondition = false; - - for (var s in policyJson.Statement) { - var statement = policyJson.Statement[s]; - - if (statement.Effect && - statement.Effect === 'Deny' && - statement.Principal && - ((typeof statement.Principal == 'string' && statement.Principal == '*') || - (Array.isArray(statement.Principal) && statement.indexOf('*') > -1)) && - statement.Action && - ((typeof statement.Action == 'string' && statement.Action == 's3:PutObject') || - (Array.isArray(statement.Action) && statement.indexOf('s3:PutObject') > -1)) && - statement.Resource && - ((typeof statement.Resource == 'string' && statement.Resource == (bucketResource + '/*')) || - (Array.isArray(statement.Principal) && statement.indexOf(bucketResource + '/*') > -1)) && - statement.Condition) { - if (statement.Condition.StringNotEquals && - statement.Condition.StringNotEquals['s3:x-amz-server-side-encryption']) { - encryptionType = statement.Condition.StringNotEquals['s3:x-amz-server-side-encryption']; - } else if (statement.Condition.Null && - statement.Condition.Null['s3:x-amz-server-side-encryption']) { - nullCondition = true; - } + var getBucketPolicy = helpers.addSource(cache, source, + ['s3', 'getBucketPolicy', region, bucket.Name]); + + // Check the bucket policy + if (getBucketPolicy && getBucketPolicy.err && + getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { + helpers.addResult(results, 2, + 'No bucket policy found', + bucketLocation, bucketResource); + } else if (!getBucketPolicy || getBucketPolicy.err || + !getBucketPolicy.data || !getBucketPolicy.data.Policy) { + helpers.addResult(results, 3, + 'Error querying for bucket policy for bucket: ' + bucket.Name + + ': ' + helpers.addError(getBucketPolicy), + bucketLocation, bucketResource); + } else { + try { + var policyJson; + + if (typeof getBucketPolicy.data.Policy == 'object') { + policyJson = getBucketPolicy.data.Policy; + + } else { + try { + policyJson = JSON.parse(getBucketPolicy.data.Policy); + } catch (e) { + helpers.addResult(results, 3, + `Error querying for bucket policy for bucket: "${bucket.Name}". Policy JSON could not be parsed`, + bucketLocation, bucketResource); + return; } } - if (nullCondition && encryptionType) { - if (config.s3_enforce_encryption_require_cmk && encryptionType !== 'aws:kms') { - helpers.addResult(results, 2, - 'Bucket policy requires encryption on object uploads but is not enforcing AWS KMS type', - 'global', bucketResource, custom); + if (!policyJson || !policyJson.Statement) { + helpers.addResult(results, 3, + 'Error querying for bucket policy for bucket: ' + bucket.Name + + ': Policy JSON is invalid or does not contain valid statements.', + bucketLocation, bucketResource); + } else if (!policyJson.Statement.length) { + helpers.addResult(results, 2, + 'Bucket policy does not contain any statements', + bucketLocation, bucketResource); + } else { + var encryptionType; + var nullCondition = false; + + for (var s in policyJson.Statement) { + var statement = policyJson.Statement[s]; + + if (statement.Effect && + statement.Effect === 'Deny' && + statement.Principal && + ((helpers.globalPrincipal(statement.Principal)) || + (Array.isArray(statement.Principal) && statement.indexOf('*') > -1)) && + statement.Action && + ((typeof statement.Action == 'string' && statement.Action == 's3:PutObject') || + (Array.isArray(statement.Action) && statement.indexOf('s3:PutObject') > -1)) && + statement.Resource && + ((typeof statement.Resource == 'string' && statement.Resource == (bucketResource + '/*')) || + (Array.isArray(statement.Principal) && statement.indexOf(bucketResource + '/*') > -1)) && + statement.Condition) { + if (statement.Condition.StringNotEquals && + statement.Condition.StringNotEquals['s3:x-amz-server-side-encryption']) { + encryptionType = statement.Condition.StringNotEquals['s3:x-amz-server-side-encryption']; + } else if (statement.Condition.Null && + statement.Condition.Null['s3:x-amz-server-side-encryption']) { + nullCondition = true; + } + } + } + + if (nullCondition && encryptionType) { + if ((config.s3_enforce_encryption_require_cmk && encryptionType !== 'aws:kms')) { + helpers.addResult(results, 2, + 'Bucket policy requires encryption on object uploads but is not enforcing AWS KMS type', + bucketLocation, bucketResource, custom); + } else { + helpers.addResult(results, 0, + 'Bucket policy requires encryption on object uploads', + bucketLocation, bucketResource, custom); + } } else { - helpers.addResult(results, 0, - 'Bucket policy requires encryption on object uploads', - 'global', bucketResource, custom); + helpers.addResult(results, 2, 'Bucket is missing required encryption enforcement policies.', + bucketLocation, bucketResource); } - } else { - helpers.addResult(results, 2, 'Bucket is missing required encryption enforcement policies.', - 'global', bucketResource); } + } catch (e) { + helpers.addResult(results, 3, + 'Error querying for bucket policy for bucket: ' + bucket.Name + + ': Policy JSON could not be parsed.', + bucketLocation, bucketResource); } - } catch(e) { - helpers.addResult(results, 3, - 'Error querying for bucket policy for bucket: ' + bucket.Name + - ': Policy JSON could not be parsed.', - 'global', bucketResource); } + + } else { + helpers.addResult(results, 0, + 'Bucket has required encryption on object uploads', + bucketLocation, bucketResource); } + + } callback(null, results, source); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/s3/bucketEnforceEncryption.spec.js b/plugins/aws/s3/bucketEnforceEncryption.spec.js index 28c1ec9a9..09bdb37ee 100644 --- a/plugins/aws/s3/bucketEnforceEncryption.spec.js +++ b/plugins/aws/s3/bucketEnforceEncryption.spec.js @@ -18,6 +18,15 @@ const createCache = (err, data) => { data: data } } + }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } } } }; @@ -29,20 +38,22 @@ describe('bucketEnforceEncryption', function () { const cache = createCache({ code: 'NoSuchBucketPolicy' }); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, {s3_enforce_encryption_require_cmk:'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No bucket policy found') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); it('should UNKNOWN if bucket policy has no data', function (done) { const cache = createCache(); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, {s3_enforce_encryption_require_cmk: 'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); expect(results[0].message).to.include('Error querying for bucket policy') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -53,10 +64,11 @@ describe('bucketEnforceEncryption', function () { Statement: [] }) }); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, {s3_enforce_encryption_require_cmk: 'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('Bucket policy does not contain any statements') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -72,11 +84,11 @@ describe('bucketEnforceEncryption', function () { "Resource": "arn:aws:s3:::mybucket/*", "Condition": { "StringNotEquals": { - "s3:x-amz-server-side-encryption": "aws:kms" + "s3:x-amz-server-side-encryption": "aws:kms" } } - }, - { + }, + { "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", @@ -86,14 +98,15 @@ describe('bucketEnforceEncryption', function () { "s3:x-amz-server-side-encryption": true } } - } + } ] }) }); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, { s3_enforce_encryption_require_cmk: 'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('Bucket policy requires encryption on object uploads') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -109,11 +122,11 @@ describe('bucketEnforceEncryption', function () { "Resource": "arn:aws:s3:::mybucket/*", "Condition": { "StringNotEquals": { - "s3:x-amz-server-side-encryption": "AES256" + "s3:x-amz-server-side-encryption": "aws:kms" } } - }, - { + }, + { "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", @@ -123,14 +136,15 @@ describe('bucketEnforceEncryption', function () { "s3:x-amz-server-side-encryption": true } } - } + } ] }) }); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, {s3_enforce_encryption_require_cmk:'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('Bucket policy requires encryption on object uploads') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -146,11 +160,11 @@ describe('bucketEnforceEncryption', function () { "Resource": "arn:aws:s3:::mybucket/*", "Condition": { "StringNotEquals": { - "s3:x-amz-server-side-encryption": "AES256" + "s3:x-amz-server-side-encryption": "AES256" } } - }, - { + }, + { "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", @@ -160,14 +174,15 @@ describe('bucketEnforceEncryption', function () { "s3:x-amz-server-side-encryption": false } } - } + } ] }) }); - bucketEnforceEncryption.run(cache, {}, (err, results) => { + bucketEnforceEncryption.run(cache, {s3_enforce_encryption_require_cmk: 'true'}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('Bucket is missing required encryption enforcement policies') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -183,11 +198,11 @@ describe('bucketEnforceEncryption', function () { "Resource": "arn:aws:s3:::mybucket/*", "Condition": { "StringNotEquals": { - "s3:x-amz-server-side-encryption": "AES256" + "s3:x-amz-server-side-encryption": "AES256" } } - }, - { + }, + { "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", @@ -197,7 +212,7 @@ describe('bucketEnforceEncryption', function () { "s3:x-amz-server-side-encryption": true } } - } + } ] }) }); @@ -207,6 +222,7 @@ describe('bucketEnforceEncryption', function () { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('Bucket policy requires encryption on object uploads but is not enforcing AWS KMS type') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -222,11 +238,11 @@ describe('bucketEnforceEncryption', function () { "Resource": "arn:aws:s3:::mybucket/*", "Condition": { "StringNotEquals": { - "s3:x-amz-server-side-encryption": "aws:kms" + "s3:x-amz-server-side-encryption": "aws:kms" } } - }, - { + }, + { "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", @@ -236,7 +252,7 @@ describe('bucketEnforceEncryption', function () { "s3:x-amz-server-side-encryption": true } } - } + } ] }) }); @@ -246,6 +262,7 @@ describe('bucketEnforceEncryption', function () { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('Bucket policy requires encryption on object uploads') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -260,6 +277,7 @@ describe('bucketEnforceEncryption', function () { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('is whitelisted via custom setting') + expect(results[0].region).to.equal('us-east-1'); done(); }); }); diff --git a/plugins/aws/s3/bucketLifecycleConfiguration.js b/plugins/aws/s3/bucketLifecycleConfiguration.js new file mode 100644 index 000000000..e25a43000 --- /dev/null +++ b/plugins/aws/s3/bucketLifecycleConfiguration.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Bucket Lifecycle Configuration', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensures that S3 buckets have lifecycle configuration enabled to automatically transition S3 bucket objects.', + more_info: 'S3 bucket should have lifecycle configuration enabled to automatically downgrade the storage class for your objects.', + recommended_action: 'Update S3 bucket and create lifecycle rule configuration', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/how-to-set-lifecycle-configuration-intro.html', + apis: ['S3:listBuckets', 'S3:getBucketLifecycleConfiguration', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketLifeCycleConfiguration', 's3:DeleteBucketLifeCycle','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + `Unable to query for S3 buckets: ${helpers.addError(listBuckets)}`); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets found'); + return callback(null, results, source); + } + + async.each(listBuckets.data, function(bucket, cb){ + if (!bucket.Name) return cb(); + + var resource = `arn:${awsOrGov}:s3:::${bucket.Name}`; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + var getBucketLifecycleConfiguration = helpers.addSource(cache, source, + ['s3', 'getBucketLifecycleConfiguration', region, bucket.Name]); + + if (getBucketLifecycleConfiguration && getBucketLifecycleConfiguration.err && + getBucketLifecycleConfiguration.err.code && getBucketLifecycleConfiguration.err.code == 'NoSuchLifecycleConfiguration') { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} does not have lifecycle configuration enabled`, + bucketLocation, resource); + } else if (!getBucketLifecycleConfiguration || getBucketLifecycleConfiguration.err || !getBucketLifecycleConfiguration.data) { + helpers.addResult(results, 3, + `Unable to query for S3 bucket lifecycle configuration: ${helpers.addError(getBucketLifecycleConfiguration)}`, + bucketLocation, resource); + } else if (getBucketLifecycleConfiguration.data.Rules && + getBucketLifecycleConfiguration.data.Rules.length) { + var ruleExists = getBucketLifecycleConfiguration.data.Rules.find(rule => rule.Status && rule.Status.toUpperCase() === 'ENABLED'); + + if (ruleExists) { + helpers.addResult(results, 0, + `S3 bucket ${bucket.Name} has lifecycle configuration enabled`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} does not have lifecycle configuration enabled`, + bucketLocation, resource); + } + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} does not have lifecycle configuration enabled`, + bucketLocation, resource); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/s3/bucketLifecycleConfiguration.spec.js b/plugins/aws/s3/bucketLifecycleConfiguration.spec.js new file mode 100644 index 000000000..8c53ab2f2 --- /dev/null +++ b/plugins/aws/s3/bucketLifecycleConfiguration.spec.js @@ -0,0 +1,157 @@ +const expect = require('chai').expect; +var bucketLifecycleConfiguration = require('./bucketLifecycleConfiguration'); + +const listBuckets = [ + { + "Name": "s3buckettest", + "CreationDate": "2021-01-09T02:56:31+00:00" + }, +]; + +const getBucketLifecycleConfiguration = [ + { + "Rules": [ + { + "ID": "akd-36", + "Filter": { + "Prefix": "t-" + }, + "Status": "Enabled", + "Transitions": [ + { + "Days": 900, + "StorageClass": "STANDARD_IA" + } + ] + } + ] + }, + { + "Rules": [ + { + "ID": "akd-36", + "Filter": { + "Prefix": "t-" + }, + "Status": "Disabled", + "Transitions": [ + { + "Days": 900, + "StorageClass": "STANDARD_IA" + } + ] + } + ] + } +] + +const createCache = (listBuckets, getBucketLifecycleConfiguration, listBucketsErr, getBucketLifecycleConfigurationErr) => { + var bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + err: listBucketsErr, + data: listBuckets + } + }, + getBucketLifecycleConfiguration: { + 'us-east-1': { + [bucketName]: { + err: getBucketLifecycleConfigurationErr, + data: getBucketLifecycleConfiguration + } + } + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null + } + } + }; +}; + +describe('bucketLifecycleConfiguration', function () { + describe('run', function () { + it('should PASS if S3 bucket has lifecycle configuration enabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketLifecycleConfiguration[0], null, null); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket does not have lifecycle configuration enabled', function (done) { + const cache = createCache([listBuckets[0]], {}, null, null); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has lifecycle configuration disabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketLifecycleConfiguration[1], null); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 buckets found', function (done) { + const cache = createCache([]); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createCache(null, null, { message: "Unable to list buckets" }, null); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get bucket lifecycle configuration', function (done) { + const cache = createCache([listBuckets[0]], null, null, { message: "Unable to get bucket lifecycle configuration"}); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list S3 buckets response not found', function (done) { + const cache = createNullCache(); + bucketLifecycleConfiguration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketLogging.js b/plugins/aws/s3/bucketLogging.js index a6f289b4b..8a11aec45 100644 --- a/plugins/aws/s3/bucketLogging.js +++ b/plugins/aws/s3/bucketLogging.js @@ -3,13 +3,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'S3 Bucket Logging', category: 'S3', + domain: 'Storage', + severity: 'Medium', description: 'Ensures S3 bucket logging is enabled for S3 buckets', more_info: 'S3 bucket logging helps maintain an audit trail of \ access that can be used in the event of a security \ incident.', recommended_action: 'Enable bucket logging for each S3 bucket.', link: 'http://docs.aws.amazon.com/AmazonS3/latest/dev/Logging.html', - apis: ['S3:listBuckets', 'S3:getBucketLogging'], + apis: ['S3:listBuckets', 'S3:getBucketLogging', 'S3:getBucketLocation'], compliance: { hipaa: 'HIPAA requires strict auditing controls around data access. ' + 'S3 logging helps ensure these controls are met by logging ' + @@ -18,12 +20,26 @@ module.exports = { pci: 'PCI requires logging of all network access to environments containing ' + 'cardholder data. Enable S3 bucket access logs to log these network requests.' }, - + asl: { + conditions: [ + { + service: 's3', + api: 'getBucketLogging', + property: 'LoggingEnabled', + transform: 'STRING', + op: 'EQ', + value: 'true' + } + ] + }, + realtime_triggers: ['s3:CreateBucket','s3:PutBucketLogging','s3:DeleteBucket'], + run: function(cache, settings, callback) { var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -45,19 +61,21 @@ module.exports = { var getBucketLogging = helpers.addSource(cache, source, ['s3', 'getBucketLogging', region, bucket.Name]); + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + if (!getBucketLogging || getBucketLogging.err || !getBucketLogging.data) { helpers.addResult(results, 3, 'Error querying bucket logging for : ' + bucket.Name + ': ' + helpers.addError(getBucketLogging), - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else if (getBucketLogging.data.LoggingEnabled) { helpers.addResult(results, 0, 'Bucket : ' + bucket.Name + ' has logging enabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else { helpers.addResult(results, 2, 'Bucket : ' + bucket.Name + ' has logging disabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } }); callback(null, results, source); diff --git a/plugins/aws/s3/bucketLogging.spec.js b/plugins/aws/s3/bucketLogging.spec.js new file mode 100644 index 000000000..fcf373239 --- /dev/null +++ b/plugins/aws/s3/bucketLogging.spec.js @@ -0,0 +1,170 @@ +var expect = require('chai').expect; +const bucketLogging = require('./bucketLogging'); + +const listBuckets = [ + { + Name: 'elasticbeanstalk-us-east-1-111122223333', + CreationDate: '2020-08-20T17:42:52.000Z' + }, + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + }, + { + Name: 'test-bucket-sploit-100', + CreationDate: '2020-09-06T09:44:16.000Z' + } +]; + +const getBucketLogging = [ + { + LoggingEnabled: { + TargetBucket: 'test-bucket-130', + TargetGrants: [], + TargetPrefix: '' + } + }, + {} +]; + +const createCache = (buckets, logging) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketLogging: { + 'us-east-1': { + [bucketName]: { + data: logging + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing S3 buckets' + }, + }, + }, + getBucketLogging: { + 'us-east-1': { + err: { + message: 'error while getting bucket logging' + }, + }, + }, + }, + }; +}; + +const createBucketLoggingErrorCache = (buckets) => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketLogging: { + 'us-east-1': { + err: { + message: 'error while getting bucket logging' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketLogging: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketLogging', function () { + describe('run', function () { + it('should PASS if S3 bucket has object logging enabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketLogging[0]); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has object logging disabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketLogging[1]); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 bucket found', function (done) { + const cache = createCache([]); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createErrorCache(); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to describe bucket logging', function (done) { + const cache = createBucketLoggingErrorCache([listBuckets[0]]); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if S3 list buckets response is not found', function (done) { + const cache = createNullCache(); + bucketLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketMFADeleteEnabled.js b/plugins/aws/s3/bucketMFADeleteEnabled.js new file mode 100644 index 000000000..02217960b --- /dev/null +++ b/plugins/aws/s3/bucketMFADeleteEnabled.js @@ -0,0 +1,85 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Bucket MFA Delete Status', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensures MFA delete is enabled on S3 buckets.', + more_info: 'Adding MFA delete adds another layer of security while changing the version state' + + 'in the event of security credentials being compromised or unauthorized' + + 'access being granted.', + recommended_action: 'Enable MFA Delete on S3 buckets.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html', + apis: ['S3:listBuckets', 'S3:getBucketVersioning', 'S3:getBucketLocation'], + compliance: { + cis1: '2.1.3 Ensure MFA Delete is enabled on S3 buckets', + }, + settings: { + whitelist_buckets_for_mfa_deletion: { + name: 'Whitelist Buckets For MFA Deletion', + description: 'List of comma separated buckets which should be whitelisted to check', + regex: '^.*$', + default: '', + } + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketVersionning','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + whitelist_buckets_for_mfa_deletion: settings.whitelist_buckets_for_mfa_deletion || this.settings.whitelist_buckets_for_mfa_deletion.default + }; + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets to check'); + return callback(null, results, source); + } + + listBuckets.data.forEach(function(bucket){ + if (bucket.Name == helpers.CLOUDEXPLOIT_EVENTS_BUCKET) return; + + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + if (config.whitelist_buckets_for_mfa_deletion.includes(bucket.Name)) { + helpers.addResult(results, 2, + 'Bucket : ' + bucket.Name + ' is whitelisted', + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + return; + } + + var getBucketVersioning = helpers.addSource(cache, source, + ['s3', 'getBucketVersioning', region, bucket.Name]); + + if (!getBucketVersioning || getBucketVersioning.err || !getBucketVersioning.data) { + helpers.addResult(results, 3, + 'Error querying bucket versioning for : ' + bucket.Name + + ': ' + helpers.addError(getBucketVersioning), + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (getBucketVersioning.data.MFADelete && getBucketVersioning.data.MFADelete.toUpperCase() === 'ENABLED') { + helpers.addResult(results, 0, + 'Bucket : ' + bucket.Name + ' has MFA Delete enabled', + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else { + helpers.addResult(results, 2, + 'Bucket : ' + bucket.Name + ' has MFA Delete disabled', + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + }); + callback(null, results, source); + } +}; diff --git a/plugins/aws/s3/bucketMFADeleteEnabled.spec.js b/plugins/aws/s3/bucketMFADeleteEnabled.spec.js new file mode 100644 index 000000000..49795bcd7 --- /dev/null +++ b/plugins/aws/s3/bucketMFADeleteEnabled.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +const bucketMFAStatus = require('./bucketMFADeleteEnabled'); + +const listBuckets = [ + { + Name: 'elasticbeanstalk-us-east-1-111122223333', + CreationDate: '2020-08-20T17:42:52.000Z' + }, + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + }, + { + Name: 'test-bucket-sploit-100', + CreationDate: '2020-09-06T09:44:16.000Z' + } +]; + +const getBucketVersioning = [ + { + Status: 'Enabled', + MFADelete: 'Enabled' + }, + { + Status: 'Enabled', + MFADelete: 'Disabled' + }, + {} +]; + +const createCache = (buckets, versioning) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + [bucketName]: { + data: versioning + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing s3 buckets' + }, + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createBucketVersioningErrorCache = (buckets) => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketVersioning: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketMFAStatus', function () { + describe('run', function () { + it('should PASS if S3 bucket has MFA Delete enabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0]); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has MFA Delete disabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[1]); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 bucket found', function (done) { + const cache = createCache([]); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list s3 buckets', function (done) { + const cache = createErrorCache(); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get bucket MFA Delete', function (done) { + const cache = createBucketVersioningErrorCache([listBuckets[0]]); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if s3 list buckets response is not found', function (done) { + const cache = createNullCache(); + bucketMFAStatus.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/s3/bucketPolicyCloudFrontOac.js b/plugins/aws/s3/bucketPolicyCloudFrontOac.js new file mode 100644 index 000000000..7a8c49b60 --- /dev/null +++ b/plugins/aws/s3/bucketPolicyCloudFrontOac.js @@ -0,0 +1,195 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Bucket Policy CloudFront OAC', + category: 'S3', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures S3 bucket is origin to only one distribution and allows only that distribution.', + more_info: 'Access to CloudFront origins should only happen via ClouFront URL and not from S3 URL or any source in order to restrict access to private data.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html', + recommended_action: 'Review the access policy for S3 bucket which is an origin to a CloudFront distribution. Make sure the S3 bucket is origin to only one distribution. ' + + 'Modify the S3 bucket access policy to allow CloudFront OAC for only the associated CloudFront distribution and restrict access from any other source.', + apis: ['CloudFront:listDistributions', 'S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketLocation', 'STS:getCallerIdentity'], + compliance: { + hipaa: 'HIPAA requires that access to protected information is controlled and audited. ' + + 'If an S3 bucket backing a CloudFront distribution does not require the end ' + + 'user to access the contents through CloudFront, this policy may be violated.' + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:DeleteBucketPolicy','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', region, 'data']); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No S3 origins to check'); + return callback(null, results, source); + } + + var s3OriginFound = false; + var s3BucketAssociations = {}; + listDistributions.data.forEach(distribution => { + if (distribution.Id && + distribution.DomainName && + distribution.DomainName.length && + distribution.Origins && + distribution.Origins.Items && + distribution.Origins.Items.length) { + for (let origin of distribution.Origins.Items) { + if (!origin.DomainName) continue; + let cfUser; + let bucketName = origin.DomainName.replace(/.s3.*.com/, ''); + + if (origin.OriginAccessControlId && origin.OriginAccessControlId.length) { + cfUser = `arn:${awsOrGov}:cloudfront::${accountId}:distribution/${distribution.Id}`; + createAssociation(s3BucketAssociations, bucketName, distribution.Id, cfUser); + s3BucketAssociations[bucketName][distribution.Id].OACfound = true; + s3OriginFound = true; + } else if (origin.S3OriginConfig && + origin.S3OriginConfig.OriginAccessIdentity && + origin.S3OriginConfig.OriginAccessIdentity.length) { + let oaiId = origin.S3OriginConfig.OriginAccessIdentity.substring(origin.S3OriginConfig.OriginAccessIdentity.lastIndexOf('/') + 1); + cfUser = `arn:${awsOrGov}:iam::cloudfront:user/CloudFront Origin Access Identity ${oaiId}`; + createAssociation(s3BucketAssociations,bucketName,distribution.Id,cfUser); + s3OriginFound = true; + } else { + s3BucketAssociations[bucketName] = {}; + s3BucketAssociations[bucketName][distribution.Id] = []; + s3BucketAssociations[bucketName][distribution.Id].OACfound = false; + } + + } + } + }); + + if (!s3OriginFound) { + helpers.addResult(results, 0, 'No S3 origins found for CloudFront distributions'); + return callback(null, results, source); + } + + async.each(Object.entries(s3BucketAssociations), function([bucketName, cfDistributions], cb){ + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucketName); + + if (Object.keys(cfDistributions).length > 1) { + helpers.addResult(results, 2, + `S3 bucket is origin to more than one distributions which are these: ${Object.keys(cfDistributions).join(', ')}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var distributionId = Object.keys(cfDistributions).toString(); + if (!s3BucketAssociations[bucketName][distributionId].OACfound) { + helpers.addResult(results, 2, + `S3 bucket is origin to distribution "${distributionId}" without an Origin Access Control`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var getBucketPolicy = helpers.addSource(cache, source, + ['s3', 'getBucketPolicy', region, bucketName]); + if (getBucketPolicy && getBucketPolicy.err && + getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { + helpers.addResult(results, 2, + `No bucket policy found for S3 bucket: ${bucketName}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data || !getBucketPolicy.data.Policy) { + helpers.addResult(results, 3, + `Error querying for bucket policy for bucket "${bucketName}": ${helpers.addError(getBucketPolicy)}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var statements = helpers.normalizePolicyDocument(getBucketPolicy.data.Policy); + + if (!statements || !statements.length) return cb(); + + var unknownConditions = []; + var restrictedOrigins = []; + var allowedOrigins = []; + var allowedConditions = ['aws:PrincipalArn','aws:PrincipalAccount','aws:PrincipalOrgID','aws:SourceAccount','aws:SourceArn','aws:SourceOwner']; + + for (var statement of statements) { + var conditions = typeof helpers.isValidCondition(statement, allowedConditions, helpers.IAM_CONDITION_OPERATORS, true) ==='boolean'? [] : helpers.isValidCondition(statement, allowedConditions, helpers.IAM_CONDITION_OPERATORS, true).flat(); + var principals = helpers.extractStatementPrincipals(statement).toString(); + if (principals.length) conditions.push(principals); + + for (var condition of conditions) { + if (statement.Effect && + statement.Effect.toUpperCase() === 'ALLOW' && + !(s3BucketAssociations[bucketName][distributionId].includes(condition)) && + !unknownConditions.includes(condition)) { + unknownConditions.push(condition); + } + + if (statement.Effect && + statement.Effect.toUpperCase() === 'DENY' && + s3BucketAssociations[bucketName][distributionId].includes(condition) && + !restrictedOrigins.includes(condition)) { + restrictedOrigins.push(condition); + } + + if (statement.Effect && + statement.Effect.toUpperCase() === 'ALLOW' && + s3BucketAssociations[bucketName][distributionId].includes(condition) && + !allowedOrigins.includes(condition)) { + allowedOrigins.push(condition); + } + } + } + var policyFailures = []; + var missingOrigins = s3BucketAssociations[bucketName][distributionId].filter(function(item) { + return !allowedOrigins.includes(item) && !restrictedOrigins.includes(item); + }); + + restrictedOrigins = restrictedOrigins.concat(missingOrigins); + + if (unknownConditions.length) policyFailures.push(`allows access to these unknown sources: ${unknownConditions.join(', ')}`); + if (restrictedOrigins.length) policyFailures.push(`does not allow access to these CloudFront origins: ${restrictedOrigins.join(', ')}`); + + if (policyFailures.length) { + helpers.addResult(results, 2, `S3 bucket is origin to distribution "${distributionId}" and ${policyFailures}`, bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + } else { + helpers.addResult(results, 0, + `S3 bucket is origin to only one CloudFront distribution which is: ${distributionId}`, bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; + +function createAssociation(s3BucketAssociations,bucketName,distributionId,cfUser){ + if (s3BucketAssociations[bucketName]) { + if (s3BucketAssociations[bucketName][distributionId]) s3BucketAssociations[bucketName][distributionId].push(cfUser); + else { + s3BucketAssociations[bucketName][distributionId] = [cfUser]; + } + } else { + s3BucketAssociations[bucketName] = {}; + s3BucketAssociations[bucketName][distributionId] = [cfUser]; + s3BucketAssociations[bucketName][distributionId].OACfound = false; + } + +} \ No newline at end of file diff --git a/plugins/aws/s3/bucketPolicyCloudFrontOac.spec.js b/plugins/aws/s3/bucketPolicyCloudFrontOac.spec.js new file mode 100644 index 000000000..14b526ed7 --- /dev/null +++ b/plugins/aws/s3/bucketPolicyCloudFrontOac.spec.js @@ -0,0 +1,435 @@ +var expect = require('chai').expect; +const bucketPolicyCloudFrontOac = require('./bucketPolicyCloudFrontOac'); + +const listBuckets = [ + { + "Name": "testBucket", + "CreationDate": "2021-01-10T13:45:10.000Z" + }, + { + "Name": "cdn-data", + "CreationDate": "2020-11-30T10:43:10.000Z" + }, + { + "Name": "cdn-oai", + "CreationDate": "2020-11-30T10:43:10.000Z" + }, + { + "Name": "cdn-oac", + "CreationDate": "2020-11-30T10:43:10.000Z" + }, + { + "Name": "auto-test-label-policy", + "CreationDate": "2021-01-10T13:45:10.000Z" + } +]; + +const getBucketPolicy = [ + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"AllowCloudFrontServicePrincipal\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudfront.amazonaws.com\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::testBucket/*\",\"Condition\":{\"StringEquals\":{\"AWS:SourceArn\":\"arn:aws:cloudfront::null:distribution/E154BVARTUU9DK\"}}}]}" + }, + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"AllowCloudFrontServicePrincipal\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"cloudfront.amazonaws.com\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::testBucket/*\",\"Condition\":{\"StringEquals\":{\"AWS:SourceArn\":[\"arn:aws:cloudfront::null:distribution/E154BVARTUU9DK\",\"arn:aws:cloudfront::null:distribution/E2234BVARTUU9DK\"]}}},{\"Sid\":\"3\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E13LDJYTPM4UR5\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::testBucket/*\"}]}" }, + { + "Policy": null + }, + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1TWULNL6D9ZZY\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::auto-test-label-policy/*\"},{\"Sid\":\"2\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1PUAUTKXKHAMG\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::auto-test-label-policy/*\"}]}" + }, + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1TWULNL6D9ZZY\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::auto-test-label-policy/*\"}]}" + } +]; + +const listDistributions = [ + { + "Id": "E154BVARTUU9DK", + "ARN": "arn:aws:cloudfront::123456789:distribution/E154BVARTUU9DK", + "Status": "Deployed", + "LastModifiedTime": "2022-12-26T13:22:59.155000+00:00", + "DomainName": "ditqwnuyq5wce.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "testBucket.s3.us-east-1.amazonaws.com", + "DomainName": "testBucket.s3.us-east-1.amazonaws.com", + "OriginPath": "/hello.com", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "E30EBFZ8ZXHG1P" + } + ] + }, + }, + { + "Id": "E16OS977N7ZR5X", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-my.bucket.khulnasoft/cdn-1", + "DomainName": "my.bucket.khulnasoft.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E16OS977N77TVS", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-cdn-data/cdn-1", + "DomainName": "cdn-data.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1GFXAXB2CIBLG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E16OS978FTE8VS", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-cdn-data/cdn-1", + "DomainName": "cdn-data.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1GFXAXB2CIBLG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E18PKR761IFLYC", + "ARN": "arn:aws:cloudfront::1234567:distribution/E18PKR761IFLYC", + "Status": "InProgress", + "LastModifiedTime": "2022-12-27T09:23:05.688000+00:00", + "DomainName": "d27mad1q3ms1tu.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "DomainName": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "OriginPath": "/abc", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1PUAUTKXKHAMG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "" + } + ] + } + }, + { + "Id": "E18PKR761IFLYC", + "ARN": "arn:aws:cloudfront::null:distribution/E18PKR761IFLYC", + "Status": "Deployed", + "LastModifiedTime": "2022-12-28T10:02:15.416000+00:00", + "DomainName": "d27mad1q3ms1tu.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 3, + "Items": [ + { + "Id": "hahaauto-test-label-policy.s3.us-east-1.amazonaws.com", + "DomainName": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "OriginPath": "", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1PUAUTKXKHAMG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "" + }, + { + "Id": "abcauto-test-label-policy.s3.us-east-1.amazonaws.com", + "DomainName": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "OriginPath": "", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "E1LC09HCRJUH5H" + }, + { + "Id": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "DomainName": "auto-test-label-policy.s3.us-east-1.amazonaws.com", + "OriginPath": "/abc", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1PUAUTKXKHAMG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "" + } + ] + }, + "OriginGroups": { + "Quantity": 0 + } + } +]; + +const createCache = (listDistributions, listBuckets, getBucketPolicy, policyErr) => { + var bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: listBuckets + }, + }, + getBucketPolicy: { + 'us-east-1': { + [bucketName]: { + err: policyErr, + data: getBucketPolicy + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + cloudfront: { + listDistributions: { + 'us-east-1': { + data: listDistributions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing S3 buckets' + }, + }, + }, + getBucketPolicy: { + 'us-east-1': { + err: { + message: 'error while getting bucket logging' + }, + }, + }, + }, + cloudfront: { + listDistributions: { + 'us-east-1': { + err: { + message: 'Unable to list distributions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null + } + } + }; +}; + +describe('bucketPolicyCloudFrontOac', function () { + describe('run', function () { + it('should PASS if no S3 origins found for CloudFront distributions', function (done) { + const cache = createCache([listDistributions[1]]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No S3 origins found for CloudFront distributions'); + done(); + }); + }); + + it('should PASS if S3 bucket is origin to only one CloudFront distribution', function (done) { + const cache = createCache([listDistributions[0]], [listBuckets[0]], getBucketPolicy[0]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('S3 bucket is origin to only one CloudFront distribution') + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to more than one distributions', function (done) { + const cache = createCache([listDistributions[2], listDistributions[3]], [listBuckets[1]]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('S3 bucket is origin to more than one distributions'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to distribution without an Origin Access Control', function (done) { + const cache = createCache([listDistributions[4]],[listBuckets[0]],getBucketPolicy[3]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('without an Origin Access Control'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to distribution and allows access to unknown sources', function (done) { + const cache = createCache([listDistributions[0]], [listBuckets[0]], getBucketPolicy[1]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('allows access to these unknown sources'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to distribution and does not allow access to these CloudFront origins', function (done) { + const cache = createCache([listDistributions[5]], [listBuckets[4]], getBucketPolicy[4]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('does not allow access to these CloudFront origins') + done(); + }); + }); + + it('should FAIL if no bucket policy found for S3 bucket', function (done) { + const cache = createCache([listDistributions[0]], [listBuckets[0]], getBucketPolicy[2], { code: 'NoSuchBucketPolicy' }); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No bucket policy found for S3 bucket'); + done(); + }); + }); + + it('should PASS if no S3 origins to check', function (done) { + const cache = createCache([]); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list CloudFront distributions', function (done) { + const cache = createErrorCache(); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if list distributions result not found', function (done) { + const cache = createNullCache(); + bucketPolicyCloudFrontOac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketPolicyCloudFrontOai.js b/plugins/aws/s3/bucketPolicyCloudFrontOai.js new file mode 100644 index 000000000..ed4d12eed --- /dev/null +++ b/plugins/aws/s3/bucketPolicyCloudFrontOai.js @@ -0,0 +1,186 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Bucket Policy CloudFront OAI', + category: 'S3', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures S3 bucket is origin to only one distribution and allows only that distribution.', + more_info: 'Access to CloudFront origins should only happen via ClouFront URL and not from S3 URL or any source in order to restrict access to private data.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html', + recommended_action: 'Review the access policy for S3 bucket which is an origin to a CloudFront distribution. Make sure the S3 bucket is origin to only one distribution. ' + + 'Modify the S3 bucket access policy to allow CloudFront OAI for only the associated CloudFront distribution and restrict access from any other source.', + apis: ['CloudFront:listDistributions', 'S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketLocation'], + compliance: { + hipaa: 'HIPAA requires that access to protected information is controlled and audited. ' + + 'If an S3 bucket backing a CloudFront distribution does not require the end ' + + 'user to access the contents through CloudFront, this policy may be violated.' + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:DeleteBucketPolicy','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listDistributions = helpers.addSource(cache, source, + ['cloudfront', 'listDistributions', region]); + + if (!listDistributions) return callback(null, results, source); + + if (listDistributions.err || !listDistributions.data) { + helpers.addResult(results, 3, + 'Unable to query for CloudFront distributions: ' + helpers.addError(listDistributions)); + return callback(null, results, source); + } + + if (!listDistributions.data.length) { + helpers.addResult(results, 0, 'No S3 origins to check'); + return callback(null, results, source); + } + + var s3OriginFound = false; + var s3BucketAssociations = {}; + listDistributions.data.forEach(distribution => { + if (distribution.Id && + distribution.DomainName && + distribution.DomainName.length && + distribution.Origins && + distribution.Origins.Items && + distribution.Origins.Items.length) { + + for (let origin of distribution.Origins.Items) { + if (origin.S3OriginConfig && origin.DomainName) { + s3OriginFound = true; + let bucketName = origin.DomainName.replace(/.s3.*.com/, ''); + if (bucketName && + origin.S3OriginConfig.OriginAccessIdentity && + origin.S3OriginConfig.OriginAccessIdentity.length) { + + let oaiId = origin.S3OriginConfig.OriginAccessIdentity.substring(origin.S3OriginConfig.OriginAccessIdentity.lastIndexOf('/') + 1); + let cfUser = `arn:${awsOrGov}:iam::cloudfront:user/CloudFront Origin Access Identity ${oaiId}`; + if (s3BucketAssociations[bucketName]) { + if (s3BucketAssociations[bucketName][distribution.Id]) s3BucketAssociations[bucketName][distribution.Id].push(cfUser); + else { + s3BucketAssociations[bucketName][distribution.Id] = [cfUser]; + } + } else { + s3BucketAssociations[bucketName] = {}; + s3BucketAssociations[bucketName][distribution.Id] = [cfUser]; + } + } else { + s3BucketAssociations[bucketName] = {}; + s3BucketAssociations[bucketName][distribution.Id] = []; + return; + } + } + } + } + }); + + if (!s3OriginFound) { + helpers.addResult(results, 0, 'No S3 origins found for CloudFront distributions'); + return callback(null, results, source); + } + + async.each(Object.entries(s3BucketAssociations), function([bucketName, cfDistributions], cb){ + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucketName); + + if (Object.keys(cfDistributions).length > 1) { + helpers.addResult(results, 2, + `S3 bucket is origin to more than one distributions which are these: ${Object.keys(cfDistributions).join(', ')}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var distributionId = Object.keys(cfDistributions).toString(); + if (!s3BucketAssociations[bucketName][distributionId].length) { + distributionId = Object.keys(cfDistributions).toString(); + helpers.addResult(results, 2, + `S3 bucket is origin to distribution "${distributionId}" without an origin access identity`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var getBucketPolicy = helpers.addSource(cache, source, + ['s3', 'getBucketPolicy', region, bucketName]); + if (getBucketPolicy && getBucketPolicy.err && + getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { + helpers.addResult(results, 2, + `No bucket policy found for S3 bucket: ${bucketName}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data || !getBucketPolicy.data.Policy) { + helpers.addResult(results, 3, + `Error querying for bucket policy for bucket "${bucketName}": ${helpers.addError(getBucketPolicy)}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + return cb(); + } + + var statements = helpers.normalizePolicyDocument(getBucketPolicy.data.Policy); + + if (!statements || !statements.length) return cb(); + + var unknownPrincipals = []; + var restrictedOrigins = []; + var allowedOrigins = []; + for (var statement of statements) { + var principals = helpers.extractStatementPrincipals(statement); + + for (var principal of principals) { + if (statement.Effect && + statement.Effect.toUpperCase() === 'ALLOW' && + !s3BucketAssociations[bucketName][distributionId].includes(principal) && + !unknownPrincipals.includes(principal)) { + unknownPrincipals.push(principal); + } + + if (statement.Effect && + statement.Effect.toUpperCase() === 'DENY' && + s3BucketAssociations[bucketName][distributionId].includes(principal) && + !restrictedOrigins.includes(principal)) { + restrictedOrigins.push(principal); + } + + if (statement.Effect && + statement.Effect.toUpperCase() === 'ALLOW' && + s3BucketAssociations[bucketName][distributionId].includes(principal) && + !allowedOrigins.includes(principal)) { + allowedOrigins.push(principal); + } + } + } + + var missingOrigins = s3BucketAssociations[bucketName][distributionId].filter(function(item) { + return !allowedOrigins.includes(item) && !restrictedOrigins.includes(item); + }); + + restrictedOrigins = restrictedOrigins.concat(missingOrigins); + + if (unknownPrincipals.length || restrictedOrigins.length) { + if (unknownPrincipals.length) { + helpers.addResult(results, 2, + `S3 bucket is origin to distribution "${distributionId}" and allows access to these unknown sources: ${unknownPrincipals.join(', ')}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + } + if (restrictedOrigins.length) { + helpers.addResult(results, 2, + `S3 bucket is origin to distribution "${distributionId}" and does not allow access to these CloudFront OAIs: ${restrictedOrigins.join(', ')}`, + bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + } + } else { + helpers.addResult(results, 0, + `S3 bucket is origin to only one CloudFront distribution which is: ${distributionId}`, bucketLocation, `arn:${awsOrGov}:s3:::${bucketName}`); + } + + cb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/s3/bucketPolicyCloudFrontOai.spec.js b/plugins/aws/s3/bucketPolicyCloudFrontOai.spec.js new file mode 100644 index 000000000..50765f38e --- /dev/null +++ b/plugins/aws/s3/bucketPolicyCloudFrontOai.spec.js @@ -0,0 +1,330 @@ +var expect = require('chai').expect; +const bucketPolicyCloudFrontOai = require('./bucketPolicyCloudFrontOai'); + +const listBuckets = [ + { + "Name": "my.bucket.khulnasoft", + "CreationDate": "2021-01-10T13:45:10.000Z" + }, + { + "Name": "cdn-data", + "CreationDate": "2020-11-30T10:43:10.000Z" + }, + { + "Name": "cdn-oai", + "CreationDate": "2020-11-30T10:43:10.000Z" + } +]; + +const getBucketPolicy = [ + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1GFXAXB2CIBLG\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::my.bucket.akhtar/*\"}]}" + }, + { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"PolicyForCloudFrontPrivateContent\",\"Statement\":[{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::000011112222:root\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::my.bucket.akhtar/*\"}]}" + }, + { + "Policy": null + } +]; + +const listDistributions = [ + { + "Id": "E1MAXX9WRS9EF1", + "DomainName": "dyfdsiscpe8ax.cloudfront.net", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "ELB-alb-1-384199277", + "DomainName": "alb-1-384199277.us-east-1.elb.amazonaws.com" + } + ] + }, + }, + { + "Id": "E16OS977N7ZR5X", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-my.bucket.khulnasoft/cdn-1", + "DomainName": "my.bucket.khulnasoft.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1GFXAXB2CIBLG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E16OS977N77TVS", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-cdn-data/cdn-1", + "DomainName": "cdn-data.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1GFXAXB2CIBLG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E16OS978FTE8VS", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-cdn-data/cdn-1", + "DomainName": "cdn-data.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "origin-access-identity/cloudfront/E1GFXAXB2CIBLG" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + }, + { + "Id": "E16OS978FTE8VS", + "DomainName": "d1pu9fj4y91pww.cloudfront.net", + "Aliases": { + "Quantity": 0 + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "S3-cdn-data/cdn-1", + "DomainName": "cdn-data.s3.amazonaws.com", + "OriginPath": "/cdn-1", + "CustomHeaders": { + "Quantity": 0 + }, + "S3OriginConfig": { + "OriginAccessIdentity": "" + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10 + } + ] + } + } +]; + +const createCache = (listDistributions, listBuckets, getBucketPolicy, policyErr) => { + var bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: listBuckets + }, + }, + getBucketPolicy: { + 'us-east-1': { + [bucketName]: { + err: policyErr, + data: getBucketPolicy + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + cloudfront: { + listDistributions: { + 'us-east-1': { + data: listDistributions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing S3 buckets' + }, + }, + }, + getBucketPolicy: { + 'us-east-1': { + err: { + message: 'error while getting bucket logging' + }, + }, + }, + }, + cloudfront: { + listDistributions: { + 'us-east-1': { + err: { + message: 'Unable to list distributions' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + cloudfront: { + listDistributions: { + 'us-east-1': null + } + } + }; +}; + +describe('bucketPolicyCloudFrontOai', function () { + describe('run', function () { + it('should PASS if no S3 origins found for CloudFront distributions', function (done) { + const cache = createCache([listDistributions[0]]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if S3 bucket is origin to only one CloudFront distribution', function (done) { + const cache = createCache([listDistributions[1]], [listBuckets[0]], getBucketPolicy[0]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to more than one distributions', function (done) { + const cache = createCache([listDistributions[2], listDistributions[3]], [listBuckets[1]]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to distribution without an origin access identity', function (done) { + const cache = createCache([listDistributions[4]]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to dstribution and allows access to unknown sources', function (done) { + const cache = createCache([listDistributions[1]], [listBuckets[0]], getBucketPolicy[1]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket is origin to dstribution and does not allow access to these CloudFront OAIs', function (done) { + const cache = createCache([listDistributions[1]], [listBuckets[0]], getBucketPolicy[1]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if no bucket policy found for S3 bucket', function (done) { + const cache = createCache([listDistributions[1]], [listBuckets[0]], getBucketPolicy[1], { code: 'NoSuchBucketPolicy' }); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if error while querying S3 bucket', function (done) { + const cache = createCache([listDistributions[1]], [listBuckets[0]], getBucketPolicy[2]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 origins to check', function (done) { + const cache = createCache([]); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list CloudFront distributions', function (done) { + const cache = createErrorCache(); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if list distributions result not found', function (done) { + const cache = createNullCache(); + bucketPolicyCloudFrontOai.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketPublicAccessBlock.js b/plugins/aws/s3/bucketPublicAccessBlock.js index 1435ee48b..1446d3b07 100644 --- a/plugins/aws/s3/bucketPublicAccessBlock.js +++ b/plugins/aws/s3/bucketPublicAccessBlock.js @@ -3,37 +3,50 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'S3 Bucket Public Access Block', category: 'S3', - description: 'Ensures S3 public access block is enabled on all buckets', - more_info: 'Blocking S3 public access at the bucket-level ensures objects are not accidentally exposed.', - recommended_action: 'Enable the S3 public access block on all S3 buckets.', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures S3 public access block is enabled on all buckets or for AWS account', + more_info: 'Blocking S3 public access at the account level or bucket-level ensures objects are not accidentally exposed.', + recommended_action: 'Enable the S3 public access block on all S3 buckets or for AWS account.', link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html', - apis: ['S3:listBuckets', 'S3:getPublicAccessBlock'], + apis: ['S3:listBuckets', 'S3:getPublicAccessBlock', 'S3:getBucketLocation', 'S3Control:getPublicAccessBlock', 'STS:getCallerIdentity'], settings: { s3_public_access_block_allow_pattern: { name: 'S3 Public Access Block Allow Pattern', description: 'When set, whitelists buckets matching the given pattern. Useful for overriding buckets outside the account control.', regex: '^.{1,255}$', default: false + }, + check_global_block: { + name: 'Check Global Block', + description: 'When set, check account level public access for S3 and override bucket level public access check.', + regex: '^(true|false)$', + default: 'false' } }, + realtime_triggers: ['s3:CreateBucket', 's3:PutPublicAccessBlock','s3:DeleteBucket'], run: function(cache, settings, callback) { var config = { - s3_public_access_block_allow_pattern: settings.s3_public_access_block_allow_pattern || this.settings.s3_public_access_block_allow_pattern.default + s3_public_access_block_allow_pattern: settings.s3_public_access_block_allow_pattern || this.settings.s3_public_access_block_allow_pattern.default, + check_global_block: settings.check_global_block || this.settings.check_global_block.default }; + config.check_global_block = (config.check_global_block == 'true'); + var custom = helpers.isCustom(settings, this.settings); var results = []; var source = {}; var region = helpers.defaultRegion(settings); - + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', region, 'data']); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); if (!listBuckets) return callback(null, results, source); if (listBuckets.err || !listBuckets.data) { - helpers.addResult(results, 3, 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + helpers.addResult(results, 3, `Unable to query for S3 buckets: ${helpers.addError(listBuckets)}`); return callback(null, results, source); } @@ -42,33 +55,59 @@ module.exports = { return callback(null, results, source); } + var globalMissingBlocks = null; + + if (config.check_global_block) { + var accountPublicAccessBlock = helpers.addSource(cache, source, ['s3control', 'getPublicAccessBlock', region, accountId]); + + if (!accountPublicAccessBlock || + accountPublicAccessBlock.err || + !accountPublicAccessBlock.data || + !accountPublicAccessBlock.data.PublicAccessBlockConfiguration) { + helpers.addResult(results, 3, + `Unable to query public access block setting for AWS account: ${helpers.addError(accountPublicAccessBlock)}`); + return callback(null, results, source); + } + var configAccount = accountPublicAccessBlock.data.PublicAccessBlockConfiguration; + globalMissingBlocks = Object.keys(configAccount).filter(k => !configAccount[k]); + } + var allowRegex = (config.s3_public_access_block_allow_pattern && config.s3_public_access_block_allow_pattern.length) ? new RegExp(config.s3_public_access_block_allow_pattern) : false; for (let { Name: bucket } of listBuckets.data) { + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket); + + if (config.check_global_block) { + if (!globalMissingBlocks.length) { + helpers.addResult(results, 0, 'AWS account has public access block fully enabled', bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`); + continue; + } + } + var getPublicAccessBlock = helpers.addSource(cache, source, ['s3', 'getPublicAccessBlock', region, bucket]); if (!getPublicAccessBlock) continue; if (allowRegex && allowRegex.test(bucket)) { helpers.addResult(results, 0, 'Bucket: ' + bucket + ' is whitelisted via custom setting.', - 'global', 'arn:aws:s3:::' + bucket, custom); + bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`, custom); } else { if (getPublicAccessBlock.err && getPublicAccessBlock.err.code === 'NoSuchPublicAccessBlockConfiguration') { - helpers.addResult(results, 2, 'Public Access Block not enabled', 'global', 'arn:aws:s3:::' + bucket); + helpers.addResult(results, 2, 'S3 bucket does not have Public Access Block enabled', bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`); continue; } if (getPublicAccessBlock.err || !getPublicAccessBlock.data) { - helpers.addResult(results, 3, `Error: ${helpers.addError(getPublicAccessBlock)}`, 'global', 'arn:aws:s3:::' + bucket); + helpers.addResult(results, 3, `Error: ${helpers.addError(getPublicAccessBlock)}`, bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`); continue; } var configLocal = getPublicAccessBlock.data.PublicAccessBlockConfiguration; var missingBlocks = Object.keys(configLocal).filter(k => !configLocal[k]); if (missingBlocks.length) { - helpers.addResult(results, 2, `Missing public access blocks: ${missingBlocks.join(', ')}`, 'global', 'arn:aws:s3:::' + bucket); + helpers.addResult(results, 2, `S3 bucket is missing public access blocks: ${missingBlocks.join(', ')}`, bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`); continue; } - helpers.addResult(results, 0, 'Public access block fully enabled', 'global', 'arn:aws:s3:::' + bucket); + helpers.addResult(results, 0, 'S3 bucket has public access block fully enabled', bucketLocation, `arn:${awsOrGov}:s3:::${bucket}`); } } diff --git a/plugins/aws/s3/bucketPublicAccessBlock.spec.js b/plugins/aws/s3/bucketPublicAccessBlock.spec.js index ef3b01c17..80ad8dde1 100644 --- a/plugins/aws/s3/bucketPublicAccessBlock.spec.js +++ b/plugins/aws/s3/bucketPublicAccessBlock.spec.js @@ -25,6 +25,15 @@ const createCache = (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, Restr }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, }; }; @@ -48,6 +57,15 @@ const createCacheNoPublicAccessBlock = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, }; }; @@ -130,18 +148,19 @@ const createCacheErrorGetPublicAccessBlock = () => { }; }; -describe('bucketPublicAccessBlock', function () { - describe('run', function () { - it('should PASS if public access block is fully configured', function (done) { +describe('bucketPublicAccessBlock', function() { + describe('run', function() { + it('should PASS if public access block is fully configured', function(done) { const cache = createCache(true, true, true, true); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should PASS if no buckets in account', function (done) { + it('should PASS if no buckets in account', function(done) { const cache = createCacheEmptyListBucket(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -150,7 +169,7 @@ describe('bucketPublicAccessBlock', function () { }); }); - it('should do nothing if null listBuckets', function (done) { + it('should do nothing if null listBuckets', function(done) { const cache = createCacheNullListBuckets(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(0); @@ -158,7 +177,7 @@ describe('bucketPublicAccessBlock', function () { }); }); - it('should UNKNOWN if error listing buckets', function (done) { + it('should UNKNOWN if error listing buckets', function(done) { const cache = createCacheErrorListBucket(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -167,45 +186,49 @@ describe('bucketPublicAccessBlock', function () { }); }); - it('should FAIL if public access block is partially configured', function (done) { + it('should FAIL if public access block is partially configured', function(done) { const cache = createCache(true, true, false, false); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should FAIL if public access block not found', function (done) { + it('should FAIL if public access block not found', function(done) { const cache = createCacheNoPublicAccessBlock(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should FAIL if public access block not found', function (done) { + it('should FAIL if public access block not found', function(done) { const cache = createCacheNoPublicAccessBlock(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should PASS if public access block not found but whitelisted', function (done) { + it('should PASS if public access block not found but whitelisted', function(done) { const cache = createCacheNoPublicAccessBlock(); bucketPublicAccessBlock.run(cache, { s3_public_access_block_allow_pattern: 'mybucket' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should do nothing if null getPublicAccessBlock', function (done) { + it('should do nothing if null getPublicAccessBlock', function(done) { const cache = createCacheNullGetPublicAccessBlock(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(0); @@ -213,7 +236,7 @@ describe('bucketPublicAccessBlock', function () { }); }); - it('should UNKNOWN if error getting public access block', function (done) { + it('should UNKNOWN if error getting public access block', function(done) { const cache = createCacheErrorGetPublicAccessBlock(); bucketPublicAccessBlock.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -222,4 +245,4 @@ describe('bucketPublicAccessBlock', function () { }); }); }); -}); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketSecureTransportEnabled.js b/plugins/aws/s3/bucketSecureTransportEnabled.js new file mode 100644 index 000000000..2330cbec2 --- /dev/null +++ b/plugins/aws/s3/bucketSecureTransportEnabled.js @@ -0,0 +1,113 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Secure Transport Enabled', + category: 'S3', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure AWS S3 buckets enforce SSL to secure data in transit', + more_info: 'S3 buckets should be configured to strictly require SSL connections ' + + 'to deny unencrypted HTTP requests when dealing with sensitive data.', + recommended_action: 'Update S3 bucket policy to enforse SSL to secure data in transit.', + link: 'https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/', + apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:DeleteBucketPolicy','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + `Unable to query for S3 buckets: ${helpers.addError(listBuckets)}`); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets found'); + return callback(null, results, source); + } + + listBuckets.data.forEach(bucket => { + if (!bucket.Name) return; + + var resource = `arn:${awsOrGov}:s3:::` + bucket.Name; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + var getBucketPolicy = helpers.addSource(cache, source, + ['s3', 'getBucketPolicy', region, bucket.Name]); + + // Check the bucket policy + if (getBucketPolicy && getBucketPolicy.err && + getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { + helpers.addResult(results, 2, 'No bucket policy found', bucketLocation, resource); + } else if (!getBucketPolicy || getBucketPolicy.err || + !getBucketPolicy.data || !getBucketPolicy.data.Policy) { + helpers.addResult(results, 3, + `Error querying for bucket policy for bucket "${bucket.Name}" ${helpers.addError(getBucketPolicy)}`, + bucketLocation, resource); + } else { + var statements = helpers.normalizePolicyDocument(getBucketPolicy.data.Policy); + + if (!statements || !statements.length) { + helpers.addResult(results, 2, + 'Bucket policy does not contain any statements', + bucketLocation, resource); + return; + } + + statements.forEach(statement => { + if (!statement.Sid || !statement.Sid.length) statement.Sid = Math.random().toString(36).substring(2,7); + }); + + var denyPermissionsMap = helpers.getDenyPermissionsMap(statements); + var sslEnforced = true; + + for (var statement of statements) { + if (statement.Effect && statement.Effect === 'Allow' && statement.Principal && !statement.Principal.Service) { + if (!helpers.isEffectiveStatement(statement, denyPermissionsMap)) continue; + + if (!statement.Condition || + !statement.Condition.Bool || + !statement.Condition.Bool['aws:SecureTransport'] || + statement.Condition.Bool['aws:SecureTransport'] === 'false') { + sslEnforced = false; + break; + } + } else if (statement.Effect && statement.Effect === 'Deny' && statement.Principal && !statement.Principal.Service && statement.Sid) { + var denyActionResourceMap = helpers.getDenyPermissionsMap(statements, statement.Sid); + if (!helpers.isEffectiveStatement(statement, denyActionResourceMap)) continue; + + if (!statement.Condition || + !statement.Condition.Bool || + !statement.Condition.Bool['aws:SecureTransport'] || + statement.Condition.Bool['aws:SecureTransport'] === 'true') { + sslEnforced = false; + break; + } + } + } + + if (sslEnforced) { + helpers.addResult(results, 0, + `Bucket Policy for bucket "${bucket.Name}" enforces SSL to secure data in transit`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `Bucket Policy for bucket "${bucket.Name}" does not enforce SSL to secure data in transit`, + bucketLocation, resource); + } + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/s3/bucketSecureTransportEnabled.spec.js b/plugins/aws/s3/bucketSecureTransportEnabled.spec.js new file mode 100644 index 000000000..dbbf055e9 --- /dev/null +++ b/plugins/aws/s3/bucketSecureTransportEnabled.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +const bucketSecureTransportEnabled = require('./bucketSecureTransportEnabled'); + +const listBuckets = [ + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + }, + { + Name: 'elasticbeanstalk-us-east-1-123456654321', + CreationDate: '2020-08-20T17:42:52.000Z' + }, + { + Name: 'test-bucket-sploit-100', + CreationDate: '2020-09-06T09:44:16.000Z' + } +]; + +const getBucketPolicy = [ + { + Policy: '{"Version":"2012-10-17","Id":"ExamplePolicy","Statement":[{"Sid":"","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00000011111:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::staging-01-sd-logs/*"]},{"Sid":"","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::staging-01-sd-logs/*","arn:aws:s3:::staging-01-sd-logs"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}' + }, + { + Policy: '{"Version":"2008-10-17","Statement":[{"Sid":"Stmt1537431944913","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00001111122:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::cloudexploit-test-secure-transport/*"]},{"Sid":"Stmt1537431944211","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::cloudexploit-test-secure-transport/*","arn:aws:s3:::cloudexploit-test-secure-transport"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}' + }, + { + Policy: '{"Version":"2012-10-17","Id":"ExamplePolicy","Statement":[]}' + }, + { + Policy: '{"Version":"2012-10-17","Id":"ExamplePolicy","Statement":[{"Sid":"","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00000011111:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::staging-01-sd-logs/*"]},{"Sid":"","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::staging-01-sd-logs/*","arn:aws:s3:::staging-01-sd-logs"],"Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}' + }, +]; + + +const createCache = (listBuckets, getBucketPolicy) => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: listBuckets + }, + }, + getBucketPolicy: { + 'us-east-1': { + [listBuckets[0].Name]: { + data: getBucketPolicy + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [listBuckets[0].Name]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing buckets' + }, + }, + }, + getBucketPolicy: { + 'us-east-1': { + err: { + message: 'error while getting bucket policy' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketSecureTransportEnabled', function () { + describe('run', function () { + it('should PASS if S3 bucket enforces SSL to secure data in transit', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[0]); + bucketSecureTransportEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('enforces SSL to secure data in transit'); + done(); + }); + }); + + it('should FAIL if S3 bucket policy does not include any statement', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[2]); + bucketSecureTransportEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Bucket policy does not contain any statements'); + + done(); + }); + }); + + it('should FAIL if S3 bucket does not enforce SSL to secure data in transit', function (done) { + const cache = createCache([listBuckets[0]], getBucketPolicy[3]); + bucketSecureTransportEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('does not enforce SSL to secure data in transit'); + + done(); + }); + }); + + it('should UNKNOWN if error while listing S3 buckets', function (done) { + const cache = createErrorCache(); + bucketSecureTransportEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for S3 buckets'); + done(); + }); + }); + + it('should not return any result if S3 list bucket response is not found', function (done) { + const cache = createNullCache(); + bucketSecureTransportEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketTransferAcceleration.js b/plugins/aws/s3/bucketTransferAcceleration.js new file mode 100644 index 000000000..7be03e606 --- /dev/null +++ b/plugins/aws/s3/bucketTransferAcceleration.js @@ -0,0 +1,65 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Transfer Acceleration Enabled', + category: 'S3', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that S3 buckets have transfer acceleration enabled to increase the speed of data transfers.', + more_info: 'S3 buckets should have transfer acceleration enabled to increase the speed of data transfers in and out of Amazon S3 using AWS edge network.', + recommended_action: 'Modify S3 bucket to enable transfer acceleration.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html', + apis: ['S3:listBuckets', 'S3:getBucketAccelerateConfiguration', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketAccelerateConfiguration','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + `Unable to query for S3 buckets: ${helpers.addError(listBuckets)}`); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets found'); + return callback(null, results, source); + } + + listBuckets.data.forEach(function(bucket){ + var resource = `arn:${awsOrGov}:s3:::${bucket.Name}`; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + var getBucketAccelerateConfiguration = helpers.addSource(cache, source, + ['s3', 'getBucketAccelerateConfiguration', region, bucket.Name]); + + if (!getBucketAccelerateConfiguration || getBucketAccelerateConfiguration.err || !getBucketAccelerateConfiguration.data) { + helpers.addResult(results, 3, + `Unable to get bucket acceleration configuration: ${helpers.addError(getBucketAccelerateConfiguration)}`, + bucketLocation, resource); + return; + } + + if (getBucketAccelerateConfiguration.data.Status && getBucketAccelerateConfiguration.data.Status.toUpperCase() === 'ENABLED') { + helpers.addResult(results, 0, + `S3 bucket ${bucket.Name} has transfer acceleration enabled`, + bucketLocation, resource); + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} does not have transfer acceleration enabled`, + bucketLocation, resource); + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/s3/bucketTransferAcceleration.spec.js b/plugins/aws/s3/bucketTransferAcceleration.spec.js new file mode 100644 index 000000000..edad8b739 --- /dev/null +++ b/plugins/aws/s3/bucketTransferAcceleration.spec.js @@ -0,0 +1,129 @@ +const expect = require('chai').expect; +var bucketTransferAcceleration = require('./bucketTransferAcceleration'); + +const listBuckets = [ + { + "Name": "s3buckettest", + "CreationDate": "2021-01-09T02:56:31+00:00" + }, +]; + +const getBucketAccelerateConfiguration = [ + { + Status: 'Enabled' + }, + { + Status: 'Suspended' + } +] + +const createCache = (listBuckets, getBucketAccelerateConfiguration, listBucketsErr, getBucketAccelerateConfigurationErr) => { + var bucketName = (listBuckets && listBuckets.length) ? listBuckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + err: listBucketsErr, + data: listBuckets + } + }, + getBucketAccelerateConfiguration: { + 'us-east-1': { + [bucketName]: { + err: getBucketAccelerateConfigurationErr, + data: getBucketAccelerateConfiguration + } + } + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null + } + } + }; +}; + +describe('bucketTransferAcceleration', function () { + describe('run', function () { + it('should PASS if S3 bucket has transfer acceleration enabled', function (done) { + const cache = createCache([{Name: 'bucket-1'}], { Status: 'Enabled' }, null, null); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket does not have transfer acceleration enabled', function (done) { + const cache = createCache([{Name: 'bucket-1'}], {}, null, null); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has transfer acceleration suspended', function (done) { + const cache = createCache([{Name: 'bucket-1'}], { Status: 'Suspended' }, null); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 buckets found', function (done) { + const cache = createCache([]); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createCache(null, null, { message: "Unable to list buckets" }, null); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get bucket acceleration configuration', function (done) { + const cache = createCache([{Name: 'bucket-1'}], null, null, { message: "Unable to get bucket acceleration configuration"}); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list S3 buckets response not found', function (done) { + const cache = createNullCache(); + bucketTransferAcceleration.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketVersioning.js b/plugins/aws/s3/bucketVersioning.js index 698281f91..75e604b1b 100644 --- a/plugins/aws/s3/bucketVersioning.js +++ b/plugins/aws/s3/bucketVersioning.js @@ -3,6 +3,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'S3 Bucket Versioning', category: 'S3', + domain: 'Storage', + severity: 'Low', description: 'Ensures object versioning is enabled on S3 buckets', more_info: 'Object versioning can help protect against the overwriting of \ objects or data loss in the event of a compromise.', @@ -10,13 +12,54 @@ module.exports = { sensitive contents at a minimum and for all buckets \ ideally.', link: 'http://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html', - apis: ['S3:listBuckets', 'S3:getBucketVersioning'], - + apis: ['S3:listBuckets', 'S3:getBucketVersioning', 'S3:getBucketLocation'], + remediation_description: 'The impacted bucket will be configured to be have Versioning enabled.', + remediation_min_version: '202010211553', + apis_remediate: ['S3:listBuckets', 'S3:getBucketVersioning', 'S3:getBucketLocation'], + actions: { + remediate: ['S3:putBucketVersioning'], + rollback: ['S3:putBucketVersioning'], + }, + permissions: { + remediate: ['s3:PutBucketVersioning'], + rollback: ['s3:PutBucketVersioning'] + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketVersioning','s3:DeleteBucket'], + asl: { + conditions: [ + { + service: 's3', + api: 'getBucketVersioning', + property: 'Status', + transform: 'STRING', + op: 'EXISTS', + }, + { + service: 's3', + api: 'getBucketVersioning', + property: 'Status', + transform: 'STRING', + op: 'EQ', + value: 'Enabled', + logical: 'AND' + }, + { + service: 's3', + api: 'getBucketVersioning', + property: 'Status', + transform: 'STRING', + op: 'MATCHES', + value: '^[A-Z]{1}[a-z]+$', + logical: 'AND' + } + ] + }, run: function(cache, settings, callback) { var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -35,6 +78,8 @@ module.exports = { } listBuckets.data.forEach(function(bucket){ + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + var getBucketVersioning = helpers.addSource(cache, source, ['s3', 'getBucketVersioning', region, bucket.Name]); @@ -42,18 +87,88 @@ module.exports = { helpers.addResult(results, 3, 'Error querying bucket versioning for : ' + bucket.Name + ': ' + helpers.addError(getBucketVersioning), - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else if (getBucketVersioning.data.Status == 'Enabled') { helpers.addResult(results, 0, 'Bucket : ' + bucket.Name + ' has versioning enabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else { helpers.addResult(results, 2, 'Bucket : ' + bucket.Name + ' has versioning disabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } }); callback(null, results, source); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'bucketVersioning'; + var bucketNameArr = resource.split(':'); + var bucketName = bucketNameArr[bucketNameArr.length - 1]; + + // find the location of the bucket needing to be remediated + var bucketLocations = cache['s3']['getBucketLocation']; + var bucketLocation; + var err; + + if (!bucketLocations || bucketLocations.err || !Object.keys(bucketLocations).length){ + err = bucketLocations.err || 'Unable to get bucket location'; + return callback(err, null); + } + + for (var key in bucketLocations) { + if (bucketLocations[key][bucketName]) { + bucketLocation = key; + break; + } + } + if (!bucketLocation) { + err = 'Unable to get bucket location'; + return callback(err, null); + } + + // add the location of the bucket to the config + config.region = bucketLocation; + var params = {}; + // create the params necessary for the remediation + params = { + 'Bucket': bucketName, + 'VersioningConfiguration': { + Status: 'Enabled' + } + }; + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'VersioningConfiguration': 'Suspended', + 'Bucket': bucketName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'VersioningConfiguration': 'Enabled', + 'Bucket': bucketName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + + rollback: function(config, cache, settings, resource, callback) { + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/s3/bucketVersioning.spec.js b/plugins/aws/s3/bucketVersioning.spec.js new file mode 100644 index 000000000..f829f2978 --- /dev/null +++ b/plugins/aws/s3/bucketVersioning.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +const bucketVersioning = require('./bucketVersioning'); + +const listBuckets = [ + { + Name: 'elasticbeanstalk-us-east-1-111122223333', + CreationDate: '2020-08-20T17:42:52.000Z' + }, + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + }, + { + Name: 'test-bucket-sploit-100', + CreationDate: '2020-09-06T09:44:16.000Z' + } +]; + +const getBucketVersioning = [ + { + Status: 'Enabled' + }, + {} +]; + +const createCache = (buckets, versioning) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + [bucketName]: { + data: versioning + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing s3 buckets' + }, + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createBucketVersioningErrorCache = (buckets) => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketVersioning: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('bucketVersioning', function () { + describe('run', function () { + it('should PASS if S3 bucket has object versioning enabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0]); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has object versioning disabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[1]); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 bucket found', function (done) { + const cache = createCache([]); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list s3 buckets', function (done) { + const cache = createErrorCache(); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get bucket versioning', function (done) { + const cache = createBucketVersioningErrorCache([listBuckets[0]]); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if s3 list buckets response is not found', function (done) { + const cache = createNullCache(); + bucketVersioning.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/bucketWebsiteEnabled.js b/plugins/aws/s3/bucketWebsiteEnabled.js index 93c54750f..c2d50efc9 100644 --- a/plugins/aws/s3/bucketWebsiteEnabled.js +++ b/plugins/aws/s3/bucketWebsiteEnabled.js @@ -3,17 +3,33 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'S3 Bucket Website Enabled', category: 'S3', + domain: 'Storage', + severity: 'High', description: 'Ensures S3 buckets are not configured with static website hosting', more_info: 'S3 buckets should not be configured with static website hosting with public objects. Instead, a CloudFront distribution should be configured with an origin access identity.', recommended_action: 'Disable S3 bucket static website hosting in favor or CloudFront distributions.', link: 'https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-https-requests-s3/', - apis: ['S3:listBuckets', 'S3:getBucketWebsite'], + apis: ['S3:listBuckets', 'S3:getBucketWebsite', 'S3:getBucketLocation', 'S3:listObjects'], + settings: { + s3_website_whitelist_empty_buckets: { + name: 'S3 Website Whitelist Empty Buckets', + description: 'When set to true, whitelist empty buckets without checking bucket website configutation', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketWebsite', 's3:DeleteBucketWebsite','s3:DeleteBucket'], run: function(cache, settings, callback) { var results = []; var source = {}; var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var config = { + s3_website_whitelist_empty_buckets: settings.s3_website_whitelist_empty_buckets || this.settings.s3_website_whitelist_empty_buckets.default, + }; + var ignoreEmptyBuckets = (config.s3_website_whitelist_empty_buckets == 'true'); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -32,6 +48,25 @@ module.exports = { } listBuckets.data.forEach(function(bucket){ + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + if (ignoreEmptyBuckets){ + var listObjects = helpers.addSource(cache, source, + ['s3', 'listObjects', region, bucket.Name]); + + if (!listObjects || listObjects.err || !listObjects.data) { + helpers.addResult(results, 3, + 'Unable to list S3 bucket objects: ' + helpers.addError(listObjects), bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + return; + } + + if (!listObjects.data.Contents || !listObjects.data.Contents.length){ + helpers.addResult(results, 0, + 'Bucket : ' + bucket.Name + ' is empty', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + return; + } + } + var getBucketWebsite = helpers.addSource(cache, source, ['s3', 'getBucketWebsite', region, bucket.Name]); @@ -39,20 +74,20 @@ module.exports = { getBucketWebsite.err.code && getBucketWebsite.err.code == 'NoSuchWebsiteConfiguration') { helpers.addResult(results, 0, 'Bucket : ' + bucket.Name + ' does not have static website hosting enabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else if (!getBucketWebsite || getBucketWebsite.err || !getBucketWebsite.data) { helpers.addResult(results, 3, 'Error querying bucket website for : ' + bucket.Name + ': ' + helpers.addError(getBucketWebsite), - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else if (Object.keys(getBucketWebsite.data).length) { helpers.addResult(results, 2, 'Bucket : ' + bucket.Name + ' has static website hosting enabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } else { helpers.addResult(results, 0, 'Bucket : ' + bucket.Name + ' does not have static website hosting enabled', - 'global', 'arn:aws:s3:::' + bucket.Name); + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); } }); callback(null, results, source); diff --git a/plugins/aws/s3/bucketWebsiteEnabled.spec.js b/plugins/aws/s3/bucketWebsiteEnabled.spec.js index 1bc326713..c751ceb04 100644 --- a/plugins/aws/s3/bucketWebsiteEnabled.spec.js +++ b/plugins/aws/s3/bucketWebsiteEnabled.spec.js @@ -2,9 +2,9 @@ var assert = require('assert'); var expect = require('chai').expect; var s3 = require('./bucketWebsiteEnabled'); -const createCache = (bucketErr, website) => { +const createCache = (bucketErr, website, emptyBucket) => { var bucketObj = {}; - + var bucketContents = []; if (bucketErr) { bucketObj = { "err": { @@ -17,6 +17,15 @@ const createCache = (bucketErr, website) => { }; } + if (!emptyBucket) { + bucketContents.push({ + ETag: "\"70ee1738b6b21e2c8a43f3a5ab0eee71\"", + Key: "example1.jpg", + Size: 11, + StorageClass: "STANDARD" + }) + } + return { "s3": { "listBuckets": { @@ -32,6 +41,22 @@ const createCache = (bucketErr, website) => { "us-east-1": { "bucket1": bucketObj } + }, + "getBucketLocation": { + 'us-east-1': { + "bucket1": { + "data": { + "LocationConstraint": 'us-east-1' + } + } + } + }, + "listObjects": { + "us-east-1": { + "bucket1": { + "data": bucketContents + } + } } } }; @@ -44,6 +69,7 @@ describe('bucketWebsiteEnabled', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('does not have static website hosting enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -57,6 +83,7 @@ describe('bucketWebsiteEnabled', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) expect(results[0].message).to.include('does not have static website hosting enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -70,6 +97,7 @@ describe('bucketWebsiteEnabled', function () { expect(results.length).to.equal(1) expect(results[0].status).to.equal(2) expect(results[0].message).to.include('has static website hosting enabled') + expect(results[0].region).to.equal('us-east-1'); done() }; @@ -79,5 +107,19 @@ describe('bucketWebsiteEnabled', function () { s3.run(cache, {}, callback); }) + + it('should give passing result if S3 bucket is empty and whitelist s3 bucket setting is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('is empty') + expect(results[0].region).to.equal('us-east-1'); + done() + }; + + const cache = createCache(false, null, false); + + s3.run(cache, { s3_website_whitelist_empty_buckets: 'true' }, callback); + }) }) }); \ No newline at end of file diff --git a/plugins/aws/s3/objectLevelReadEventLogging.js b/plugins/aws/s3/objectLevelReadEventLogging.js new file mode 100644 index 000000000..4f3c12fea --- /dev/null +++ b/plugins/aws/s3/objectLevelReadEventLogging.js @@ -0,0 +1,130 @@ +var helpers = require('../../../helpers/aws'); +var async = require('async'); +module.exports = { + title: 'S3 Object Read Logging', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that Object-level logging for read events is enabled for S3 bucket.', + more_info: 'Enabling Object-level S3 event logging significantly enhances security, especially for sensitive data.', + recommended_action: 'Enable object level logging for read events for each S3 bucket.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-cloudtrail-logging-for-s3.html#enable-cloudtrail-events', + apis: ['S3:listBuckets', 'CloudTrail:describeTrails', 'CloudTrail:getEventSelectors', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 'cloudtrail:CreateTrail', 'cloudtrail:PutEventSelectors', 'cloudtrail:PutInsightSelectors','s3:DeleteBucket', 'cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets Founds'); + return callback(null, results, source); + } + var isall = false; + var buckets = []; + var startsWithBuckets = []; + var endsWithBuckets = []; + var notStartsWithBuckets = []; + var notEndsWithBuckets = []; + + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + 'Unable to query for cloudtrails: ' + helpers.addError(listBuckets)); + return rcb(); + } + + describeTrails.data.forEach(event => { + var describeEventsSelectors = helpers.addSource(cache, source, + ['cloudtrail', 'getEventSelectors', region, event.TrailARN]); + + if (!describeEventsSelectors || describeEventsSelectors.err || !describeEventsSelectors.data ) { + return; + } + + if (describeEventsSelectors.data.EventSelectors) { + const basicEventSelectors = describeEventsSelectors.data.EventSelectors; + + for (const event of basicEventSelectors) { + + const dataResources = event.DataResources || []; + + for (const dataResource of dataResources) { + + if (dataResource.Type === 'AWS::S3::Object') { + if (event.ReadWriteType === 'All' || event.ReadWriteType === 'ReadOnly') { + if (dataResource.Values.includes(`arn:${awsOrGov}:s3`)) { + isall = true; + } else { + buckets = dataResource.Values.map((value) => value.split(':::')[1]); + buckets = buckets.map((name) => name.slice(0, -1)); + } + } + } + } + } + } else if (describeEventsSelectors.data.AdvancedEventSelectors) { + var eventSelectors = describeEventsSelectors.data.AdvancedEventSelectors; + + for (const selector of eventSelectors) { + + const fieldSelectors = selector.FieldSelectors || []; + const dataEventCategoryField = fieldSelectors.find((f) => f.Field === 'eventCategory' && f.Equals.includes('Data')); + const s3ObjectField = fieldSelectors.find((f) => f.Field === 'resources.type' && f.Equals.includes('AWS::S3::Object')); + const readOnlyField = fieldSelectors.find((f) => f.Field === 'readOnly' && f.Equals.includes('true')); + const writeOnlyField = fieldSelectors.find((f) => f.Field === 'readOnly' && f.Equals.includes('false')); + const resourcesARNField = fieldSelectors.find((f) => f.Field === 'resources.ARN'); + + if (dataEventCategoryField && s3ObjectField) { + if ((readOnlyField || !writeOnlyField )&& !resourcesARNField) { + isall = true; + } else if (readOnlyField) { + helpers.processFieldSelectors(fieldSelectors, buckets ,startsWithBuckets,notEndsWithBuckets,endsWithBuckets, notStartsWithBuckets); + } + } + } + } + }); + rcb(); + + },function() { + listBuckets.data.forEach(function(bucket){ + var bucketLocation = helpers.getS3BucketLocation(cache, defaultRegion, bucket.Name); + const conditions = helpers.checkConditions(startsWithBuckets, notStartsWithBuckets, endsWithBuckets, notEndsWithBuckets, bucket.Name); + + if (isall || conditions.startsWithCondition || conditions.notStartsWithCondition || conditions.endsWithCondition || conditions.notEndsWithCondition){ + helpers.addResult(results, 0, 'Bucket has object-level logging for read events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (buckets.length) { + if (buckets.includes(bucket.Name)) { + helpers.addResult(results, 0, 'Bucket has object-level logging for read events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else { + helpers.addResult(results, 2, 'Bucket does not has object-level logging for read events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + } else if (!isall) { + helpers.addResult(results, 2, 'Bucket does not has object-level logging for read events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + }); + + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/s3/objectLevelReadEventLogging.spec.js b/plugins/aws/s3/objectLevelReadEventLogging.spec.js new file mode 100644 index 000000000..6e2ec81fb --- /dev/null +++ b/plugins/aws/s3/objectLevelReadEventLogging.spec.js @@ -0,0 +1,231 @@ +var expect = require('chai').expect; +const objectLevelReadLogging = require('./objectLevelReadEventLogging'); + +const listBuckets = [ + { + Name: 'elasticbeanstalk-us-east-1-111122223333', + CreationDate: '2020-08-20T17:42:52.000Z' + } +]; + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "elasticbeanstalk-us-east-1-111122223333", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "'elasticbeanstalk-us-east-1-111122223333", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getEventSelectors = [ + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "EventSelectors": [ + { + "ReadWriteType": "ReadOnly", + "IncludeManagementEvents": true, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "EventSelectors": [ + { + "ReadWriteType": "ReadOnly", + "IncludeManagementEvents": false, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3:::test-bucket-130/" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + + "TrailARN": "arn:aws:cloudtrail:us-east-1:672202477801:trail/trail-1", + "AdvancedEventSelectors": [ + { + "FieldSelectors": [ + { + "Field": "eventCategory", + "Equals": [ + "Data" + ] + }, + { + "Field": "resources.type", + "Equals": [ + "AWS::S3::Object" + ] + } + ] + }, + { + "Name": "Management events selector", + "FieldSelectors": [ + { + "Field": "eventCategory", + "Equals": [ + "Management" + ] + } + ] + } + ] + } +]; + +const createCache = (buckets, describeTrails, getEventSelectors) => { + var trailARN = (describeTrails && describeTrails.length) ? describeTrails[0].TrailARN : null; + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + }, + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: describeTrails + }, + }, + getEventSelectors: { + 'us-east-1': { + [trailARN]: { + data: getEventSelectors + } + } + } + }, + + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + } + }, + cloudtrail: { + describeTrails: { + 'us-east-1': null + } + } + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing S3 buckets' + }, + }, + } + }, + }; +}; + +describe('objectLevelReadLogging', function () { + describe('run', function () { + it('should PASS no S3 bucket found', function (done) { + const cache = createCache([],trails[0],getEventSelectors[0]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No S3 buckets Founds'); + done(); + }); + }); + + it('should FAIL if S3 bucket doesnot have object-level logging for read events', function (done) { + const cache = createCache(listBuckets,[trails[0]],getEventSelectors[1]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not has object-level logging for read events'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if object-level logging is enable for read events', function (done) { + const cache = createCache(listBuckets,[trails[0]],getEventSelectors[0]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has object-level logging for read events'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createErrorCache(); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if S3 list buckets response is not found', function (done) { + const cache = createNullCache(); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/objectLevelWriteEventLogging.js b/plugins/aws/s3/objectLevelWriteEventLogging.js new file mode 100644 index 000000000..21753f6a4 --- /dev/null +++ b/plugins/aws/s3/objectLevelWriteEventLogging.js @@ -0,0 +1,131 @@ +var helpers = require('../../../helpers/aws'); +var async = require('async'); +module.exports = { + title: 'S3 Object Write Logging', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that Object-level logging for write events is enabled for S3 bucket.', + more_info: 'Enabling Object-level S3 event logging significantly enhances security, especially for sensitive data.', + recommended_action: 'Enable object level logging for Write events for each S3 bucket.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-cloudtrail-logging-for-s3.html#enable-cloudtrail-events.', + apis: ['S3:listBuckets', 'CloudTrail:describeTrails', 'CloudTrail:getEventSelectors', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 'cloudtrail:CreateTrail', 'cloudtrail:PutEventSelectors', 'cloudtrail:PutInsightSelectors','s3:DeleteBucket', 'cloudtrail:DeleteTrail'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets Founds'); + return callback(null, results, source); + } + + var isall = false; + var buckets=[]; + var startsWithBuckets = []; + var endsWithBuckets = []; + var notStartsWithBuckets = []; + var notEndsWithBuckets = []; + + async.each(regions.cloudtrail, function(region, rcb){ + var describeTrails = helpers.addSource(cache, source, + ['cloudtrail', 'describeTrails', region]); + + if (!describeTrails) return rcb(); + + if (describeTrails.err || !describeTrails.data) { + helpers.addResult(results, 3, + 'Unable to query for cloudtrails: ' + helpers.addError(listBuckets)); + return rcb(); + } + + describeTrails.data.forEach(event => { + var describeEventsSelectors = helpers.addSource(cache, source, + ['cloudtrail', 'getEventSelectors', region, event.TrailARN]); + + if (!describeEventsSelectors || describeEventsSelectors.err || !describeEventsSelectors.data ) { + return; + } + + if (describeEventsSelectors.data.EventSelectors) { + const basicEventSelectors = describeEventsSelectors.data.EventSelectors; + + for (const event of basicEventSelectors) { + const dataResources = event.DataResources || []; + + for (const dataResource of dataResources) { + + if (dataResource.Type === 'AWS::S3::Object') { + if (event.ReadWriteType === 'All' || event.ReadWriteType === 'WriteOnly') { + if (dataResource.Values.includes(`arn:${awsOrGov}:s3`)) { + isall = true; + } else { + buckets = dataResource.Values.map((value) => value.split(':::')[1]); + buckets = buckets.map((name) => name.slice(0, -1)); + } + } + } + } + } + } else if (describeEventsSelectors.data.AdvancedEventSelectors) { + var eventSelectors = describeEventsSelectors.data.AdvancedEventSelectors; + + for (const selector of eventSelectors) { + + const fieldSelectors = selector.FieldSelectors || []; + const dataEventCategoryField = fieldSelectors.find((f) => f.Field === 'eventCategory' && f.Equals.includes('Data')); + const s3ObjectField = fieldSelectors.find((f) => f.Field === 'resources.type' && f.Equals.includes('AWS::S3::Object')); + const readOnlyField = fieldSelectors.find((f) => f.Field === 'readOnly' && f.Equals.includes('true')); + const writeOnlyField = fieldSelectors.find((f) => f.Field === 'readOnly' && f.Equals.includes('false')); + const resourcesARNField = fieldSelectors.find((f) => f.Field === 'resources.ARN'); + + if (dataEventCategoryField && s3ObjectField) { + if ((writeOnlyField || !readOnlyField )&& !resourcesARNField) { + isall = true; + } else if (writeOnlyField ) { + helpers.processFieldSelectors(fieldSelectors, buckets ,startsWithBuckets,notEndsWithBuckets,endsWithBuckets, notStartsWithBuckets); + } + } + } + } + }); + rcb(); + + },function(){ + listBuckets.data.forEach(function(bucket){ + + var bucketLocation = helpers.getS3BucketLocation(cache, defaultRegion, bucket.Name); + const conditions = helpers.checkConditions(startsWithBuckets, notStartsWithBuckets, endsWithBuckets, notEndsWithBuckets, bucket.Name); + + if (isall || conditions.startsWithCondition || conditions.notStartsWithCondition || conditions.endsWithCondition || conditions.notEndsWithCondition){ + helpers.addResult(results, 0, 'Bucket has object-level logging for write events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (buckets.length) { + if (buckets.includes(bucket.Name)){ + helpers.addResult(results, 0, 'Bucket has object-level logging for write events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else { + helpers.addResult(results, 2, 'Bucket does not has object-level logging for write events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + } else if (!isall) { + helpers.addResult(results, 2, 'Bucket does not has object-level logging for write events', bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + }); + + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/s3/objectLevelWriteEventLogging.spec.js b/plugins/aws/s3/objectLevelWriteEventLogging.spec.js new file mode 100644 index 000000000..1de66b4dd --- /dev/null +++ b/plugins/aws/s3/objectLevelWriteEventLogging.spec.js @@ -0,0 +1,231 @@ +var expect = require('chai').expect; +const objectLevelReadLogging = require('./objectLevelWriteEventLogging'); + +const listBuckets = [ + { + Name: 'elasticbeanstalk-us-east-1-111122223333', + CreationDate: '2020-08-20T17:42:52.000Z' + } +]; + +const trails = [ + { + "Name": "trail-1", + "S3BucketName": "elasticbeanstalk-us-east-1-111122223333", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": true, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "LogFileValidationEnabled": true, + "KmsKeyId": "a14dea26-1459-4f62-ab85-d5a54293a495", + "HasCustomEventSelectors": true, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + }, + { + "Name": "trail-2", + "S3BucketName": "'elasticbeanstalk-us-east-1-111122223333", + "IncludeGlobalServiceEvents": true, + "IsMultiRegionTrail": false, + "HomeRegion": "us-east-1", + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-2", + "LogFileValidationEnabled": false, + "HasCustomEventSelectors": false, + "HasInsightSelectors": false, + "IsOrganizationTrail": false + } +]; + +const getEventSelectors = [ + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:123456654321:trail/trail-1", + "EventSelectors": [ + { + "ReadWriteType": "WriteOnly", + "IncludeManagementEvents": true, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + "TrailARN": "arn:aws:cloudtrail:us-east-1:112233445566:trail/test-trail-1", + "EventSelectors": [ + { + "ReadWriteType": "ReadOnly", + "IncludeManagementEvents": false, + "DataResources": [ + { + "Type": "AWS::S3::Object", + "Values": [ + "arn:aws:s3:::test-bucket-130/" + ] + } + ], + "ExcludeManagementEventSources": [] + } + ] + }, + { + + "TrailARN": "arn:aws:cloudtrail:us-east-1:672202477801:trail/trail-1", + "AdvancedEventSelectors": [ + { + "FieldSelectors": [ + { + "Field": "eventCategory", + "Equals": [ + "Data" + ] + }, + { + "Field": "resources.type", + "Equals": [ + "AWS::S3::Object" + ] + } + ] + }, + { + "Name": "Management events selector", + "FieldSelectors": [ + { + "Field": "eventCategory", + "Equals": [ + "Management" + ] + } + ] + } + ] + } +]; + +const createCache = (buckets, describeTrails, getEventSelectors) => { + var trailARN = (describeTrails && describeTrails.length) ? describeTrails[0].TrailARN : null; + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + }, + cloudtrail: { + describeTrails: { + 'us-east-1': { + data: describeTrails + }, + }, + getEventSelectors: { + 'us-east-1': { + [trailARN]: { + data: getEventSelectors + } + } + } + }, + + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + } + }, + cloudtrail: { + describeTrails: { + 'us-east-1': null + } + } + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing S3 buckets' + }, + }, + } + }, + }; +}; + +describe('objectLevelReadLogging', function () { + describe('run', function () { + it('should PASS no S3 bucket found', function (done) { + const cache = createCache([],trails[0],getEventSelectors[0]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No S3 buckets Founds'); + done(); + }); + }); + + it('should FAIL if S3 bucket doesnot have object-level logging for read events', function (done) { + const cache = createCache(listBuckets,[trails[0]],getEventSelectors[1]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not has object-level logging for write events'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if object-level logging is enable for read events', function (done) { + const cache = createCache(listBuckets,[trails[0]],getEventSelectors[0]); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has object-level logging for write events'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list S3 buckets', function (done) { + const cache = createErrorCache(); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if S3 list buckets response is not found', function (done) { + const cache = createNullCache(); + objectLevelReadLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/s3BucketHasTags.js b/plugins/aws/s3/s3BucketHasTags.js new file mode 100644 index 000000000..155497837 --- /dev/null +++ b/plugins/aws/s3/s3BucketHasTags.js @@ -0,0 +1,74 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Bucket Has Tags', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that AWS S3 Bucket have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify S3 buckets and add tags.', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/CostAllocTagging.html', + apis: ['S3:listBuckets', 'S3:getBucketTagging', 'S3:getBucketLocation'], + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketTagging','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var defaultRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); + if (!listBuckets) return callback(null, results, source); + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets found'); + return callback(null, results, source); + } + + listBuckets.data.forEach(function(bucket) { + if (!bucket.Name) return; + + var bucketLocation = helpers.getS3BucketLocation(cache, defaultRegion, bucket.Name); + var bucketArn = `arn:${awsOrGov}:s3:::${bucket.Name}`; + + // Try the bucket's actual region first, then fall back to default region + var getBucketTagging = helpers.addSource(cache, source, + ['s3', 'getBucketTagging', bucketLocation, bucket.Name]); + + // If not found in bucket's region, try default region (where collector runs) + if (!getBucketTagging) { + getBucketTagging = helpers.addSource(cache, source, + ['s3', 'getBucketTagging', defaultRegion, bucket.Name]); + } + + + if (!getBucketTagging || getBucketTagging.err) { + if (getBucketTagging && getBucketTagging.err && + (getBucketTagging.err.code === 'NoSuchTagSet' || + getBucketTagging.err.message && getBucketTagging.err.message.includes('does not exist'))) { + // No tags exist for this bucket + helpers.addResult(results, 2, 'S3 bucket does not have any tags', bucketLocation, bucketArn); + } else { + helpers.addResult(results, 3, + 'Unable to query S3 bucket tags: ' + helpers.addError(getBucketTagging), + bucketLocation, bucketArn); + } + return; + } + + if (getBucketTagging.data && getBucketTagging.data.TagSet && getBucketTagging.data.TagSet.length > 0) { + helpers.addResult(results, 0, 'S3 bucket has tags', bucketLocation, bucketArn); + } else { + helpers.addResult(results, 2, 'S3 bucket does not have any tags', bucketLocation, bucketArn); + } + }); + callback(null, results, source); + } +}; diff --git a/plugins/aws/s3/s3BucketHasTags.spec.js b/plugins/aws/s3/s3BucketHasTags.spec.js new file mode 100644 index 000000000..5eefe1109 --- /dev/null +++ b/plugins/aws/s3/s3BucketHasTags.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var s3BucketHasTags = require('./s3BucketHasTags'); + +const createCache = (bucketData, bucketDataErr, bucketTaggingData, bucketTaggingErr) => { + var bucketName = (bucketData && bucketData.length) ? bucketData[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + err: bucketDataErr, + data: bucketData + } + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + getBucketTagging: { + 'us-east-1': { + [bucketName]: { + err: bucketTaggingErr, + data: bucketTaggingData + } + } + } + } + } +}; + + +describe('s3BucketHasTags', function () { + describe('run', function () { + it('should give unknown result if unable to list the s3 buckets', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for S3 buckets') + done(); + }; + + const cache = createCache( null,{message: 'unable to query for s3 buckets'}); + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give passing result if no s3 bucket found.', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No S3 buckets found'); + done(); + }; + const cache = createCache([], null); + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query bucket tagging', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query S3 bucket tags:'); + done(); + }; + // Create cache with error in both potential lookup locations (bucket region and default region) + const cache = { + s3: { + listBuckets: { + 'us-east-1': { + err: null, + data: [{ + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }] + } + }, + getBucketLocation: { + 'us-east-1': { + 'test-bucket': { + data: { + LocationConstraint: null // us-east-1 + } + } + } + }, + getBucketTagging: { + 'us-east-1': { + 'test-bucket': { + err: { + message: "Unable to query bucket tags" + }, + data: null + } + } + } + } + }; + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give passing result if s3 bucket has tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('S3 bucket has tags'); + done(); + }; + + const cache = createCache( + [{ + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }], null, + { + "TagSet": [ + {"Key": "key1", "Value": "value1"}, + {"Key": "key2", "Value": "value2"} + ] + }, null + ); + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give failing result if s3 does not have tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('S3 bucket does not have any tags'); + done(); + }; + + const cache = createCache( + [{ + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }], null, + null, { + code: "NoSuchTagSet", + message: "The TagSet does not exist" + } + ); + + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give failing result if s3 has empty tag set', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('S3 bucket does not have any tags'); + done(); + }; + + const cache = createCache( + [{ + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }], null, + { + "TagSet": [] + }, null + ); + + s3BucketHasTags.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3/s3Encryption.js b/plugins/aws/s3/s3Encryption.js index d2acdf6e4..35ebea1df 100644 --- a/plugins/aws/s3/s3Encryption.js +++ b/plugins/aws/s3/s3Encryption.js @@ -17,12 +17,12 @@ function statementTargetsAction(statement, targetAction) { } /** - * Return the encryption level for the satement + * Return the encryption level for the statement * If multiple conditions in StringNotEquals, return the least-restrictive condition found first (sse) */ function getEncryptionLevel(statement) { if (statement) { - if (statement.Effect === 'Deny' && statement.Principal === '*') { + if (statement.Effect === 'Deny' && helpers.globalPrincipal(statement.Principal)) { if (statementTargetsAction(statement, 's3:PutObject')) { if (statement.Condition && statement.Condition.StringNotEquals) { if (statement.Condition.StringNotEquals['s3:x-amz-server-side-encryption'] === 'AES256') { @@ -50,36 +50,43 @@ function getKeyEncryptionLevel(kmsKey) { module.exports = { title: 'S3 Bucket Encryption Enforcement', category: 'S3', + domain: 'Storage', + severity: 'High', description: 'All statements in all S3 bucket policies must have a condition that requires encryption at a certain level', more_info: 'S3 buckets support numerous types of encryption, including AES-256, KMS using a default key, KMS with a CMK, or via HSM-based key.', recommended_action: 'Configure a bucket policy to enforce encryption.', link: 'https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/', - apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'KMS:listKeys', 'KMS:describeKey'], + apis: ['S3:listBuckets', 'S3:getBucketPolicy', 'S3:getBucketWebsite', 'KMS:listKeys', 'KMS:describeKey', 'S3:getBucketLocation'], settings: { s3_required_encryption_level: { name: 'S3 Minimum Default Encryption Level', - description: 'In order (lowest to highest) \ - sse=Server-Side Encryption; \ - awskms=AWS-managed KMS; \ - awscmk=Customer managed KMS; \ - externalcmk=Customer managed externally sourced KMS; \ - cloudhsm=Customer managed CloudHSM sourced KMS', + description: 'In order (low to high) sse=Server-Side Encryption; awskms=AWS KMS; awscmk=Customer KMS; externalcmk=Customer external KMS; cloudhsm=Customer CloudHSM', regex: '^(sse|awskms|awscmk|externalcmk|cloudhsm)$', - default: 'sse', + default: 'awskms', + }, + s3_allow_unencrypted_static_websites: { + name: 'S3 Allow Unencrypted Static Websites', + description: 'Allow buckets having static website enabled to skip encryption', + regex: '^(true|false)$', + default: 'false', } }, + realtime_triggers: ['s3:CreateBucket', 's3:putBucketEncryption','s3:DeleteBucket'], run: function(cache, settings, callback) { var results = []; var source = {}; var desiredEncryptionLevelString = settings.s3_required_encryption_level || this.settings.s3_required_encryption_level.default; - if(!desiredEncryptionLevelString.match(this.settings.s3_required_encryption_level.regex)) { + var s3_allow_unencrypted_static_websites = settings.s3_allow_unencrypted_static_websites || this.settings.s3_allow_unencrypted_static_websites.default; + if (!desiredEncryptionLevelString.match(this.settings.s3_required_encryption_level.regex)) { helpers.addResult(results, 3, 'Settings misconfigured for S3 Encryption Enforcement.'); return callback(null, results, source); } + var allowSkipEncryption = (s3_allow_unencrypted_static_websites == 'true'); var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var listBuckets = helpers.addSource(cache, source, ['s3', 'listBuckets', region]); @@ -95,15 +102,30 @@ module.exports = { } for (let bucket of listBuckets.data) { - var bucketResource = `arn:aws:s3:::${bucket.Name}`; + var bucketResource = `arn:${awsOrGov}:s3:::${bucket.Name}`; + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + if (allowSkipEncryption) { + var getBucketWebsite = helpers.addSource(cache, source, ['s3', 'getBucketWebsite', region, bucket.Name]); + if (getBucketWebsite && getBucketWebsite.err && getBucketWebsite.err.code && getBucketWebsite.err.code === 'NoSuchWebsiteConfiguration') { + // do nothing + } else if (!getBucketWebsite || getBucketWebsite.err || !getBucketWebsite.data) { + helpers.addResult(results, 3, `Error querying for bucket website: ${bucket.Name}: ${helpers.addError(getBucketWebsite)}`, 'global', bucketResource); + continue; + } else { + helpers.addResult(results, 0, + 'Bucket has static website hosting enabled', 'global', bucketResource); + continue; + } + } var getBucketPolicy = helpers.addSource(cache, source, ['s3', 'getBucketPolicy', region, bucket.Name]); if (getBucketPolicy && getBucketPolicy.err && getBucketPolicy.err.code && getBucketPolicy.err.code === 'NoSuchBucketPolicy') { - helpers.addResult(results, 2, 'No bucket policy found; encryption not enforced', 'global', bucketResource); + helpers.addResult(results, 2, 'No bucket policy found; encryption not enforced', bucketLocation, bucketResource); continue; } if (!getBucketPolicy || getBucketPolicy.err || !getBucketPolicy.data || !getBucketPolicy.data.Policy) { - helpers.addResult(results, 3, `Error querying for bucket policy on bucket: ${bucket.Name}: ${helpers.addError(getBucketPolicy)}`, 'global', bucketResource); + helpers.addResult(results, 3, `Error querying for bucket policy on bucket: ${bucket.Name}: ${helpers.addError(getBucketPolicy)}`, bucketLocation, bucketResource); continue; } @@ -115,16 +137,16 @@ module.exports = { } else { policyJson = getBucketPolicy.data.Policy; } - } catch(e) { - helpers.addResult(results, 3, `Bucket policy on bucket [${bucket.Name}] could not be parsed.`, 'global', bucketResource); + } catch (e) { + helpers.addResult(results, 3, `Bucket policy on bucket [${bucket.Name}] could not be parsed.`, bucketLocation, bucketResource); continue; } if (!policyJson || !policyJson.Statement) { - helpers.addResult(results, 3, `Error querying for bucket policy for bucket: ${bucket.Name}: Policy JSON is invalid or does not contain valid statements.`, 'global', bucketResource); + helpers.addResult(results, 3, `Error querying for bucket policy for bucket: ${bucket.Name}: Policy JSON is invalid or does not contain valid statements.`, bucketLocation, bucketResource); continue; } if (!policyJson.Statement.length) { - helpers.addResult(results, 2, 'Bucket policy does not contain any statements; encryption not enforced', 'global', bucketResource); + helpers.addResult(results, 2, 'Bucket policy does not contain any statements; encryption not enforced', bucketLocation, bucketResource); continue; } @@ -133,7 +155,7 @@ module.exports = { if (encryptionLevel.level) return encryptionLevel.level; if (encryptionLevel.key) { const keyId = encryptionLevel.key.split('/')[1]; - const describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, keyId]); + const describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', bucketLocation, keyId]); if (!describeKey || describeKey.err || !describeKey.data) { helpers.addResult(results, 3, `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, region, keyId); return 0; @@ -147,9 +169,9 @@ module.exports = { const currentEncryptionLevel = statementEncryptionLevels.reduce((max, level) => encryptionLevelMap[level] > encryptionLevelMap[max] ? level : max, 'none'); if (encryptionLevelMap[currentEncryptionLevel] < encryptionLevelMap[desiredEncryptionLevelString]) { - helpers.addResult(results, 2, `Bucket policy does not enforce encryption to ${desiredEncryptionLevelString}, policy currently enforces: ${currentEncryptionLevel}`, 'global', bucketResource); + helpers.addResult(results, 2, `Bucket policy does not enforce encryption to ${desiredEncryptionLevelString}, policy currently enforces: ${currentEncryptionLevel}`, bucketLocation, bucketResource); } else { - helpers.addResult(results, 0, `Bucket policy enforces encryption to ${desiredEncryptionLevelString}, policy currently enforces: ${currentEncryptionLevel}`, 'global', bucketResource); + helpers.addResult(results, 0, `Bucket policy enforces encryption to ${desiredEncryptionLevelString}, policy currently enforces: ${currentEncryptionLevel}`, bucketLocation, bucketResource); } } callback(null, results, source); diff --git a/plugins/aws/s3/s3Encryption.spec.js b/plugins/aws/s3/s3Encryption.spec.js index c0e8f1d7c..d9b2aa3e3 100644 --- a/plugins/aws/s3/s3Encryption.spec.js +++ b/plugins/aws/s3/s3Encryption.spec.js @@ -28,6 +28,15 @@ const createCacheNoEncryption = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, }; }; @@ -64,6 +73,15 @@ const createCacheSSE = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, }; }; @@ -100,6 +118,15 @@ const createCacheAWSKMS = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, kms: { describeKey: { @@ -150,6 +177,15 @@ const createCacheAWSCMK = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, kms: { describeKey: { @@ -200,6 +236,15 @@ const createCacheExternalCMK = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, kms: { describeKey: { @@ -250,6 +295,15 @@ const createCacheHSM = () => { }, }, }, + getBucketLocation: { + 'us-east-1': { + mybucket: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + } }, kms: { describeKey: { @@ -267,6 +321,32 @@ const createCacheHSM = () => { }; }; +const createCacheStaticWebsite = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: [{ + Name: 'mybucket', + }], + }, + }, + getBucketWebsite: { + 'us-east-1': { + mybucket: { + data: { + "IndexDocument": { + "Suffix": "index.html" + } + }, + }, + }, + }, + }, + + }; +}; + const createCacheNoBuckets = () => { return { s3: { @@ -295,6 +375,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'awskms' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -302,6 +383,16 @@ describe('s3Encryption', function () { it('should PASS BucketPolicy=SSE, Configured=SSE', function (done) { const cache = createCacheSSE(); s3Encryption.run(cache, { s3_required_encryption_level: 'sse' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if s3_unencrypted_static_websites if enabled and bucket has static website hosting enabled', function (done) { + const cache = createCacheStaticWebsite(); + s3Encryption.run(cache, { s3_allow_unencrypted_static_websites: 'true' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); done(); @@ -313,6 +404,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'awskms' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -322,6 +414,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -331,6 +424,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'externalcmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -340,6 +434,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'cloudhsm' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); @@ -349,6 +444,7 @@ describe('s3Encryption', function () { s3Encryption.run(cache, { s3_required_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); diff --git a/plugins/aws/s3/versionedBucketsLC.js b/plugins/aws/s3/versionedBucketsLC.js new file mode 100644 index 000000000..4d139484a --- /dev/null +++ b/plugins/aws/s3/versionedBucketsLC.js @@ -0,0 +1,99 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Versioned Buckets Lifecycle Configuration', + category: 'S3', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that S3 buckets having versioning enabled also have lifecycle policy configured for non-current objects.', + more_info: 'When object versioning is enabled on a bucket, every modification/update to an object results in a new version of the object that will be stored indefinitely. ' + + 'Enable a lifecycle policy, so that non-current object versions are removed or transitioned in a predictable manner.', + recommended_action: 'Configure lifecycle rules for buckets which have versioning enabled', + link: 'https://docs.aws.amazon.com/AmazonS3/latest/dev/how-to-set-lifecycle-configuration-intro.html', + apis: ['S3:listBuckets', 'S3:getBucketVersioning', 'S3:getBucketLocation', 'S3:getBucketLifecycleConfiguration'], + realtime_triggers: ['s3:CreateBucket', 's3:PutBucketVersioning', 's3:putBucketLifecycleConfiguration','s3:DeleteBucket'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', region]); + + if (!listBuckets) return callback(null, results, source); + + if (listBuckets.err || !listBuckets.data) { + helpers.addResult(results, 3, + 'Unable to query for S3 buckets: ' + helpers.addError(listBuckets)); + return callback(null, results, source); + } + + if (!listBuckets.data.length) { + helpers.addResult(results, 0, 'No S3 buckets to check'); + return callback(null, results, source); + } + + listBuckets.data.forEach(function(bucket){ + var bucketLocation = helpers.getS3BucketLocation(cache, region, bucket.Name); + + var getBucketVersioning = helpers.addSource(cache, source, + ['s3', 'getBucketVersioning', region, bucket.Name]); + + if (!getBucketVersioning || getBucketVersioning.err || !getBucketVersioning.data) { + helpers.addResult(results, 3, + 'Error querying bucket versioning for : ' + bucket.Name + + ': ' + helpers.addError(getBucketVersioning), + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (getBucketVersioning.data.Status == 'Enabled') { + var getBucketLifecycleConfiguration = helpers.addSource(cache, source, + ['s3', 'getBucketLifecycleConfiguration', region, bucket.Name]); + + if (getBucketLifecycleConfiguration && getBucketLifecycleConfiguration.err && + getBucketLifecycleConfiguration.err.code && getBucketLifecycleConfiguration.err.code == 'NoSuchLifecycleConfiguration') { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} has versioning enabled but has lifecycle configuration disabled`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (!getBucketLifecycleConfiguration || getBucketLifecycleConfiguration.err || !getBucketLifecycleConfiguration.data) { + helpers.addResult(results, 3, + `Unable to query for S3 bucket lifecycle configuration: ${helpers.addError(getBucketLifecycleConfiguration)}`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else if (getBucketLifecycleConfiguration.data.Rules && + getBucketLifecycleConfiguration.data.Rules.length) { + var ruleExists = getBucketLifecycleConfiguration.data.Rules.find(rule => rule.Status && rule.Status.toUpperCase() === 'ENABLED'); + + if (ruleExists) { + var ruleForNonCurrent = getBucketLifecycleConfiguration.data.Rules.find(rule => rule.Status && + rule.Status.toUpperCase() === 'ENABLED' && + Object.keys(rule).some(key => (key == 'NoncurrentVersionTransitions' && rule[key].length) || + key == 'NoncurrentVersionExpiration' && Object.keys(rule[key]).length)); + if (ruleForNonCurrent) { + helpers.addResult(results, 0, + `S3 bucket ${bucket.Name} has versioning and lifecycle configuration enabled for non-current versions`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} has versioning and lifecycle configuration enabled but lifecycle policy includes no rule for non-current objects`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} has versioning and lifecycle configuration configured but lifecycle policy does not have enabled rules`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + } else { + helpers.addResult(results, 2, + `S3 bucket ${bucket.Name} has versioning enabled but has lifecycle configuration disabled`, + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + } else { + helpers.addResult(results, 0, + 'Bucket : ' + bucket.Name + ' has versioning disabled', + bucketLocation, `arn:${awsOrGov}:s3:::` + bucket.Name); + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/s3/versionedBucketsLC.spec.js b/plugins/aws/s3/versionedBucketsLC.spec.js new file mode 100644 index 000000000..7a6aff2b2 --- /dev/null +++ b/plugins/aws/s3/versionedBucketsLC.spec.js @@ -0,0 +1,249 @@ +var expect = require('chai').expect; +const versionedBucketsLC = require('./versionedBucketsLC'); + +const listBuckets = [ + { + Name: 'test-bucket-130', + CreationDate: '2020-09-10T09:11:40.000Z' + } +]; + +const getBucketVersioning = [ + { + Status: 'Enabled' + }, + {} +]; + +const getBucketLifecycleConfiguration = [ + { + "Rules": [ + { + "ID": "akd-36", + "Filter": { + "Prefix": "t-" + }, + "Status": "Enabled", + "NoncurrentVersionTransitions": [ + { + "Days": 900, + "StorageClass": "STANDARD_IA" + } + ] + } + ] + }, + { + "Rules": [ + { + "ID": "akd-36", + "Filter": { + "Prefix": "t-" + }, + "Status": "Enabled", + "Transitions": [ + { + "Days": 900, + "StorageClass": "STANDARD_IA" + } + ] + } + ] + }, + { + "Rules": [ + { + "ID": "akd-36", + "Filter": { + "Prefix": "t-" + }, + "Status": "Disabled", + "NoncurrentVersionTransitions": [ + { + "Days": 900, + "StorageClass": "STANDARD_IA" + } + ] + } + ] + } +] + +const createCache = (buckets, versioning, lifecycle, lifecycleErr) => { + var bucketName = (buckets && buckets.length) ? buckets[0].Name : null; + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + [bucketName]: { + data: versioning, + }, + }, + }, + getBucketLocation: { + 'us-east-1': { + [bucketName]: { + data: { + LocationConstraint: 'us-east-1' + } + } + } + }, + getBucketLifecycleConfiguration: { + 'us-east-1': { + [bucketName]: { + data: lifecycle, + err: lifecycleErr + } + } + }, + }, + }; +}; + +const createErrorCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': { + err: { + message: 'error while listing s3 buckets' + }, + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createBucketVersioningErrorCache = (buckets) => { + return { + s3: { + listBuckets: { + 'us-east-1': { + data: buckets + }, + }, + getBucketVersioning: { + 'us-east-1': { + err: { + message: 'error while getting bucket versioning' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + s3: { + listBuckets: { + 'us-east-1': null, + }, + getBucketVersioning: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('versionedBucketsLC', function () { + describe('run', function () { + it('should PASS if S3 bucket has versioning and lifecycle configuration enabled for non-current versions', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0], getBucketLifecycleConfiguration[0]); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if S3 bucket has versioning disabled', function (done) { + const cache = createCache([listBuckets[0]], {}); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has versioning enabled but has lifecycle configuration disabled', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0], { code: 'NoSuchLifecycleConfiguration' }); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has versioning and lifecycle configuration configured but lifecyle policy does not have enabled rules', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0], getBucketLifecycleConfiguration[2]); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 bucket has versioning and lifecycle configuration enabled but lifecycle policy includes no rule for non-current objects', function (done) { + const cache = createCache([listBuckets[0]], getBucketVersioning[0], getBucketLifecycleConfiguration[1]); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 bucket found', function (done) { + const cache = createCache([]); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list s3 buckets', function (done) { + const cache = createErrorCache(); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get bucket versioning', function (done) { + const cache = createBucketVersioningErrorCache([listBuckets[0]]); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if s3 list buckets response is not found', function (done) { + const cache = createNullCache(); + versionedBucketsLC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/s3glacier/vaultPublicAccess.js b/plugins/aws/s3glacier/vaultPublicAccess.js new file mode 100644 index 000000000..6a46dad44 --- /dev/null +++ b/plugins/aws/s3glacier/vaultPublicAccess.js @@ -0,0 +1,123 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'S3 Glacier Vault Public Access', + category: 'Glacier', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that S3 Glacier Vault public access block is enabled for the account.', + more_info: 'Blocking S3 Glacier Vault public access at the account level ensures objects are not accidentally exposed.', + recommended_action: 'Add access policy for the S3 Glacier Vault to block public access for the AWS account.', + link: 'https://docs.aws.amazon.com/amazonglacier/latest/dev/access-control-overview.html', + apis: ['Glacier:listVaults', 'Glacier:getVaultAccessPolicy', 'STS:getCallerIdentity'], + settings: { + glacier_vault_policy_condition_keys: { + name: 'S3 Glacier Vault Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount, aws:SourceArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceOwner,aws:SourceArn,aws:SourceAccount' + } + }, + realtime_triggers: ['glacier:CreateVault', 'glacier:SetVaultAccessPolicy', 'glacier:DeleteVault'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var config = { + glacier_vault_policy_condition_keys: settings.glacier_vault_policy_condition_keys || this.settings.glacier_vault_policy_condition_keys.default + }; + config.glacier_vault_policy_condition_keys = config.glacier_vault_policy_condition_keys.replace(/\s/g, ''); + var allowedConditionKeys = config.glacier_vault_policy_condition_keys.split(','); + + + regions.glacier.forEach(region => { + const listVaults = helpers.addSource(cache, source, + ['glacier', 'listVaults', region]); + + if (!listVaults) return; + + if (listVaults.err || !listVaults.data) { + helpers.addResult(results, 3, `Unable to list S3 Glacier vaults: ${helpers.addError(listVaults)}`, region); + return; + } + + if (!listVaults.data.length) { + helpers.addResult(results, 0, 'No S3 Glacier vaults found', region); + return; + } + + for (let vault of listVaults.data) { + if (!vault.VaultName) continue; + + let resource = vault.VaultARN; + + const getVaultAccessPolicy = helpers.addSource(cache, source, + ['glacier', 'getVaultAccessPolicy', region, vault.VaultName]); + + if (getVaultAccessPolicy && getVaultAccessPolicy.err && getVaultAccessPolicy.err.code && + getVaultAccessPolicy.err.code == 'ResourceNotFoundException') { + helpers.addResult(results, 0, + 'S3 Glacier vault does not have any policy attached', region, resource); + continue; + } + + if (!getVaultAccessPolicy || getVaultAccessPolicy.err || !getVaultAccessPolicy.data) { + helpers.addResult(results, 3, `Unable to get vault policy: ${helpers.addError(getVaultAccessPolicy)}`, region, resource); + continue; + } + + var statements = (getVaultAccessPolicy.data.policy && getVaultAccessPolicy.data.policy.Policy) ? + helpers.normalizePolicyDocument(getVaultAccessPolicy.data.policy.Policy) : []; + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'S3 Glacier vault policy does not contain any statements', + region, resource); + continue; + } + + let actions = []; + for (let statement of statements) { + var effectEval = (statement.Effect && statement.Effect == 'Allow' ? true : false); + + // Evaluates whether the principal is open to everyone/anonymous + var principalEval = helpers.globalPrincipal(statement.Principal, settings); + + // Evaluates whether condition is scoped or global + let scopedCondition; + if (statement.Condition) scopedCondition = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, false, accountId, settings); + + if (!scopedCondition && principalEval && effectEval) { + if (statement.Action && typeof statement.Action === 'string') { + if (actions.indexOf(statement.Action) === -1) { + actions.push(statement.Action); + } + } else if (statement.Action && statement.Action.length) { + for (var a in statement.Action) { + if (actions.indexOf(statement.Action[a]) === -1) { + actions.push(statement.Action[a]); + } + } + } + } + } + + if (actions.length) { + helpers.addResult(results, 2, + 'S3 Glacoer vault policy allows global access to the action(s): ' + actions, + region, resource); + } else { + helpers.addResult(results, 0, + 'S3 Glacoer vault policy does not allow global access', + region, resource); + } + } + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/s3glacier/vaultPublicAccess.spec.js b/plugins/aws/s3glacier/vaultPublicAccess.spec.js new file mode 100644 index 000000000..005e9f92b --- /dev/null +++ b/plugins/aws/s3glacier/vaultPublicAccess.spec.js @@ -0,0 +1,180 @@ +var expect = require('chai').expect; +const vaultPublicAccess = require('./vaultPublicAccess'); + +const vaultPolicy = [ + { + policy: { + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":\"111111111111\",\"Action\":\"glacier:*\",\"Resource\":\"arn:aws:glacier:us-east-1:111111111111:vaults/vault-access\"}]}", + }, + }, + { + policy: { + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"glacier:*\",\"Resource\":\"arn:aws:glacier:us-east-1:111111111111:vaults/vault-access\"}]}", + }, + }, + { + policy: { + Policy: "{\"Version\":\"2012-10-17\",\"Statement\":[]}", + }, + }, + { + policy: { + Policy: '{"Version":"2012-10-17"}' + }, + }, +]; + +const listVaults = { + VaultARN: "arn:aws:glacier:us-east-1:111111111111:vaults/vault-access", + VaultName: "vault-access", + CreationDate: "2021-09-08T18:07:43.593Z", + LastInventoryDate: null, + NumberOfArchives: 0, + SizeInBytes: 0, +} + +const createCache = (vault, policy) => { + var vaultName = (vault && vault.length && vault[0].VaultName) ? vault[0].VaultName : null; + return { + glacier: { + listVaults: { + 'us-east-1': { + data: vault + }, + }, + getVaultAccessPolicy: { + 'us-east-1': { + [vaultName]: { + data: policy + }, + }, + }, + } + } +}; + +const createErrorCache = () => { + return { + glacier: { + listVaults: { + 'us-east-1': { + err: { + message: 'error while listing vaults' + }, + }, + }, + getVaultAccessPolicy: { + 'us-east-1': { + ['name']: { + err: { + message: 'error while getting vault policy' + }, + }, + }, + }, + }, + }; +}; + +const createPolicyErrorCache = (vault) => { + var vaultName = vault[0].VaultName; + return { + glacier: { + listVaults: { + 'us-east-1': { + data: vault + } + }, + getVaultAccessPolicy: { + 'us-east-1': { + [vaultName]: { + err: { + message: 'No Policy Exist' + } + } + }, + }, + }, + }; +}; + + +const createNullCache = () => { + return { + glacier: { + listVaults: { + 'us-east-1': null, + }, + getVaultAccessPolicy: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('vaultPublicAccess', function () { + describe('run', function () { + it('should PASS if S3 Glacoer vault policy does not allow global access', function (done) { + const cache = createCache([listVaults], vaultPolicy[0]); + vaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('S3 Glacoer vault policy does not allow global access'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if S3 Glacoer vault policy allows global access to the actions', function (done) { + const cache = createCache([listVaults], vaultPolicy[1]); + vaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include(`S3 Glacoer vault policy allows global access`); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no S3 vaults to check', function (done) { + const cache = createCache([]); + vaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('No S3 Glacier vaults found'); + done(); + }); + }); + + it('should PASS if vault policy does not contain any statements', function (done) { + const cache = createCache([listVaults], vaultPolicy[2]); + vaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('S3 Glacier vault policy does not contain any statements'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list vaults', function (done) { + const cache = createErrorCache(); + vaultPublicAccess.run(cache, {}, (err, results) => { + const { message } = cache.glacier.listVaults['us-east-1'].err; + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.equal(`Unable to list S3 Glacier vaults: ${message}`); + done(); + }); + }); + + it('should not return any result if s3 list vaults response is not found', function (done) { + const cache = createNullCache(); + vaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/sagemaker/notebookDataEncrypted.js b/plugins/aws/sagemaker/notebookDataEncrypted.js index 4cd839f38..a49abb31a 100644 --- a/plugins/aws/sagemaker/notebookDataEncrypted.js +++ b/plugins/aws/sagemaker/notebookDataEncrypted.js @@ -3,7 +3,9 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Notebook Data Encrypted', - category: 'SageMaker', + category: 'AI & ML', + domain: 'Compute', + severity: 'High', description: 'Ensure Notebook data is encrypted', more_info: 'An optional encryption key can be supplied during Notebook Instance creation.', recommended_action: 'An existing KMS key should be supplied during Notebook Instance creation.', @@ -14,6 +16,7 @@ module.exports = { 'data at rest. SageMaker encryption ensures Notebook data is ' + 'encrypted at rest.' }, + realtime_triggers: ['sagemaker:CreateNotebookInstance', 'sagemaker:DeleteNotebookInstance'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/sagemaker/notebookDataEncrypted.spec.js b/plugins/aws/sagemaker/notebookDataEncrypted.spec.js new file mode 100644 index 000000000..a381e951b --- /dev/null +++ b/plugins/aws/sagemaker/notebookDataEncrypted.spec.js @@ -0,0 +1,109 @@ +var expect = require('chai').expect; +const notebookDataEncrypted = require('./notebookDataEncrypted'); + +const listNotebookInstances = [ + { + "NotebookInstanceName": "nb-instance-2", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-2", + "NotebookInstanceStatus": "Pending", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "KmsKeyId": "0723d7e2-8655-4553-b4e3-20084f6bddba", + "InstanceType": "ml.t2.medium", + "CreationTime": "2020-11-16T14:59:14.821Z", + "LastModifiedTime": "2020-11-16T14:59:21.692Z" + }, + { + "NotebookInstanceName": "nb-instance-3", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-3", + "NotebookInstanceStatus": "Pending", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "InstanceType": "ml.t2.medium", + "CreationTime": "2020-11-16T14:59:14.821Z", + "LastModifiedTime": "2020-11-16T14:59:21.692Z" + } +]; + +const createCache = (instances) => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + data: instances + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + err: { + message: 'Error listing notebook instances' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': null + } + } + }; +}; + + +describe('notebookDataEncrypted', function () { + describe('run', function () { + it('should PASS if KMS key found for Notebook Instance', function (done) { + const cache = createCache([listNotebookInstances[0]]); + notebookDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if KMS key not found for Notebook Instance', function (done) { + const cache = createCache([listNotebookInstances[1]]); + notebookDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Notebook instances found', function (done) { + const cache = createCache([]); + notebookDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Notebook instances', function (done) { + const cache = createErrorCache(); + notebookDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Notebook instances response not found', function (done) { + const cache = createNullCache(); + notebookDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sagemaker/notebookDirectInternetAccess.js b/plugins/aws/sagemaker/notebookDirectInternetAccess.js index 1cc5c8e67..f5caa163f 100644 --- a/plugins/aws/sagemaker/notebookDirectInternetAccess.js +++ b/plugins/aws/sagemaker/notebookDirectInternetAccess.js @@ -3,12 +3,15 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Notebook Direct Internet Access', - category: 'SageMaker', + category: 'AI & ML', + domain: 'Compute', + severity: 'Medium', description: 'Ensure Notebook Instance is not publicly available.', more_info: 'SageMaker notebooks should not be exposed to the Internet. Public availability can be configured via the DirectInternetAccess attribute.', recommended_action: 'Disable DirectInternetAccess for each SageMaker notebook.', link: 'https://docs.aws.amazon.com/sagemaker/latest/dg/appendix-additional-considerations.html#appendix-notebook-and-internet-access', apis: ['SageMaker:listNotebookInstances'], + realtime_triggers: ['sagemaker:CreateNotebookInstance', 'sagemaker:UpdateNootbookInstance','sagemaker:DeleteNotebookInstance'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/sagemaker/notebookDirectInternetAccess.spec.js b/plugins/aws/sagemaker/notebookDirectInternetAccess.spec.js new file mode 100644 index 000000000..c429bbe91 --- /dev/null +++ b/plugins/aws/sagemaker/notebookDirectInternetAccess.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +const notebookDirectInternetAccess = require('./notebookDirectInternetAccess'); + +const listNotebookInstances = [ + { + "NotebookInstanceName": "nb-instance-2", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-2", + "NotebookInstanceStatus": "InService", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "KmsKeyId": "0723d7e2-8655-4553-b4e3-20084f6bddba", + "InstanceType": "ml.t2.medium", + "CreationTime": "2020-11-16T14:59:14.821Z", + "LastModifiedTime": "2020-11-16T14:59:21.692Z" + }, + { + "NotebookInstanceName": "nb-instance-3", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-3", + "NotebookInstanceStatus": "InService", + "DirectInternetAccess": "Enabled", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "InstanceType": "ml.t2.medium", + "CreationTime": "2020-11-16T14:59:14.821Z", + "LastModifiedTime": "2020-11-16T14:59:21.692Z" + } +]; + +const createCache = (instances) => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + data: instances + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + err: { + message: 'Error listing notebook instances' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': null + } + } + }; +}; + + +describe('notebookDirectInternetAccess', function () { + describe('run', function () { + it('should PASS if Direct Internet access is not enabled', function (done) { + const cache = createCache([listNotebookInstances[0]]); + notebookDirectInternetAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Direct Internet access is enabled', function (done) { + const cache = createCache([listNotebookInstances[1]]); + notebookDirectInternetAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Notebook instances found', function (done) { + const cache = createCache([]); + notebookDirectInternetAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Notebook instances', function (done) { + const cache = createErrorCache(); + notebookDirectInternetAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Notebook instances response not found', function (done) { + const cache = createNullCache(); + notebookDirectInternetAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sagemaker/notebookInstanceInVpc.js b/plugins/aws/sagemaker/notebookInstanceInVpc.js new file mode 100644 index 000000000..11a653239 --- /dev/null +++ b/plugins/aws/sagemaker/notebookInstanceInVpc.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Notebook instance in VPC', + category: 'AI & ML', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Amazon SageMaker Notebook instances are launched within a VPC.', + more_info: 'Launching instances can bring multiple advantages such as better networking infrastructure, much more flexible control over access security. Also it makes it possible to access VPC-only resources such as EFS file systems.', + recommended_action: 'Migrate Notebook instances to exist within a VPC', + link: 'https://docs.aws.amazon.com/sagemaker/latest/dg/API_CreateNotebookInstance.html#API_CreateNotebookInstance_RequestSyntax', + apis: ['SageMaker:listNotebookInstances', 'SageMaker:describeNotebookInstance'], + compliance: { + hipaa: 'AWS VPC is the recommended location for processing of HIPAA-related ' + + 'data. All instances storing or processing HIPAA data should be ' + + 'launched in a VPC to avoid exposure to the public network.', + pci: 'VPCs provide a firewall for compute resources that meets the network ' + + 'segmentation criteria for PCI. Ensure all instances are launched ' + + 'within a VPC to comply with isolation requirements.' + }, + realtime_triggers: ['sagemaker:CreateNotebookInstance','sagemaker:DeleteNotebookInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sagemaker, function(region, rcb){ + var listNotebookInstances = helpers.addSource(cache, source, + ['sagemaker', 'listNotebookInstances', region]); + + if (!listNotebookInstances) return rcb(); + + if (listNotebookInstances.err) { + helpers.addResult(results, 3, + 'Unable to query for Notebook Instances: ' + + helpers.addError(listNotebookInstances), region); + return rcb(); + } + + if (!listNotebookInstances.data || !listNotebookInstances.data.length) { + helpers.addResult( + results, 0, 'No Notebook Instances Found', region); + return rcb(); + } + + for (var i in listNotebookInstances.data) { + var instance = listNotebookInstances.data[i]; + var instanceArn = instance.NotebookInstanceArn; + + // A network interface is assigned when the notebook is VPC-based. + // Similarly, the instance must be assigned to a subnet if it is VPC-based. + var describeInstance = helpers.addSource(cache, source, + ['sagemaker', 'describeNotebookInstance', region, instance.NotebookInstanceName]); + + if (!describeInstance) return; + + else if (describeInstance.err || !describeInstance.data) { + helpers.addResult( + results, 3, 'Unable to query for Notebook Instance: ' + + helpers.addError(describeInstance), region); + return; + } else { + if (!describeInstance.data.NetworkInterfaceId) { + helpers.addResult(results, 2, + 'SageMaker Notebook instance not in VPC', region, instanceArn); + } else { + helpers.addResult(results, 0, + 'SageMaker Notebook instance in VPC', region, instanceArn); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sagemaker/notebookInstanceInVpc.spec.js b/plugins/aws/sagemaker/notebookInstanceInVpc.spec.js new file mode 100644 index 000000000..3be6fd9aa --- /dev/null +++ b/plugins/aws/sagemaker/notebookInstanceInVpc.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +const notebookInstanceInVpc = require('./notebookInstanceInVpc'); + +const listNotebookInstances = [ + { + "NotebookInstanceName": "nb-instance-2", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-2", + "NotebookInstanceStatus": "InService", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "KmsKeyId": "0723d7e2-8655-4553-b4e3-20084f6bddba", + "InstanceType": "ml.t2.medium", + "CreationTime": "2020-11-16T14:59:14.821Z", + "LastModifiedTime": "2020-11-16T14:59:21.692Z", + }, + { + "NotebookInstanceName": "nb-instance-3", + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:112233445566:notebook-instance/nb-instance-3", + "NotebookInstanceStatus": "InService", + "DirectInternetAccess": "Enabled", + "Url": "nb-instance-2.notebook.us-east-1.sagemaker.aws", + "InstanceType": "ml.t2.medium", + } +]; + +const describeNotebookInstance = [ + { + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:1122334455:notebook-instance/test", + "NotebookInstanceName": "test", + "NotebookInstanceStatus": "Pending", + "Url": "test-mdbp.notebook.us-east-1.sagemaker.aws", + "InstanceType": "ml.t3.medium", + "SubnetId": "subnet-090543c3cc7bee455", + "SecurityGroups": [ + "sg-0931c3a02deed68f5" + ], + "RoleArn": "arn:aws:iam::11222333445:role/service-role/AmazonSageMaker-ExecutionRole-20230803T155360", + "NetworkInterfaceId": "eni-0fce758c1693daae7", + "LastModifiedTime": "2023-08-04T12:59:53.661000+05:00", + "CreationTime": "2023-08-04T12:59:46.521000+05:00", + "DirectInternetAccess": "Disabled", + "VolumeSizeInGB": 5, + "RootAccess": "Enabled", + "PlatformIdentifier": "notebook-al2-v2", + "InstanceMetadataServiceConfiguration": { + "MinimumInstanceMetadataServiceVersion": "2" + } + }, + { + "NotebookInstanceArn": "arn:aws:sagemaker:us-east-1:11222334455:notebook-instance/test", + "NotebookInstanceName": "test", + "NotebookInstanceStatus": "Pending", + "Url": "test-mdbp.notebook.us-east-1.sagemaker.aws", + "InstanceType": "ml.t3.medium", + "SecurityGroups": [ + "sg-0931c3a02deed68f5" + ], + "RoleArn": "arn:aws:iam::1122334455:role/service-role/AmazonSageMaker-ExecutionRole-20230803T155360", + "LastModifiedTime": "2023-08-04T12:59:53.661000+05:00", + "CreationTime": "2023-08-04T12:59:46.521000+05:00", + "DirectInternetAccess": "Disabled", + "VolumeSizeInGB": 5, + "RootAccess": "Enabled", + "PlatformIdentifier": "notebook-al2-v2", + "InstanceMetadataServiceConfiguration": { + "MinimumInstanceMetadataServiceVersion": "2" + } + } + + +]; + +const createCache = (listInstances, instances) => { + var name = (listInstances && listInstances.length) ? listInstances[0].NotebookInstanceName : null; + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + data: listInstances + } + }, + describeNotebookInstance: { + 'us-east-1': { + [name] : { + data: instances + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': { + err: { + message: 'Error listing notebook instances' + } + } + }, + describeNotebookInstance: { + 'us-east-1': { + 'NotebookInstanceName': { + err: { + message: 'error fetching instance detail' + }, + }, + }, + } + } + }; +}; + +const createNullCache = () => { + return { + sagemaker: { + listNotebookInstances: { + 'us-east-1': null + } + } + }; +}; + + +describe('notebookInstanceInVpc', function() { + describe('run', function () { + it('should PASS if instance launched within VPC', function(done) { + const cache = createCache([listNotebookInstances[0]],describeNotebookInstance[0]); + notebookInstanceInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instance is not launched within VPC', function(done) { + const cache = createCache([listNotebookInstances[1]],describeNotebookInstance[1]); + notebookInstanceInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Notebook instances found', function (done) { + const cache = createCache([]); + notebookInstanceInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list Notebook instances', function (done) { + const cache = createErrorCache(); + notebookInstanceInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Notebook instances response not found', function (done) { + const cache = createNullCache(); + notebookInstanceInVpc.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/secretsmanager/secretHasTags.js b/plugins/aws/secretsmanager/secretHasTags.js new file mode 100644 index 000000000..98ce3a26a --- /dev/null +++ b/plugins/aws/secretsmanager/secretHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Secret Has Tags', + category: 'Secrets Manager', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that AWS Secrets Manager secrets have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Update Secrets and add tags.', + apis: ['SecretsManager:listSecrets'], + link: 'https://docs.aws.amazon.com/secretsmanager/latest/userguide/managing-secrets_tagging.html', + realtime_triggers: ['secretesmanager:CreateSecret', 'secretesmanager:TagResource', 'secretesmanager:UntagResource', 'secretesmanager:DeleteSecret'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.secretsmanager, (region, rcb) => { + var listSecrets = helpers.addSource(cache, source, ['secretsmanager', 'listSecrets', region]); + + if (!listSecrets) return rcb(); + + if (!listSecrets.data || listSecrets.err) { + helpers.addResult(results, 3, `Unable to query for Secrets Manager secrets: ${helpers.addError(listSecrets)}`, region); + return rcb(); + } + + if (!listSecrets.data.length) { + helpers.addResult(results, 0, 'No secrets found', region); + return rcb(); + } + + for (let secret of listSecrets.data){ + if (!secret.ARN) continue; + + if (!secret.Tags || !secret.Tags.length){ + helpers.addResult(results, 2, 'Secrets Manager secret does not have tags', region, secret.ARN); + } else { + helpers.addResult(results, 0, 'Secrets Manager secret has tags', region, secret.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/secretsmanager/secretHasTags.spec.js b/plugins/aws/secretsmanager/secretHasTags.spec.js new file mode 100644 index 000000000..6d17bc4d5 --- /dev/null +++ b/plugins/aws/secretsmanager/secretHasTags.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var secretHasTags = require('./secretHasTags'); + +const listSecrets = [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": true, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [{key: 'value'}], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + }, + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": false, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + } +]; + + +const createCache = (listSecrets, listErr) => { + + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + err: listErr, + data: listSecrets + } + }, + } + }; +}; + +const createNullCache = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': null, + } + } + }; +}; + +describe('secretHasTags', function () { + describe('run', function () { + it('should PASS if secret has tags', function (done) { + const cache = createCache([listSecrets[0]]); + secretHasTags.run(cache, {},(err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Secrets Manager secret has tags'); + done(); + }); + }); + + it('should FAIL if secret does not have tags', function (done) { + const cache = createCache([listSecrets[1]]); + secretHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Secrets Manager secret does not have tags'); + done(); + }); + }); + + it('should PASS if no secrets found', function (done) { + const cache = createCache([]); + secretHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No secrets found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for secrets', function (done) { + const cache = createCache([],{ message: 'Unable to query secrets'}); + secretHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for Secrets Manager secrets'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/secretsmanager/secretRotationEnabled.js b/plugins/aws/secretsmanager/secretRotationEnabled.js new file mode 100644 index 000000000..d35e1d96e --- /dev/null +++ b/plugins/aws/secretsmanager/secretRotationEnabled.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Secrets Manager Secret Rotation Enabled', + category: 'Secrets Manager', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures AWS Secrets Manager is configured to automatically rotate the secret for a secured service or database.', + more_info: 'Secrets Manager rotation makes access to your databases and third-party services secure by automatically rotating secrets used to access these resources.', + recommended_action: 'Enable secret rotation for your secrets', + apis: ['SecretsManager:listSecrets', 'SecretsManager:describeSecret'], + link: 'https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html', + settings: { + secretsmanager_secret_rotation_interval: { + name: 'Secrets Manager Secret Rotation Interval', + description: 'Number of days after which secret should be rotated', + regex: '[1-9]{1}[0-9]{0,3}$', + default: '40', + } + }, + realtime_triggers: ['secretesmanager:CreateSecret', 'secretesmanager:RotateSecret', 'secretsmanager:CancelRotateSecret','secretesmanager:DeleteSecret'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + secretsmanager_secret_rotation_interval: parseInt(settings.secretsmanager_secret_rotation_interval || this.settings.secretsmanager_secret_rotation_interval.default) + }; + + async.each(regions.secretsmanager, (region, rcb) => { + var listSecrets = helpers.addSource(cache, source, ['secretsmanager', 'listSecrets', region]); + + if (!listSecrets) return rcb(); + + if (!listSecrets.data || listSecrets.err) { + helpers.addResult(results, 3, `Unable to query for Secrets Manager secrets: ${helpers.addError(listSecrets)}`, region); + return rcb(); + } + + if (!listSecrets.data.length) { + helpers.addResult(results, 0, 'No secrets found', region); + return rcb(); + } + + async.each(listSecrets.data, (secret, scb) => { + if (!secret.ARN) return scb(); + + var resource = secret.ARN; + + var describeSecret = helpers.addSource(cache, source, + ['secretsmanager', 'describeSecret', region, resource]); + + if (!describeSecret || describeSecret.err || !describeSecret.data) { + helpers.addResult(results, 3, + `Unable to query Secrets Manager secret: ${helpers.addError(describeSecret)}`, region, resource); + return scb(); + } + + if (describeSecret.data.RotationEnabled && + describeSecret.data.RotationRules && + describeSecret.data.RotationRules.AutomaticallyAfterDays) { + var rotationInterval = describeSecret.data.RotationRules.AutomaticallyAfterDays; + + if (rotationInterval >= config.secretsmanager_secret_rotation_interval) { + helpers.addResult(results, 0, + `Rotation is enabled for Secrets Manager secret and rotation interval is ${rotationInterval} days`, + region, resource); + } else { + helpers.addResult(results, 2, + `Rotation is enabled for Secrets Manager secret but set rotation interval ${rotationInterval} is less than desired interval of ${config.secretsmanager_secret_rotation_interval} days`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Rotation is not enabled for Secrets Manager secret', region, resource); + } + + scb(); + }, function(){ + rcb(); + }); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/secretsmanager/secretRotationEnabled.spec.js b/plugins/aws/secretsmanager/secretRotationEnabled.spec.js new file mode 100644 index 000000000..3b4e41985 --- /dev/null +++ b/plugins/aws/secretsmanager/secretRotationEnabled.spec.js @@ -0,0 +1,185 @@ +var expect = require('chai').expect; +var secretRotationEnabled = require('./secretRotationEnabled'); + +const listSecrets = [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": true, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + }, + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": false, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + } +]; + +const describeSecret = [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": true, + "RotationRules": { + "AutomaticallyAfterDays": 30 + }, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ "AWSCURRENT" ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ "AWSPENDING" ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + }, + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": false, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ "AWSCURRENT" ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ "AWSPENDING" ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + } +]; + +const createCache = (listSecrets, describeSecret, listErr, getErr) => { + var secretArn = (listSecrets && listSecrets.length) ? listSecrets[0].ARN : null; + + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + err: listErr, + data: listSecrets + } + }, + describeSecret: { + 'us-east-1': { + [secretArn]: { + err: getErr, + data: describeSecret + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': null, + } + } + }; +}; + +describe('secretRotationEnabled', function () { + describe('run', function () { + it('should PASS if rotation is enabled for Secrets Manager secret', function (done) { + const cache = createCache([listSecrets[0]], describeSecret[0]); + secretRotationEnabled.run(cache, { secretsmanager_secret_rotation_interval: 25 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if rotation is enabled for Secrets Manager secret but set rotation interval is less than desired interval', function (done) { + const cache = createCache([listSecrets[0]], describeSecret[0]); + secretRotationEnabled.run(cache, { secretsmanager_secret_rotation_interval: 40 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if rotation is not enabled for Secrets Manager secret', function (done) { + const cache = createCache([listSecrets[0]], describeSecret[1]); + secretRotationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no secrets found', function (done) { + const cache = createCache([]); + secretRotationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for secrets', function (done) { + const cache = createCache([], null, { message: 'Unable to query secrets'}); + secretRotationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query Secrets Manager secret', function (done) { + const cache = createCache([listSecrets[0]], {}, null, { message: 'Unable to query Secrets Manager secret'}); + secretRotationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list secrets response not found', function (done) { + const cache = createNullCache(); + secretRotationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/secretsmanager/secretsManagerEncrypted.js b/plugins/aws/secretsmanager/secretsManagerEncrypted.js new file mode 100644 index 000000000..c8c14a6db --- /dev/null +++ b/plugins/aws/secretsmanager/secretsManagerEncrypted.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Secrets Manager Encrypted Secrets', + category: 'Secrets Manager', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensures Secrets Manager Secrets are encrypted', + more_info: 'Secrets Manager Secrets should be encrypted. This allows their values to be used by approved systems, while restricting access to other users of the account.', + recommended_action: 'Encrypt Secrets Manager Secrets', + apis: ['SecretsManager:listSecrets', 'KMS:listKeys', 'KMS:describeKey'], + link: 'https://docs.aws.amazon.com/secretsmanager/latest/userguide/data-protection.html', + compliance: { + hipaa: 'HIPAA requires that all data is encrypted, including data at rest', + pci: 'PCI requires proper encryption of cardholder data at rest. Secrets Manager ' + + 'encryption should be enabled for all Secrets storing this type ' + + 'of data.' + }, + settings: { + secretsmanager_minimum_encryption_level: { + name: 'Secrets Manager Secret Minimum Encryption Level', + description: 'In order (lowest to highest) \ + awskms=AWS-managed KMS; \ + awscmk=Customer managed KMS; \ + externalcmk=Customer managed externally sourced KMS; \ + cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['secretesmanager:CreateSecret', 'secretesmanager:UpdateSecret','secretesmanager:DeleteSecret'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var desiredEncryptionLevelString = settings.secretsmanager_minimum_encryption_level || this.settings.secretsmanager_minimum_encryption_level.default; + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(desiredEncryptionLevelString); + + async.each(regions.secretsmanager, (region, rcb) => { + var listSecrets = helpers.addSource(cache, source, ['secretsmanager', 'listSecrets', region]); + + if (!listSecrets) return rcb(); + + if (!listSecrets.data || listSecrets.err) { + helpers.addResult(results, 3, `Unable to query for secrets: ${helpers.addError(listSecrets)}`, region); + return rcb(); + } + + if (!listSecrets.data.length) { + helpers.addResult(results, 0, 'No secrets found', region); + return rcb(); + } + + for (let secret of listSecrets.data) { + let encryptionLevel; + let encryptionLevelString; + + if (!secret.KmsKeyId) encryptionLevel = 2; //awskms + else { + const keyId = secret.KmsKeyId.startsWith(`arn:${awsOrGov}:kms`) + ? secret.KmsKeyId.split('/')[1] + : secret.KmsKeyId; + + const describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, region, keyId); + continue; + } + + encryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + + encryptionLevelString = helpers.ENCRYPTION_LEVELS[encryptionLevel]; + + if (encryptionLevel < desiredEncryptionLevel) { + helpers.addResult(results, 2, `Secret configured to use ${encryptionLevelString} instead of ${desiredEncryptionLevelString}`, region, secret.ARN); + } else { + helpers.addResult(results, 0, `Secret configured to use desired encryption ${encryptionLevelString}`, region, secret.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/secretsmanager/secretsManagerEncrypted.spec.js b/plugins/aws/secretsmanager/secretsManagerEncrypted.spec.js new file mode 100644 index 000000000..c71d6d9e5 --- /dev/null +++ b/plugins/aws/secretsmanager/secretsManagerEncrypted.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var secretsManagerEncrypted = require('./secretsManagerEncrypted'); + +const createCacheNoSecrets = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + data: [], + }, + }, + }, + }; +}; + +const createCacheAWSKMS = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + data: [{ + ARN: 'arn:aws:secretsmanager:us-east-1:111111111111:secret:testing-3eAAB5', + }], + }, + }, + }, + }; +}; + +const createCacheAWSCMK = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + data: [{ + ARN: 'arn:aws:secretsmanager:us-east-1:111111111111:secret:testing-3eAAB5', + KmsKeyId: 'mykey', + }], + }, + }, + }, + kms: { + describeKey: { + 'us-east-1': { + mykey: { + data: { + KeyMetadata: { + Origin: 'EXTERNAL', + KeyManager: 'CUSTOMER', + }, + }, + }, + }, + }, + }, + }; +}; + +describe('secretsManagerEncrypted', function () { + describe('run', function () { + it('should PASS when there are no secrets', function (done) { + const cache = createCacheNoSecrets(); + secretsManagerEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS when there are secrets and awskms is required encryption level', function (done) { + const cache = createCacheAWSKMS(); + secretsManagerEncrypted.run(cache, { secretsmanager_minimum_encryption_level: 'awskms' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL when there are secrets using DefaultEncryptionKey and awscmk is required encryption level', function (done) { + const cache = createCacheAWSKMS(); + secretsManagerEncrypted.run(cache, { secretsmanager_minimum_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS when there are secrets using awscmk and awscmk is required encryption level', function (done) { + const cache = createCacheAWSCMK(); + secretsManagerEncrypted.run(cache, { secretsmanager_minimum_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/secretsmanager/secretsManagerInUse.js b/plugins/aws/secretsmanager/secretsManagerInUse.js new file mode 100644 index 000000000..7200f9abc --- /dev/null +++ b/plugins/aws/secretsmanager/secretsManagerInUse.js @@ -0,0 +1,43 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Secrets Manager In Use', + category: 'Secrets Manager', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that Amazon Secrets Manager service is being used in your account to manage all the credentials.', + more_info: 'Amazon Secrets Manager helps you protect sensitive information needed to access your cloud applications, services and resources. Users and apps can use secrets manager to get the secrets stored with a call to Secrets Manager API, enhancing access security.', + recommended_action: 'Use Secrets Manager service to store sensitive information in your AWS account.', + apis: ['SecretsManager:listSecrets'], + link: 'https://docs.aws.amazon.com/secretsmanager/latest/userguide/asm_access.html', + realtime_triggers: ['secretesmanager:CreateSecret', 'secretesmanager:DeleteSecret'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.secretsmanager, (region, rcb) => { + var listSecrets = helpers.addSource(cache, source, ['secretsmanager', 'listSecrets', region]); + + if (!listSecrets) return rcb(); + + if (!listSecrets.data || listSecrets.err) { + helpers.addResult(results, 3, `Unable to query for Secrets Manager secrets: ${helpers.addError(listSecrets)}`, region); + return rcb(); + } + + if (!listSecrets.data.length) { + helpers.addResult(results, 2, `Secrets Manager is not enabled: ${helpers.addError(listSecrets)}`, region); + return rcb(); + } else { + helpers.addResult(results, 0, 'Secrets Manager is enabled', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/secretsmanager/secretsManagerInUse.spec.js b/plugins/aws/secretsmanager/secretsManagerInUse.spec.js new file mode 100644 index 000000000..8537d27f5 --- /dev/null +++ b/plugins/aws/secretsmanager/secretsManagerInUse.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var secretsManagerInUse = require('./secretsManagerInUse'); + +const listSecrets = [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": true, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + }, + { + "ARN": "arn:aws:secretsmanager:us-east-1:111122223333:secret:secret-1-yfhuJM", + "Name": "secret-1", + "Description": "My DB secret", + "KmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/995d1b05-5f9c-4610-aae7-f8505f4458f5", + "RotationEnabled": false, + "RotationLambdaARN": "arn:aws:lambda:us-east-1:111122223333:function:test-lambda", + "LastChangedDate": "2021-02-22T12:40:07.146Z", + "Tags": [], + "SecretVersionsToStages": { + "56432b9d-8a51-43aa-b7f9-cac5470641d1": [ + "AWSCURRENT" + ], + "cbb8f659-cb58-41b4-ba2f-c5df8144086f": [ + "AWSPENDING" + ] + }, + "CreatedDate": "2021-02-22T12:29:25.488Z" + } +]; + +const createCache = (listSecrets, listErr) => { + var secretArn = (listSecrets && listSecrets.length) ? listSecrets[0].ARN : null; + + return { + secretsmanager: { + listSecrets: { + 'us-east-1': { + err: listErr, + data: listSecrets + } + } + } + }; +}; + +const createNullCache = () => { + return { + secretsmanager: { + listSecrets: { + 'us-east-1': null, + } + } + }; +}; + +describe('secretsManagerInUse', function () { + describe('run', function () { + it('should PASS if Secrets Manager is in use', function (done) { + const cache = createCache([listSecrets[0]]); + secretsManagerInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Secrets Manager is not in use for current region', function (done) { + const cache = createCache([]); + secretsManagerInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for secrets', function (done) { + const cache = createCache([], { message: 'Unable to query secrets'}); + secretsManagerInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if list secrets response not found', function (done) { + const cache = createNullCache(); + secretsManagerInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/securityhub/securityHubActiveFindings.js b/plugins/aws/securityhub/securityHubActiveFindings.js new file mode 100644 index 000000000..ccf32f750 --- /dev/null +++ b/plugins/aws/securityhub/securityHubActiveFindings.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Security Hub No Active Findings', + category: 'Security Hub', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Security Hub active findings do not exist in your AWS account.', + more_info: 'AWS Security Hub provides you with a comprehensive view of your security state within AWS. It continuously monitors your environment using automated security checks based on AWS best practices and industry standards, and aggregates findings from various AWS services. Active findings should be remediated and archived to maintain a secure environment.', + link: 'https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-findings.html', + recommended_action: 'Resolve the Security Hub findings and archive them.', + apis: ['SecurityHub:describeHub', 'SecurityHub:getFindings'], + settings: { + securityhub_findings_fail: { + name: 'Security Hub Findings Fail', + description: 'Return a failing result if a finding has not been archived after these many hours', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '48' + } + }, + realtime_triggers: ['securityhub:EnableSecurityHub', 'securityhub:DisableSecurityHub', 'securityhub:BatchUpdateFindings'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + + var config = { + securityhub_findings_fail: parseInt(settings.securityhub_findings_fail || this.settings.securityhub_findings_fail.default) + }; + + async.each(regions.securityhub, function(region, rcb) { + var describeHub = helpers.addSource(cache, source, ['securityhub', 'describeHub', region]); + + if (!describeHub) return rcb(); + + if (describeHub.err && describeHub.err.code === 'InvalidAccessException') { + helpers.addResult(results, 0, 'Security Hub is not enabled', region); + } else if (describeHub.err || !describeHub.data) { + helpers.addResult(results, 3, `Unable to query for Security Hub: ${helpers.addError(describeHub)}`, region); + } else { + var resource = describeHub.data.HubArn; + const getFindings = helpers.addSource(cache, source, ['securityhub', 'getFindings', region]); + + if (!getFindings || !getFindings.data) { + helpers.addResult(results, 0, 'No active findings available', region, resource); + return rcb(); + } else if (getFindings.err) { + helpers.addResult(results, 3, `Unable to get SecurityHub findings: ${helpers.addError(getFindings)}`, region, resource); + } else if (!getFindings.data.length) { + helpers.addResult(results, 0, 'No active findings available', region, resource); + return rcb(); + } else { + let activeFindings = getFindings.data.filter(finding => finding.CreatedAt && + helpers.hoursBetween(new Date, finding.CreatedAt) > config.securityhub_findings_fail); + + if (!activeFindings.length) { + helpers.addResult(results, 0, + 'Security Hub has no active findings', region, resource); + } else { + helpers.addResult(results, 2, + `Security Hub has over ${activeFindings.length} active findings`, region, resource); + } + } + + } + + return rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/securityhub/securityHubActiveFindings.spec.js b/plugins/aws/securityhub/securityHubActiveFindings.spec.js new file mode 100644 index 000000000..75df0f050 --- /dev/null +++ b/plugins/aws/securityhub/securityHubActiveFindings.spec.js @@ -0,0 +1,115 @@ +const expect = require('chai').expect; +var securityHubActiveFindings = require('./securityHubActiveFindings.js') +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 1); + +const describeHub = { + HubArn: 'arn:aws:securityhub:us-east-1:000011112222:hub/default', + SubscribedAt: '2023-08-01T12:46:59.711Z', + AutoEnableControls: true, + ControlFindingGenerator: 'SECURITY_CONTROL', +}; + +const getFindings = [ + { + 'AwsAccountId':'123456', + 'CompanyName':'AWS', + 'CreatedAt': new Date(), + 'Description': 'Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms. Routing tables are used to route network traffic between subnets and to network gateways. It is recommended that a metric filter and alarm be established for changes to route tables.', + }, + { + 'AwsAccountId':'123456', + 'CompanyName':'AWS', + 'CreatedAt': failDate, + 'Description': 'Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms. Routing tables are used to route network traffic between subnets and to network gateways. It is recommended that a metric filter and alarm be established for changes to route tables.', + }, + { + 'AwsAccountId':'123456', + 'CompanyName':'AWS', + 'CreatedAt': new Date(), + 'Description': 'Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms. Routing tables are used to route network traffic between subnets and to network gateways. It is recommended that a metric filter and alarm be established for changes to route tables.', + } +] + + +const createCache = (describeHubData, describeHubErr, getFindings, getFindingErr) => { + return { + securityhub: { + describeHub: { + 'us-east-1': { + err: describeHubErr, + data: describeHubData, + }, + }, + getFindings: { + 'us-east-1': { + err: getFindingErr, + data: getFindings, + }, + } + }, + }; +}; + + + +describe('securityHubActiveFindings', function () { + describe('run', function () { + + it('should PASS if Security Hub is not enabled', function (done) { + const errorMessage = 'InvalidAccessException'; + const cache = createCache(describeHub, { code: 'InvalidAccessException' }); + securityHubActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('Security Hub is not enabled'); + done(); + }); + }); + + it('should return UNKNOWN if Unable to query for Security Hub', function (done) { + const errorMessage = 'Unable to query for Security Hub'; + const cache = createCache(describeHub, errorMessage); + securityHubActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if Security Hub has no active findings', function (done) { + const cache = createCache(describeHub, null, []); + securityHubActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('No active findings available'); + done(); + }); + }); + + it('should PASS if Security Hub has zero active findings', function (done) { + const cache = createCache(describeHub, null, [getFindings[0]]); + securityHubActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('Security Hub has no active findings'); + done(); + }); + }); + + it('should FAIL if Security Hub has active findings', function (done) { + const cache = createCache(describeHub, null, [getFindings[1]]); + securityHubActiveFindings.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.includes('Security Hub has over'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/securityhub/securityHubEnabled.js b/plugins/aws/securityhub/securityHubEnabled.js new file mode 100644 index 000000000..ceeb22112 --- /dev/null +++ b/plugins/aws/securityhub/securityHubEnabled.js @@ -0,0 +1,39 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Security Hub Enabled', + category: 'SecurityHub', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that AWS Security Hub is enabled.', + more_info: 'AWS Security Hub provides a comprehensive view of your security posture across your AWS accounts. It aggregates, organises, and prioritises security findings from various AWS services.', + link: 'https://aws.amazon.com/security-hub/', + recommended_action: 'Enable AWS Security Hub for enhanced security monitoring and compliance.', + apis: ['SecurityHub:describeHub'], + realtime_triggers: ['securityhub:EnableSecurityHub', 'securityhub:DisableSecurityHub'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.securityhub, function(region, rcb) { + var describeHub = helpers.addSource(cache, source, ['securityhub', 'describeHub', region]); + + if (!describeHub) return rcb(); + + if (describeHub.err && describeHub.err.code === 'InvalidAccessException'){ + helpers.addResult(results, 2, 'Security Hub is not enabled', region); + } else if (describeHub.err || !describeHub.data) { + helpers.addResult(results, 3, `Unable to query for Security Hub: ${helpers.addError(describeHub)}`, region); + } else { + helpers.addResult(results, 0, 'Security Hub is enabled', region,describeHub.data.HubArn); + } + + return rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/securityhub/securityHubEnabled.spec.js b/plugins/aws/securityhub/securityHubEnabled.spec.js new file mode 100644 index 000000000..8e1211197 --- /dev/null +++ b/plugins/aws/securityhub/securityHubEnabled.spec.js @@ -0,0 +1,64 @@ +const expect = require('chai').expect; +var securityHubEnabled = require('./securityHubEnabled.js') + +const describeHub = { + HubArn: 'arn:aws:securityhub:us-east-1:000011112222:hub/default', + SubscribedAt: '2023-08-01T12:46:59.711Z', + AutoEnableControls: true, + ControlFindingGenerator: 'SECURITY_CONTROL', +}; + + + +const createCache = (describeHubData, describeHubErr) => { + return { + securityhub: { + describeHub: { + 'us-east-1': { + err: describeHubErr, + data: describeHubData, + }, + }, + }, + }; +}; + + + +describe('securityHubEnabled', function () { + describe('run', function () { + it('should PASS if Security Hub is enabled', function (done) { + const cache = createCache(describeHub, null); + securityHubEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('Security Hub is enabled'); + done(); + }); + }); + + it('should FAIL if Security Hub is not enabled', function (done) { + const errorMessage = 'InvalidAccessException'; + const cache = createCache(describeHub, { code: 'InvalidAccessException' }); + securityHubEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.equal('Security Hub is not enabled'); + done(); + }); + }); + + it('should return UNKNOWN if Unable to query for Security Hub', function (done) { + const errorMessage = 'Unable to query for Security Hub'; + const cache = createCache(describeHub, errorMessage); + securityHubEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ses/dkimEnabled.js b/plugins/aws/ses/dkimEnabled.js index 7a783f9dd..cf3937c77 100644 --- a/plugins/aws/ses/dkimEnabled.js +++ b/plugins/aws/ses/dkimEnabled.js @@ -4,17 +4,25 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Email DKIM Enabled', category: 'SES', + domain: 'Content Delivery', + severity: 'Low', description: 'Ensures DomainKeys Identified Mail (DKIM) is enabled for domains and addresses in SES.', more_info: 'DKIM is a security feature that allows recipients of an email to veriy that the sender domain has authorized the message and that it has not been spoofed.', recommended_action: 'Enable DKIM for all domains and addresses in all regions used to send email through SES.', link: 'http://docs.aws.amazon.com/ses/latest/DeveloperGuide/easy-dkim.html', - apis: ['SES:listIdentities', 'SES:getIdentityDkimAttributes'], + apis: ['SES:listIdentities', 'SES:getIdentityDkimAttributes', 'STS:getCallerIdentity'], + realtime_triggers: ['ses:CreateEmailIdentity','ses:SetIdentityDkimEnabled', 'ses:PutEmailIdentityDkimAttributes', 'ses:DeleteEmailIdentity'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + async.each(regions.ses, function(region, rcb){ var listIdentities = helpers.addSource(cache, source, ['ses', 'listIdentities', region]); @@ -43,17 +51,18 @@ module.exports = { return rcb(); } - for (var i in getIdentityDkimAttributes.data.DkimAttributes) { - var identity = getIdentityDkimAttributes.data.DkimAttributes[i]; + for (var identity of getIdentityDkimAttributes.data.DkimAttributes) { + if (!identity.identityName) continue; + var resource = `arn:${awsOrGov}:ses:${region}:${accountId}:identity/${identity.identityName}`; if (!identity.DkimEnabled) { - helpers.addResult(results, 2, 'DKIM is not enabled', region, i); + helpers.addResult(results, 2, 'DKIM is not enabled', region, resource); } else if (identity.DkimVerificationStatus !== 'Success') { helpers.addResult(results, 1, - 'DKIM is enabled, but not configured properly', region, i); + 'DKIM is enabled, but not configured properly', region, resource); } else { helpers.addResult(results, 0, - 'DKIM is enabled and configured properly', region, i); + 'DKIM is enabled and configured properly', region, resource); } } diff --git a/plugins/aws/ses/dkimEnabled.spec.js b/plugins/aws/ses/dkimEnabled.spec.js new file mode 100644 index 000000000..b5ff05cba --- /dev/null +++ b/plugins/aws/ses/dkimEnabled.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var dkimEnabled = require('./dkimEnabled'); + + +const listIdentities = [ + 'khulnasoft.com' +]; + +const getIdentityDkimAttributes = [ + { + "DkimEnabled": true, + "identityName": 'abc.com', + "DkimVerificationStatus": "Pending", + "DkimTokens": [ + "otux44vv2jf7bme4j6y7qyagkni466lo", + "tep4hxszbbu4ltdyxzpgkjqoghl7f64b", + "ljnfv3lg7vyxvwefsarexpur4hc6sle7" + ] + }, + { + "DkimEnabled": true, + "identityName": 'test.com', + "DkimVerificationStatus": "Success", + "DkimTokens": [ + "otux44vv2jf7bme4j6y7qyagkni466lo", + "tep4hxszbbu4ltdyxzpgkjqoghl7f64b", + "ljnfv3lg7vyxvwefsarexpur4hc6sle7" + ] + }, + { + "DkimEnabled": false, + "identityName": 'test.com', + "DkimVerificationStatus": "Pending", + "DkimTokens": [ + "otux44vv2jf7bme4j6y7qyagkni466lo", + "tep4hxszbbu4ltdyxzpgkjqoghl7f64b", + "ljnfv3lg7vyxvwefsarexpur4hc6sle7" + ] + } +]; + +const createCache = (listIdentities, dkimAttributes, listErr, getErr) => { + return { + ses: { + listIdentities: { + 'us-east-1': { + err: listErr, + data: listIdentities + } + }, + getIdentityDkimAttributes: { + 'us-east-1': { + err: getErr, + data: { + DkimAttributes: dkimAttributes + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + ses: { + listIdentities: { + 'us-east-1': null, + } + } + }; +}; + + +describe('dkimEnabled', function () { + describe('run', function () { + it('should PASS if DKIM is enabled and configured properly', function (done) { + const cache = createCache(listIdentities, [getIdentityDkimAttributes[1]]); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if DKIM is not enabled', function (done) { + const cache = createCache(listIdentities, [getIdentityDkimAttributes[2]]); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should WARN if DKIM is enabled, but not configured properly', function (done) { + const cache = createCache(listIdentities, [getIdentityDkimAttributes[0]]); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + done(); + }); + }); + + it('should PASS if no SES identities found', function (done) { + const cache = createCache([]); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for SES identities', function (done) { + const cache = createCache([], {}, { message: 'error listing identities'}); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to get SES DKIM attributes', function (done) { + const cache = createCache(listIdentities, {}, null, { messgage: 'error getting SES DKIM attributes'}); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list SES identities response not found', function (done) { + const cache = createNullCache(); + dkimEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ses/emailMessagesEncrypted.js b/plugins/aws/ses/emailMessagesEncrypted.js new file mode 100644 index 000000000..183dfc219 --- /dev/null +++ b/plugins/aws/ses/emailMessagesEncrypted.js @@ -0,0 +1,138 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SES Email Messages Encrypted', + category: 'SES', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Amazon SES email messages are encrypted before delivering them to specified buckets.', + more_info: 'Amazon SES email messages should be encrypted in case they are being delivered to S3 bucket to meet regulatory compliance requirements within your organization.', + recommended_action: 'Enable encryption for SES email messages if they are being delivered to S3 in active rule-set .', + link: 'https://docs.aws.amazon.com/kms/latest/developerguide/services-ses.html', + apis: ['SES:describeActiveReceiptRuleSet', 'KMS:listKeys', 'KMS:describeKey', 'STS:getCallerIdentity'], + settings: { + ses_email_desired_encryption_level: { + name: 'SES Email Desired Encryption Level', + description: 'Desired encryption level for email messages to encrypt them before they get saves on S3', + regex: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + default: 'awskms' + } + }, + realtime_triggers: ['ses:CreateEmailIdentity','ses:SetActiveReceiptRuleSet','ses:DeleteEmailIdentity'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.ses_email_desired_encryption_level || this.settings.ses_email_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ses, function(region, rcb){ + var describeActiveReceiptRuleSet = helpers.addSource(cache, source, + ['ses', 'describeActiveReceiptRuleSet', region]); + + if (!describeActiveReceiptRuleSet) return rcb(); + + if (describeActiveReceiptRuleSet.err && + ((describeActiveReceiptRuleSet.err.message && + describeActiveReceiptRuleSet.err.message.includes('Unavailable Operation')) + || (describeActiveReceiptRuleSet.err.code + && describeActiveReceiptRuleSet.err.code.includes('InvalidAction')))) return rcb(); + + + if (describeActiveReceiptRuleSet.err || !describeActiveReceiptRuleSet.data) { + helpers.addResult(results, 3, + 'Unable to query for SES active rule set: ' + helpers.addError(describeActiveReceiptRuleSet), region); + return rcb(); + } + + if (!describeActiveReceiptRuleSet.data.Metadata) { + helpers.addResult(results, 0, 'No SES active rule set found', region); + return rcb(); + } + + let ruleSetName = describeActiveReceiptRuleSet.data.Metadata.Name; + let resource = `arn:${awsOrGov}:ses:${region}:${accountId}:receipt-rule-set/${ruleSetName}`; + + if (!describeActiveReceiptRuleSet.data.Rules || !describeActiveReceiptRuleSet.data.Rules.length) { + helpers.addResult(results, 0, 'SES active rule set does not have any rules', region, resource); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let rule of describeActiveReceiptRuleSet.data.Rules) { + if (!rule.Name) continue; + + let resource = `arn:${awsOrGov}:ses:${region}:${accountId}:receipt-rule-set/${ruleSetName}:receipt-rule/${rule.Name}`; + + if (!rule.Enabled) { + helpers.addResult(results, 0, 'SES active rule set rule is not enabled', region, resource); + continue; + } + + let s3Action = rule.Actions.find(action => action.S3Action); + if (!s3Action) helpers.addResult(results, 0, 'SES active rule set rule does not have action to deliver to S3', region, resource); + else { + if (s3Action.S3Action.KmsKeyArn) { + if (s3Action.S3Action.KmsKeyArn.includes('alias/aws/ses')) currentEncryptionLevel = 2; + else { + let kmsKeyId = s3Action.S3Action.KmsKeyArn.split('/')[1] ? s3Action.S3Action.KmsKeyArn.split('/')[1] : s3Action.S3Action.KmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, s3Action.KmsKeyArn); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `SES active rule set rule is using ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `SES active rule set rule is using ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'SES active rule set rule does not have encryption enabled for email messages being delivered to S3', + region, resource); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ses/emailMessagesEncrypted.spec.js b/plugins/aws/ses/emailMessagesEncrypted.spec.js new file mode 100644 index 000000000..354ecb661 --- /dev/null +++ b/plugins/aws/ses/emailMessagesEncrypted.spec.js @@ -0,0 +1,250 @@ +var expect = require('chai').expect; +var emailMessagesEncrypted = require('./emailMessagesEncrypted'); + +const describeActiveReceiptRuleSet = [ + { + "ResponseMetadata": { + "RequestId": "7c5c3ad1-cdbb-4e90-97d2-41c3f3e11e01" + }, + "Metadata": { + "Name": "khulnasoft-ruleset", + "CreatedTimestamp": "2021-11-12T14:56:59.226Z" + }, + "Rules": [ + { + "Name": "khulnasoft-rule", + "Enabled": true, + "TlsPolicy": "Optional", + "Recipients": [], + "Actions": [ + { + "S3Action": { + "BucketName": "khulnasoft-data-bucket", + "ObjectKeyPrefix": "sesdata", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } + } + ], + "ScanEnabled": true + } + ] + }, + { + "ResponseMetadata": { + "RequestId": "7c5c3ad1-cdbb-4e90-97d2-41c3f3e11e01" + }, + "Metadata": { + "Name": "khulnasoft-ruleset", + "CreatedTimestamp": "2021-11-12T14:56:59.226Z" + }, + "Rules": [ + { + "Name": "khulnasoft-rule", + "Enabled": true, + "TlsPolicy": "Optional", + "Recipients": [], + "Actions": [ + { + "S3Action": { + "BucketName": "khulnasoft-data-bucket", + "ObjectKeyPrefix": "sesdata", + } + } + ], + "ScanEnabled": true + } + ] + }, + { + "ResponseMetadata": { + "RequestId": "7c5c3ad1-cdbb-4e90-97d2-41c3f3e11e01" + }, + "Metadata": { + "Name": "khulnasoft-ruleset", + "CreatedTimestamp": "2021-11-12T14:56:59.226Z" + }, + "Rules": [ + { + "Name": "khulnasoft-rule", + "Enabled": false, + "TlsPolicy": "Optional", + "Recipients": [], + "Actions": [ + { + "S3Action": { + "BucketName": "khulnasoft-data-bucket", + "ObjectKeyPrefix": "sesdata", + "KmsKeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } + } + ], + "ScanEnabled": true + } + ] + }, + { + "ResponseMetadata": { + "RequestId": "7c5c3ad1-cdbb-4e90-97d2-41c3f3e11e01" + }, + }, + { + "ResponseMetadata": { + "RequestId": "7c5c3ad1-cdbb-4e90-97d2-41c3f3e11e01" + }, + "Metadata": { + "Name": "khulnasoft-ruleset", + "CreatedTimestamp": "2021-11-12T14:56:59.226Z" + }, + "Rules": [] + }, +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (ruleSet, keys, describeKey, ruleSetErr, keysErr, describeKeyErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + return { + ses: { + describeActiveReceiptRuleSet: { + 'us-east-1': { + data: ruleSet, + err: ruleSetErr + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('emailMessagesEncrypted', function () { + describe('run', function () { + it('should PASS if SES active rule set rule is using desired encryption level', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[0], listKeys, describeKey[0]); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SES active rule set rule is not using desired encryption level', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[1], listKeys, describeKey[1]); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if SES active rule set rule is not enabled', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[2], listKeys); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no SES active rule set found', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[3]); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if SES active rule set does not have any rules', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[4]); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for SES active rule set', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[0], null, null, { message: "unable to query for SES active rule set" }); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(describeActiveReceiptRuleSet[0], null, null, null, { message: "Unable to list KMS keys" }); + emailMessagesEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/shield/shieldAdvancedEnabled.js b/plugins/aws/shield/shieldAdvancedEnabled.js index 2d4f21f1c..340cb6a3a 100644 --- a/plugins/aws/shield/shieldAdvancedEnabled.js +++ b/plugins/aws/shield/shieldAdvancedEnabled.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Shield Advanced Enabled', category: 'Shield', + domain: 'Availability', + severity: 'Medium', description: 'Ensures AWS Shield Advanced is setup and properly configured', more_info: 'AWS Shield Advanced provides enhanced DDOS protection for all enrolled services within a subscribed account. Subscriptions should be active.', recommended_action: 'Enable AWS Shield Advanced for the account.', link: 'https://docs.aws.amazon.com/waf/latest/developerguide/ddos-overview.html#ddos-advanced', apis: ['Shield:describeSubscription'], + realtime_triggers: ['shield:CreateSubscription', 'sheild:UpdateSubscription', 'shield:DeleteSubscription'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/shield/shieldEmergencyContacts.js b/plugins/aws/shield/shieldEmergencyContacts.js index f8d940f28..a7f1416c1 100644 --- a/plugins/aws/shield/shieldEmergencyContacts.js +++ b/plugins/aws/shield/shieldEmergencyContacts.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Shield Emergency Contacts', category: 'Shield', + domain: 'Availability', + severity: 'Medium', description: 'Ensures AWS Shield emergency contacts are configured', more_info: 'AWS Shield Emergency contacts should be configured so that AWS can contact an account representative in the event of a DDOS event.', recommended_action: 'Configure emergency contacts within AWS Shield for the account.', link: 'https://docs.aws.amazon.com/waf/latest/developerguide/ddos-edit-drt.html', apis: ['Shield:describeEmergencyContactSettings'], + realtime_triggers: ['shield:CreateSubscription','shield:UpdateEmergencyContactSettings','shield:DeleteSubscription'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/shield/shieldProtections.js b/plugins/aws/shield/shieldProtections.js index 7c16898e5..a8be9b920 100644 --- a/plugins/aws/shield/shieldProtections.js +++ b/plugins/aws/shield/shieldProtections.js @@ -3,11 +3,14 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Shield Protections', category: 'Shield', + domain: 'Availability', + severity: 'Medium', description: 'Ensures AWS Shield Advanced is configured to protect account resources', more_info: 'Once AWS Shield Advanced is enabled, it can be applied to resources within the account including ELBs, CloudFront.', recommended_action: 'Enable AWS Shield Advanced on resources within the account.', link: 'https://docs.aws.amazon.com/waf/latest/developerguide/configure-new-protection.html', apis: ['Shield:listProtections'], + realtime_triggers: ['shield:CreateProtection', 'sheild:DeleteProtection'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/sns/snsCrossAccount.js b/plugins/aws/sns/snsCrossAccount.js new file mode 100644 index 000000000..a183bb6ef --- /dev/null +++ b/plugins/aws/sns/snsCrossAccount.js @@ -0,0 +1,178 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Cross Account Access', + category: 'SNS', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures SNS policies disallow cross-account access', + more_info: 'SNS topic policies should be carefully restricted to to subscribe or send messages. Topic policies can be used to limit these privileges.', + recommended_action: 'Update the SNS policy to prevent access from external accounts.', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-using-identity-based-policies.html', + apis: ['SNS:listTopics', 'SNS:getTopicAttributes', 'STS:getCallerIdentity', 'Organizations:listAccounts'], + settings: { + sns_whitelisted_aws_account_principals: { + name: 'Whitelisted AWS Account Principals', + description: 'A comma-separated list of trusted cross account principals', + regex: '^.*$', + default: '' + }, + sns_whitelist_aws_organization_accounts: { + name: 'Whitelist All AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + }, + sns_topic_policy_condition_keys: { + name: 'SNS Topic Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount,aws:PrincipalArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner,sns:Endpoint' + }, + }, + realtime_triggers: ['sns:CreateTopic', 'sns:SetTopicAttributes', 'sns:DeleteTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + sns_whitelisted_aws_account_principals : settings.sns_whitelisted_aws_account_principals || this.settings.sns_whitelisted_aws_account_principals.default, + sns_whitelist_aws_organization_accounts: settings.sns_whitelist_aws_organization_accounts || this.settings.sns_whitelist_aws_organization_accounts.default, + sns_topic_policy_condition_keys: settings.sns_topic_policy_condition_keys || this.settings.sns_topic_policy_condition_keys.default, + }; + var allowedConditionKeys = config.sns_topic_policy_condition_keys.split(','); + var whitelistOrganization = (config.sns_whitelist_aws_organization_accounts == 'true'); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', acctRegion]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, acctRegion); + return callback(null, results, source); + } + + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + + async.each(regions.sns, function(region, rcb){ + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + 'Unable to query for SNS topics: ' + helpers.addError(listTopics), region); + return rcb(); + } + + if (!listTopics.data.length) { + helpers.addResult(results, 0, 'No SNS topics found', region); + return rcb(); + } + + async.each(listTopics.data, function(topic, cb){ + if (!topic.TopicArn) return cb(); + + var getTopicAttributes = helpers.addSource(cache, source, + ['sns', 'getTopicAttributes', region, topic.TopicArn]); + + if (!getTopicAttributes ||getTopicAttributes.err || !getTopicAttributes.data) { + helpers.addResult(results, 3, + 'Unable to query SNS topic for attributes: ' + helpers.addError(getTopicAttributes), + region, topic.TopicArn); + return cb(); + } + + if (!getTopicAttributes.data.Attributes || + !getTopicAttributes.data.Attributes.Policy) { + helpers.addResult(results, 0, + 'The SNS topic does not have a policy attached.', + region, topic.TopicArn); + return cb(); + } + + try { + var policy = JSON.parse(getTopicAttributes.data.Attributes.Policy); + } catch (e) { + helpers.addResult(results, 3, + 'The SNS topic policy is not valid JSON.', + region, topic.TopicArn); + + return cb(); + } + + var crossAccountActions = []; + + var statements = helpers.normalizePolicyDocument(policy); + + for (var statement of statements) { + if (!statement.Effect || statement.Effect !== 'Allow') continue; + if (!statement.Principal) continue; + + let conditionalPrincipals = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, true, accountId, settings); + if (helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings) || + (conditionalPrincipals && conditionalPrincipals.length)) { + let crossAccountPrincipals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + + if (conditionalPrincipals && conditionalPrincipals.length) { + conditionalPrincipals.forEach(conPrincipal => { + if (!conPrincipal.includes(accountId)) crossAccountPrincipals.push(conPrincipal); + }); + } + + if (!crossAccountPrincipals.length) continue; + + let crossAccount = false; + let orgAccount; + + for (let principal of crossAccountPrincipals) { + if (config.sns_whitelisted_aws_account_principals.includes(principal)) continue; + + if (whitelistOrganization) { + orgAccount = organizationAccounts.find(account => principal.includes(account)); + if (orgAccount) continue; + } + crossAccount = true; + break; + } + + if (crossAccount) { + for (let a in statement.Action) { + if (crossAccountActions.indexOf(statement.Action[a]) === -1) { + crossAccountActions.push(statement.Action[a]); + } + } + } + } + } + + if (crossAccountActions.length) { + helpers.addResult(results, 2, + 'SNS topic policy allows cross-account access to the action(s): ' + crossAccountActions, + region, topic.TopicArn); + } else { + helpers.addResult(results, 0, + 'SNS topic policy does not allow cross-account access', + region, topic.TopicArn); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/sns/snsCrossAccount.spec.js b/plugins/aws/sns/snsCrossAccount.spec.js new file mode 100644 index 000000000..785755f00 --- /dev/null +++ b/plugins/aws/sns/snsCrossAccount.spec.js @@ -0,0 +1,233 @@ +const expect = require('chai').expect; +const snsCrossAccount = require('./snsCrossAccount'); + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' } +]; + +const getTopicAttributes = [ + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"arn:aws:iam:112233445566"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'alias/aws/sns', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-topic', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-138-cmk","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'arn:aws:kms:us-east-1:111122223333:key/b8789907-b7f7-438d-847e-7d468bac86b2', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-138-cmk', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__default_statement_ID\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":[\"SNS:GetTopicAttributes\",\"SNS:SetTopicAttributes\",\"SNS:AddPermission\",\"SNS:RemovePermission\",\"SNS:DeleteTopic\",\"SNS:Subscribe\",\"SNS:ListSubscriptionsByTopic\",\"SNS:Publish\",\"SNS:Receive\"],\"Resource\":\"arn:aws:sns:us-east-1:111222333444:test-spec\",\"Condition\":{}}]}", + "Owner": "111122223333", + "SubscriptionsPending": "0", + "TopicArn": "arn:aws:sns:us-east-1:111122223333:test-spec", + "EffectiveDeliveryPolicy": "{\"http\":{\"defaultHealthyRetryPolicy\":{\"minDelayTarget\":20,\"maxDelayTarget\":20,\"numRetries\":3,\"numMaxDelayRetries\":0,\"numNoDelayRetries\":0,\"numMinDelayRetries\":0,\"backoffFunction\":\"linear\"},\"disableSubscriptionOverrides\":false}}", + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'alias/aws/sns', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-topic', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + } +]; + +const createCache = (listTopics, getTopicAttributes) => { + var topicArn = (listTopics && listTopics.length) ? listTopics[0].TopicArn : null; + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [topicArn]: { + data: getTopicAttributes + }, + }, + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111222333444' + } + } + }, + organizations: { + listAccounts: { + 'us-east-1': { + data: [ + { + "Id": "111222333444", + "Arn": "arn:aws:organizations::111222333444:account/o-sb9qmv2zif/111222333444", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + }, + { + "Id": "111122223333", + "Arn": "arn:aws:organizations::111122223333:account/o-sb9qmv2zif/111122223333", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + } + ] + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + getTopicAttributes: { + 'us-east-1': { + err: { + message: 'error while getting topic attributes' + }, + } + } + }, + }; +}; + +const createNullCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': null + } + } + }; +}; + +describe('snsCrossAccount', function () { + describe('run', function () { + it('should FAIL if SNS topic policy allows cross-account access', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[1]); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if cross-account is whitelisted', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[1]); + snsCrossAccount.run(cache, { sns_whitelisted_aws_account_principals: '111122223333' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SNS topic policy allows cross-account access to organization account and setting is set to true', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[1]); + snsCrossAccount.run(cache, { sns_whitelist_aws_organization_accounts: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SNS topic policy does not allow cross-account access', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[2]); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SNS topic does not use a policy', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[3]); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list SNS topics', function (done) { + const cache = createErrorCache(); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query attributes for SNS topic', function (done) { + const cache = createCache([listTopics[0]]); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list SNS topics response not found', function (done) { + const cache = createNullCache(); + snsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sns/snsSubscriptionHTTPSonly.js b/plugins/aws/sns/snsSubscriptionHTTPSonly.js new file mode 100644 index 000000000..718b40dd6 --- /dev/null +++ b/plugins/aws/sns/snsSubscriptionHTTPSonly.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Subscription HTTPS Only', + category: 'SNS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Amazon SNS subscriptions are configured to use HTTPS protocol', + more_info: 'Amazon Simple Notification Service (Amazon SNS) is a managed service that provides message delivery from publishers to subscribers. It is important to verify that SNS subscriptions are configured to use the HTTPS protocol.', + recommended_action: 'Create a new SNS subscription using HTTPS protocol.', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-http-https-endpoint-as-subscriber.html', + apis: ['SNS:listSubscriptions'], + realtime_triggers: ['sns:Subscribe', 'sns:Unsubscribe'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sns, function(region, rcb) { + var listSubscriptions = helpers.addSource(cache, source, + ['sns', 'listSubscriptions', region]); + + if (!listSubscriptions) return rcb(); + + if (listSubscriptions.err) { + helpers.addResult(results, 3, + 'Unable to query for SNS subscriptions: ' + + helpers.addError(listSubscriptions), region); + return rcb(); + } + + if (!listSubscriptions.data || !listSubscriptions.data.length) { + helpers.addResult(results, 0, 'No SNS subscriptions found', region); + return rcb(); + } + + for (var subscription of listSubscriptions.data) { + if (!subscription.SubscriptionArn || !subscription.Protocol) continue; + + if (subscription.Protocol.toLowerCase() != 'https'){ + helpers.addResult(results, 2, 'SNS subscription is not using HTTPS protocol', + region, subscription.SubscriptionArn); + } else { + helpers.addResult(results, 0, 'SNS subscription is using HTTPS protocol', + region, subscription.SubscriptionArn); + } + } + + rcb(); + },function(){ + callback(null, results, source); + }); + + } +}; + + diff --git a/plugins/aws/sns/snsSubscriptionHTTPSonly.spec.js b/plugins/aws/sns/snsSubscriptionHTTPSonly.spec.js new file mode 100644 index 000000000..864f3267d --- /dev/null +++ b/plugins/aws/sns/snsSubscriptionHTTPSonly.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +const snsSubscriptionHTTPSonly = require('./snsSubscriptionHTTPSonly'); + +const listSubscriptions = [ + { + "SubscriptionArn": "arn:aws:sns:us-east-1:000011112222:Default_CloudWatch_Alarms_Topic:18c0d0b0-f39e-4ac8-8e08-88f239768f61", + "Owner": "000011112222", + "Protocol": "https", + "Endpoint": "xyz@khulnasoft.com", + "TopicArn": "arn:aws:sns:us-east-1:000011112222:Default_CloudWatch_Alarms_Topic" + }, + { + "SubscriptionArn": "arn:aws:sns:us-east-1:000011112222:khulnasoft-cspm-sns-000011112222:1a1450c0-03bd-4feb-a99f-bba3ca0540e9", + "Owner": "000011112222", + "Protocol": "http", + "Endpoint": "xxz@khulnasoft.com", + "TopicArn": "arn:aws:sns:us-east-1:000011112222:khulnasoft-cspm-sns-000011112222" + } +]; + +const createCache = (instances) => { + return { + sns: { + listSubscriptions: { + 'us-east-1': { + data: instances + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sns: { + listSubscriptions: { + 'us-east-1': { + err: { + message: 'Error listing subscriptions' + } + } + } + } + }; +}; + + + +describe('snsSubscriptionHTTPprotocol', function () { + describe('run', function () { + it('should PASS if SNS subscription is configure to use http protocol', function (done) { + const cache = createCache([listSubscriptions[0]]); + snsSubscriptionHTTPSonly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SNS subscription is using HTTPS protocol'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SNS subscription is not configure to use http protocol', function (done) { + const cache = createCache([listSubscriptions[1]]); + snsSubscriptionHTTPSonly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SNS subscription is not using HTTPS protocol'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no SNS subscriptions found', function (done) { + const cache = createCache([]); + snsSubscriptionHTTPSonly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SNS subscriptions found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for SNS subscriptions', function (done) { + const cache = createErrorCache(); + snsSubscriptionHTTPSonly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SNS subscriptions'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/sns/snsTopicHasTags.js b/plugins/aws/sns/snsTopicHasTags.js new file mode 100644 index 000000000..7196b90a8 --- /dev/null +++ b/plugins/aws/sns/snsTopicHasTags.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Topic Has Tags', + category: 'SNS', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that Amazon SNS topics have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify SNS topic and add tags.', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-tags.html', + apis: ['SNS:listTopics', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['sns:CreateTopic', 'sns:TagResource', 'sns:UntagResource','sns:DeleteTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sns, function(region, rcb){ + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + 'Unable to query for SNS topics: ' + helpers.addError(listTopics), region); + return rcb(); + } + + if (!listTopics.data.length) { + helpers.addResult(results, 0, 'No SNS topics found', region); + return rcb(); + } + + const topicARN = []; + for (let topic of listTopics.data){ + if (!topic.TopicArn) continue; + topicARN.push(topic.TopicArn); + } + helpers.checkTags(cache, 'SNS topic', topicARN, region, results, settings); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sns/snsTopicHasTags.spec.js b/plugins/aws/sns/snsTopicHasTags.spec.js new file mode 100644 index 000000000..013c69ee0 --- /dev/null +++ b/plugins/aws/sns/snsTopicHasTags.spec.js @@ -0,0 +1,126 @@ +var expect = require('chai').expect; +const snsTopicHasTags = require('./snsTopicHasTags'); + + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137' }, +]; + +const resourcegroupstaggingapi = [ + { + "ResourceARN": "arn:aws:sns:us-east-1:111122223333:test-topic-137", + "Tags": [{key:"key1", value:"value"}], + }, + { + "ResourceARN": "arn:aws:sns:us-east-1:111122223333:test-topic-137", + "Tags": [], + } + +]; + +const createCache = (listTopics, rgData) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + }, + }; +}; + +const createTopicAttributesErrorCache = (listTopics) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + }, + }; +}; + + +describe('snsTopicHasTags', function () { + describe('run', function () { + it('should PASS if SNS topic has tags', function (done) { + const cache = createCache([listTopics[0]], [resourcegroupstaggingapi[0]]); + snsTopicHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('SNS topic has tags'); + done(); + }); + }); + + it('should FAIL if SNS topic does not have tags', function (done) { + const cache = createCache([listTopics[0]], [resourcegroupstaggingapi[1]]); + snsTopicHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('SNS topic does not have any tags') + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + snsTopicHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No SNS topics found') + done(); + }); + }); + + it('should UNKNOWN if error while listing SNS topics', function (done) { + const cache = createCache(null, null); + snsTopicHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for SNS topics') + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache([listTopics[0]], null); + snsTopicHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources') + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/sns/snsTopicNoHttpPolicy.js b/plugins/aws/sns/snsTopicNoHttpPolicy.js new file mode 100644 index 000000000..27d6189b6 --- /dev/null +++ b/plugins/aws/sns/snsTopicNoHttpPolicy.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Topic HTTP Protocol Restriction', + category: 'SNS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures SNS topics do not allow HTTP protocol.', + more_info: 'SNS topics should be configured to restrict access to the HTTP protocol to prevent unauthorized send or subscribe operations.', + recommended_action: 'Adjust the topic policy to only allow authorized AWS users in known accounts to send or subscribe via the HTTP protocol.', + link: 'http://docs.aws.amazon.com/sns/latest/dg/AccessPolicyLanguage.html', + apis: ['SNS:listTopics', 'SNS:getTopicAttributes'], + realtime_triggers: ['sns:CreateTopic', 'sns:SetTopicAttributes','sns:DeleteTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sns, function(region, rcb){ + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + 'Unable to query for SNS topics: ' + helpers.addError(listTopics), region); + return rcb(); + } + + if (!listTopics.data.length) { + helpers.addResult(results, 0, 'No SNS topics found', region); + return rcb(); + } + + listTopics.data.forEach( topic => { + if (!topic.TopicArn) return; + + var getTopicAttributes = helpers.addSource(cache, source, + ['sns', 'getTopicAttributes', region, topic.TopicArn]); + + if (!getTopicAttributes || + (!getTopicAttributes.err && !getTopicAttributes.data)) return; + + if (getTopicAttributes.err || !getTopicAttributes.data) { + helpers.addResult(results, 3, + 'Unable to query SNS topic for policy: ' + helpers.addError(getTopicAttributes), + region, topic.TopicArn); + return; + } + + if (!getTopicAttributes.data.Attributes || + !getTopicAttributes.data.Attributes.Policy) { + helpers.addResult(results, 3, + 'The SNS topic does not have a policy attached.', + region, topic.TopicArn); + return; + } + + var statements = helpers.normalizePolicyDocument(getTopicAttributes.data.Attributes.Policy); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'The SNS Topic policy does not have trust relationship statements', + region, topic.TopicArn); + return; + } + + var hasHttpProtocolRestriction = false; + + function checkProtocol(protocol) { + protocol = protocol.toLowerCase(); + if ((effect === 'Allow' && protocol === 'http') || (effect === 'Deny' && protocol === 'https')) { + return true; + } + } + for (var statement of statements) { + if (statement.Condition && statement.Condition.StringEquals) { + var protocolCondition = statement.Condition.StringEquals['SNS:Protocol']; + if (protocolCondition && protocolCondition.length) { + var effect = statement.Effect; + if (typeof protocolCondition === 'string') { + hasHttpProtocolRestriction = checkProtocol(protocolCondition); + + } else if (Array.isArray(protocolCondition) && protocolCondition.length) { + for (var protocol of protocolCondition) { + if (checkProtocol(protocol)) { + hasHttpProtocolRestriction = true; + break; + } + } + } + } + if (hasHttpProtocolRestriction) break; + } + } + + if (hasHttpProtocolRestriction) { + helpers.addResult(results, 2, + 'The SNS topic policy allows unsecured access via HTTP protocol.', + region, topic.TopicArn); + } else { + helpers.addResult(results, 0, + 'The SNS topic policy does not allow unsecured access via HTTP protocol.', + region, topic.TopicArn); + } + + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sns/snsTopicNoHttpPolicy.spec.js b/plugins/aws/sns/snsTopicNoHttpPolicy.spec.js new file mode 100644 index 000000000..549c5a4c9 --- /dev/null +++ b/plugins/aws/sns/snsTopicNoHttpPolicy.spec.js @@ -0,0 +1,206 @@ +var expect = require('chai').expect; +var snsTopicnoHttpPolicy = require('./snsTopicNoHttpPolicy'); + + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' } +]; + +const getTopicAttributes = [ + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"arn:aws:iam:112233445566"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'alias/aws/sns', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-topic', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"arn:aws:iam:112233445566"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic","Condition":{"StringEquals":{"AWS:SourceOwner":"123456789012"}}},{"Sid":"__console_sub_0","Effect":"Deny","Principal":{"AWS":"*"},"Action":["SNS:Subscribe","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1: ... ","Condition":{"StringEquals":{"SNS:Protocol":"https"}}}]}', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'arn:aws:kms:us-east-1:111122223333:key/b8789907-b7f7-438d-847e-7d468bac86b2', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-138-cmk', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"arn:aws:iam:112233445566"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic","Condition":{"StringEquals":{"AWS:SourceOwner":"123456789012"}}},{"Sid":"__console_sub_0","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:Subscribe","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1: ... ","Condition":{"StringEquals":{"SNS:Protocol":"http"}}}]}', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'arn:aws:kms:us-east-1:111122223333:key/b8789907-b7f7-438d-847e-7d468bac86b2', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-138-cmk', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + } +]; + +const createCache = (listTopics, getTopicAttributes) => { + var topicArn = (listTopics && listTopics.length) ? listTopics[0].TopicArn : null; + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [topicArn]: { + data: getTopicAttributes + }, + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + getTopicAttributes: { + 'us-east-1': { + err: { + message: 'error while getting topic attributes' + }, + } + } + }, + }; +}; + +const createTopicAttributesErrorCache = (listTopics) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [listTopics[0].TopicArn]: { + err: { + message: 'error while getting topic attributes' + }, + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': null, + }, + getTopicAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('snsTopicnoHttpPolicy', function () { + describe('run', function () { + it('should PASS if SNS topic policy does not allow http protocol', function (done) { + const cache = createCache([listTopics[0]], getTopicAttributes[0]); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('The SNS topic policy does not allow unsecured access via HTTP protocol.'); + done(); + }); + }); + + it('should FAIL if SNS topic policy deny https protocol', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[1]); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('The SNS topic policy allows unsecured access via HTTP protocol.'); + done(); + }); + }); + + it('should FAIL if SNS topic policy allow http protocol', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[2]); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('The SNS topic policy allows unsecured access via HTTP protocol.'); + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No SNS topics found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list SNS topics', function (done) { + const cache = createErrorCache(); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for SNS topics: '); + done(); + }); + }); + + it('should UNKNOWN if unable to get SNS topic attributes', function (done) { + const cache = createTopicAttributesErrorCache([listTopics[0]]); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query SNS topic for policy: '); + done(); + }); + }); + + it('should not return anything if list SNS topics response is not found', function (done) { + const cache = createNullCache(); + snsTopicnoHttpPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/sns/snsValidSubscribers.js b/plugins/aws/sns/snsValidSubscribers.js new file mode 100644 index 000000000..e41c6bc6f --- /dev/null +++ b/plugins/aws/sns/snsValidSubscribers.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Valid Subscribers', + category: 'SNS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Amazon SNS subscriptions are valid and there are no unwanted subscribers.', + more_info: 'Amazon Simple Notification Service (Amazon SNS) is a managed service that provides message delivery from publishers to subscribers. So check for appropriate subscribers in order to improve access security to your SNS topics. ', + recommended_action: 'Check for unwanted SNS subscriptions periodically', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-create-subscribe-endpoint-to-topic.html', + apis: ['SNS:listSubscriptions'], + settings: { + sns_unwanted_subscribers: { + name: 'SNS Unwanted Subscribers', + description: 'Comma-separated list of subscription endpoint i.e. xyz@khulnasoft.com', + regex: '^.*$', + default: '', + } + }, + realtime_triggers: ['sns:Subscribe', 'sns:Unsubscribe'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + sns_unwanted_subscribers: settings.sns_unwanted_subscribers || this.settings.sns_unwanted_subscribers.default + }; + + config.sns_unwanted_subscribers = config.sns_unwanted_subscribers.replace(/\s+/g, ''); + + if (!config.sns_unwanted_subscribers.length) return callback(null, results, source); + + config.sns_unwanted_subscribers = config.sns_unwanted_subscribers.toLowerCase(); + + async.each(regions.sns, function(region, rcb){ + var listSubscriptions = helpers.addSource(cache, source, + ['sns', 'listSubscriptions', region]); + + if (!listSubscriptions) return rcb(); + + if (listSubscriptions.err) { + helpers.addResult(results, 3, + 'Unable to query for SNS subscriptions: ' + + helpers.addError(listSubscriptions), region); + return rcb(); + } + + if (!listSubscriptions.data || !listSubscriptions.data.length) { + helpers.addResult( + results, 0, 'No SNS subscriptions Found', region); + return rcb(); + } + + for (let subscriber of listSubscriptions.data) { + if (!subscriber.SubscriptionArn) continue; + + let resource = subscriber.SubscriptionArn; + + if (subscriber.Endpoint && config.sns_unwanted_subscribers.includes(subscriber.Endpoint.toLowerCase())){ + helpers.addResult(results, 2, + 'SNS subscription is an unwanted subscription', region, resource); + } else { + helpers.addResult(results, 0, + 'SNS subscription is a wanted subscription', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sns/snsValidSubscribers.spec.js b/plugins/aws/sns/snsValidSubscribers.spec.js new file mode 100644 index 000000000..01b865f48 --- /dev/null +++ b/plugins/aws/sns/snsValidSubscribers.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +const snsValidSubscribers = require('./snsValidSubscribers'); + +const listSubscriptions = [ + { + "SubscriptionArn": "arn:aws:sns:us-east-1:000011112222:Default_CloudWatch_Alarms_Topic:18c0d0b0-f39e-4ac8-8e08-88f239768f61", + "Owner": "000011112222", + "Protocol": "email", + "Endpoint": "xyz@khulnasoft.com", + "TopicArn": "arn:aws:sns:us-east-1:000011112222:Default_CloudWatch_Alarms_Topic" + }, + { + "SubscriptionArn": "arn:aws:sns:us-east-1:000011112222:khulnasoft-cspm-sns-000011112222:1a1450c0-03bd-4feb-a99f-bba3ca0540e9", + "Owner": "000011112222", + "Protocol": "email", + "Endpoint": "xxz@khulnasoft.com", + "TopicArn": "arn:aws:sns:us-east-1:000011112222:khulnasoft-cspm-sns-000011112222" + } +]; + +const createCache = (instances) => { + return { + sns: { + listSubscriptions: { + 'us-east-1': { + data: instances + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sns: { + listSubscriptions: { + 'us-east-1': { + err: { + message: 'Error listing subscriptions' + } + } + } + } + }; +}; + + + +describe('snsValidSubscribers', function () { + describe('run', function () { + it('should PASS if SNS subscription is wanted', function (done) { + const cache = createCache([listSubscriptions[1]]); + snsValidSubscribers.run(cache, { sns_unwanted_subscribers: 'xyz@khulnasoft.com' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SNS subscription is a wanted subscription'); + done(); + }); + }); + + it('should FAIL if SNS subscription is unwanted', function (done) { + const cache = createCache([listSubscriptions[0]]); + snsValidSubscribers.run(cache, { sns_unwanted_subscribers: 'xyz@khulnasoft.com' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SNS subscription is an unwanted subscription'); + done(); + }); + }); + + it('should PASS if no SNS subscriptions found', function (done) { + const cache = createCache([]); + snsValidSubscribers.run(cache, { sns_unwanted_subscribers: 'xyz@khulnasoft.com' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SNS subscriptions Found'); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for SNS subscriptions', function (done) { + const cache = createErrorCache(); + snsValidSubscribers.run(cache, { sns_unwanted_subscribers: 'xyz@khulnasoft.com' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SNS subscriptions'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sns/topicCmkEncrypted.js b/plugins/aws/sns/topicCmkEncrypted.js new file mode 100644 index 000000000..c0f2b0fee --- /dev/null +++ b/plugins/aws/sns/topicCmkEncrypted.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Topic CMK Encryption', + category: 'SNS', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures Amazon SNS topics are encrypted with KMS Customer Master Keys (CMKs).', + more_info: 'AWS SNS topics should be encrypted with KMS Customer Master Keys (CMKs) instead of AWS managed-keys' + + 'in order to have a more granular control over the SNS data-at-rest encryption and decryption process.', + recommended_action: 'Update SNS topics to use Customer Master Keys (CMKs) for Server-Side Encryption.', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html', + apis: ['SNS:listTopics', 'SNS:getTopicAttributes'], + realtime_triggers: ['sns:CreateTopic', 'sns:SetTopicAttributes','sns:DeleteTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sns, function(region, rcb){ + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + 'Unable to query for SNS topics: ' + helpers.addError(listTopics), region); + return rcb(); + } + + if (!listTopics.data.length) { + helpers.addResult(results, 0, 'No SNS topics found', region); + return rcb(); + } + + async.each(listTopics.data, function(topic, cb){ + if (!topic.TopicArn) return cb(); + + var resource = topic.TopicArn; + + var getTopicAttributes = helpers.addSource(cache, source, + ['sns', 'getTopicAttributes', region, resource]); + + if (!getTopicAttributes || getTopicAttributes.err || !getTopicAttributes.data) { + helpers.addResult(results, 3, + 'Unable to query SNS topic attributes: ' + helpers.addError(getTopicAttributes), + region, resource); + + return cb(); + } + + if (getTopicAttributes.data.Attributes && + getTopicAttributes.data.Attributes.KmsMasterKeyId) { + var kmsMasterKeyId = getTopicAttributes.data.Attributes.KmsMasterKeyId; + if (kmsMasterKeyId === 'alias/aws/sns'){ + helpers.addResult(results, 2, + 'SNS topic is using default KMS key for Server-Side Encryption', + region, resource); + } else { + helpers.addResult(results, 0, + 'SNS topic is using CMK key for Server-Side Encryption', + region, resource); + } + } else { + helpers.addResult(results, 2, + 'Server-Side Encryption is not enabled for SNS topic', + region, resource); + } + + cb(); + + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sns/topicCmkEncrypted.spec.js b/plugins/aws/sns/topicCmkEncrypted.spec.js new file mode 100644 index 000000000..e0836c34a --- /dev/null +++ b/plugins/aws/sns/topicCmkEncrypted.spec.js @@ -0,0 +1,194 @@ +var expect = require('chai').expect; +const topicCmkEncrypted = require('./topicCmkEncrypted'); + + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137-2' } +]; + +const getTopicAttributes = [ + { + ResponseMetadata: { RequestId: '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }, + Attributes: { + Policy: '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic-137","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + Owner: '111122223333', + SubscriptionsPending: '0', + KmsMasterKeyId: 'alias/aws/sns', + TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + SubscriptionsConfirmed: '0', + DisplayName: '', + SubscriptionsDeleted: '0' + } + }, + { + ResponseMetadata: { RequestId: '3b1eb829-6c77-5b75-a179-efffa71ad118' }, + Attributes: { + Policy: '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-138-cmk","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + Owner: '111122223333', + SubscriptionsPending: '0', + KmsMasterKeyId: 'arn:aws:kms:us-east-1:111122223333:key/b8789907-b7f7-438d-847e-7d468bac86b2', + TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-138-cmk', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + SubscriptionsConfirmed: '0', + DisplayName: '', + SubscriptionsDeleted: '0' + } + }, + { + ResponseMetadata: { RequestId: '20222ee7-0216-5ec4-ad6e-79324cef93b7' }, + Attributes: { + Policy: '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic-137-2","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + Owner: '111122223333', + SubscriptionsPending: '0', + TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137-2', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + SubscriptionsConfirmed: '0', + DisplayName: '', + SubscriptionsDeleted: '0' + } + } +]; + +const createCache = (listTopics, getTopicAttributes) => { + var topicArn = (listTopics && listTopics.length) ? listTopics[0].TopicArn : null; + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [topicArn]: { + data: getTopicAttributes + }, + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + getTopicAttributes: { + 'us-east-1': { + err: { + message: 'error while getting topic attributes' + }, + } + } + }, + }; +}; + +const createTopicAttributesErrorCache = (listTopics) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [listTopics[0].TopicArn]: { + err: { + message: 'error while getting topic attributes' + }, + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': null, + }, + getTopicAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('topicCmkEncrypted', function () { + describe('run', function () { + it('should PASS if SNS topic is using CMK key for Server-Side Encryption', function (done) { + const cache = createCache([listTopics[0]], getTopicAttributes[1]); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if SNS topic is using default KMS key for Server-Side Encryption', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[0]); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Server-Side Encryption is not enabled for SNS topic', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[2]); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while listing SNS topics', function (done) { + const cache = createErrorCache(); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if error while getting SNS topic attributes', function (done) { + const cache = createTopicAttributesErrorCache([listTopics[0]]); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if unable to list SNS topics', function (done) { + const cache = createNullCache(); + topicCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sns/topicEncrypted.js b/plugins/aws/sns/topicEncrypted.js new file mode 100644 index 000000000..408f60a6f --- /dev/null +++ b/plugins/aws/sns/topicEncrypted.js @@ -0,0 +1,175 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SNS Topic Encrypted', + category: 'SNS', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that Amazon SNS topics enforce Server-Side Encryption (SSE)', + more_info: 'SNS topics should enforce Server-Side Encryption (SSE) to secure data at rest. SSE protects the contents of messages in Amazon SNS topics using keys managed in AWS Key Management Service (AWS KMS).', + recommended_action: 'Enable Server-Side Encryption to protect the content of SNS topic messages.', + link: 'https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html', + apis: ['SNS:listTopics', 'SNS:getTopicAttributes', 'KMS:listKeys', 'KMS:describeKey'], + remediation_description: 'Server-Side Encryption to protect the content of SNS topic messages will be enabled.', + remediation_min_version: '202011182332', + apis_remediate: ['SNS:listTopics', 'SNS:getTopicAttributes', 'KMS:listKeys', 'KMS:describeKey'], + remediation_inputs: { + kmsKeyIdforSns: { + name: '(Optional) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + actions: { + remediate: ['SNS:setTopicAttributes'], + rollback: ['SNS:setTopicAttributes'] + }, + permissions: { + remediate: ['sns:SetTopicAttributes'], + rollback: ['sns:SetTopicAttributes'] + }, + realtime_triggers: ['sns:CreateTopic', 'sns:SetTopicAttributes', 'sns:DeleteTopic'], + asl: { + conditions: [ + { + service: 'sns', + api: 'getTopicAttributes', + property: 'Attributes.KmsMasterKeyId', + transform: 'STRING', + op: 'NE', + value: null + } + ] + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.sns, function(region, rcb){ + var listTopics = helpers.addSource(cache, source, + ['sns', 'listTopics', region]); + + if (!listTopics) return rcb(); + + if (listTopics.err || !listTopics.data) { + helpers.addResult(results, 3, + 'Unable to query for SNS topics: ' + helpers.addError(listTopics), region); + return rcb(); + } + + if (!listTopics.data.length) { + helpers.addResult(results, 0, 'No SNS topics found', region); + return rcb(); + } + + async.each(listTopics.data, function(topic, cb){ + if (!topic.TopicArn) return cb(); + + var resource = topic.TopicArn; + var accountId = resource.split(':')[4]; + var cloudexploitSNS = helpers.CLOUDEXPLOIT_EVENTS_SNS + accountId; + + if ( resource.indexOf(cloudexploitSNS) > -1){ + helpers.addResult(results, 0, + 'This SNS topic is auto-allowed as part of a cross-account notification topic used by the real-time events service', + region, resource); + return cb(); + } + var getTopicAttributes = helpers.addSource(cache, source, + ['sns', 'getTopicAttributes', region, resource]); + + if (!getTopicAttributes || getTopicAttributes.err || !getTopicAttributes.data) { + helpers.addResult(results, 3, + 'Unable to query SNS topic attributes: ' + helpers.addError(getTopicAttributes), + region, resource); + + return cb(); + } + + if (getTopicAttributes.data.Attributes && + getTopicAttributes.data.Attributes.KmsMasterKeyId) { + helpers.addResult(results, 0, + 'Server-Side Encryption is enabled for SNS topic', + region, resource); + } else { + helpers.addResult(results, 2, + 'Server-Side Encryption is not enabled for SNS topic', + region, resource); + } + + cb(); + + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'topicEncrypted'; + var topicNameArr = resource.split(':'); + var topicName = topicNameArr[topicNameArr.length - 1]; + var defaultKeyDesc = 'Default master key that protects my SNS data when no other key is defined'; + // find the location of the topic needing to be remediate + var topicLocation = topicNameArr[3]; + // add the location of the topic to the config + config.region = topicLocation; + var params = {}; + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforSns) { + params = { + AttributeName: 'KmsMasterKeyId', + TopicArn: resource, + AttributeValue: settings.input.kmsKeyIdforSns + }; + } else { + var defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default SNS key for the region ${config.region}`); + params = { + AttributeName: 'KmsMasterKeyId', + TopicArn: resource, + AttributeValue: defaultKmsKeyId + }; + } + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'Topic': topicName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'Topic': topicName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + + rollback: function(config, cache, settings, resource, callback) { + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); + } +}; \ No newline at end of file diff --git a/plugins/aws/sns/topicEncrypted.spec.js b/plugins/aws/sns/topicEncrypted.spec.js new file mode 100644 index 000000000..edea31f1f --- /dev/null +++ b/plugins/aws/sns/topicEncrypted.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +const topicEncrypted = require('./topicEncrypted'); + + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137-2' } +]; + +const getTopicAttributes = [ + { + ResponseMetadata: { RequestId: '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }, + Attributes: { + Policy: '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic-137","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + Owner: '111122223333', + SubscriptionsPending: '0', + KmsMasterKeyId: 'alias/aws/sns', + TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + SubscriptionsConfirmed: '0', + DisplayName: '', + SubscriptionsDeleted: '0' + } + }, + { + ResponseMetadata: { RequestId: '20222ee7-0216-5ec4-ad6e-79324cef93b7' }, + Attributes: { + Policy: '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic-137-2","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + Owner: '111122223333', + SubscriptionsPending: '0', + TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-137-2', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + SubscriptionsConfirmed: '0', + DisplayName: '', + SubscriptionsDeleted: '0' + } + } +]; + +const createCache = (listTopics, getTopicAttributes) => { + var topicArn = (listTopics && listTopics.length) ? listTopics[0].TopicArn : null; + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [topicArn]: { + data: getTopicAttributes + }, + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + getTopicAttributes: { + 'us-east-1': { + err: { + message: 'error while getting topic attributes' + }, + } + } + }, + }; +}; + +const createTopicAttributesErrorCache = (listTopics) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [listTopics[0].TopicArn]: { + err: { + message: 'error while getting topic attributes' + }, + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': null, + }, + getTopicAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('topicEncrypted', function () { + describe('run', function () { + it('should PASS if Server-Side Encryption is enabled for SNS topic', function (done) { + const cache = createCache([listTopics[0]], getTopicAttributes[0]); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Server-Side Encryption is not enabled for SNS topic', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[1]); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while listing SNS topics', function (done) { + const cache = createErrorCache(); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if error while getting SNS topic attributes', function (done) { + const cache = createTopicAttributesErrorCache([listTopics[0]]); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any result if unable to list SNS topics', function (done) { + const cache = createNullCache(); + topicEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sns/topicPolicies.js b/plugins/aws/sns/topicPolicies.js index 15e7e9b34..d1bd0eb9b 100644 --- a/plugins/aws/sns/topicPolicies.js +++ b/plugins/aws/sns/topicPolicies.js @@ -4,17 +4,41 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SNS Topic Policies', category: 'SNS', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures SNS topics do not allow global send or subscribe.', more_info: 'SNS policies should not be configured to allow any AWS user to subscribe or send messages. This could result in data leakage or financial DDoS.', recommended_action: 'Adjust the topic policy to only allow authorized AWS users in known accounts to subscribe.', link: 'http://docs.aws.amazon.com/sns/latest/dg/AccessPolicyLanguage.html', - apis: ['SNS:listTopics', 'SNS:getTopicAttributes'], + apis: ['SNS:listTopics', 'SNS:getTopicAttributes', 'STS:getCallerIdentity'], + settings: { + sns_topic_policy_condition_keys: { + name: 'SNS Topic Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount, aws:SourceArn' + + 'This setting assumes following rules:' + + '1. As a best practice, "Deny" with "StringNotLike" and "Allow" with "StringLike" are used to prevent accidental privileged access' + + '2. IAM condition keys which work with "Numeric" or "Date" operators are not used' + + '3. Bool values are set to "true" with "Allow" and "false" with "Deny"', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceOwner,aws:SourceArn,aws:SourceAccount,sns:Endpoint' + } + }, + realtime_triggers: ['sns:CreateTopic', 'sns:SetTopicAttributes','sns:DeleteTopic'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var config = { + sns_topic_policy_condition_keys: settings.sns_topic_policy_condition_keys || this.settings.sns_topic_policy_condition_keys.default + }; + config.sns_topic_policy_condition_keys = config.sns_topic_policy_condition_keys.replace(/\s/g, ''); + var allowedConditionKeys = config.sns_topic_policy_condition_keys.split(','); + async.each(regions.sns, function(region, rcb){ var listTopics = helpers.addSource(cache, source, ['sns', 'listTopics', region]); @@ -32,87 +56,67 @@ module.exports = { return rcb(); } - async.each(listTopics.data, function(topic, cb){ - if (!topic.TopicArn) return cb(); - + listTopics.data.forEach( topic => { + if (!topic.TopicArn) return; + var getTopicAttributes = helpers.addSource(cache, source, ['sns', 'getTopicAttributes', region, topic.TopicArn]); - + if (!getTopicAttributes || - (!getTopicAttributes.err && !getTopicAttributes.data)) return cb(); - + (!getTopicAttributes.err && !getTopicAttributes.data)) return; + if (getTopicAttributes.err || !getTopicAttributes.data) { helpers.addResult(results, 3, 'Unable to query SNS topic for policy: ' + helpers.addError(getTopicAttributes), region, topic.TopicArn); - - return cb(); + return; } - + if (!getTopicAttributes.data.Attributes || !getTopicAttributes.data.Attributes.Policy) { helpers.addResult(results, 3, 'The SNS topic does not have a policy attached.', region, topic.TopicArn); - - return cb(); + return; } - - try { - var policy = JSON.parse(getTopicAttributes.data.Attributes.Policy); - } catch (e) { - helpers.addResult(results, 3, - 'The SNS topic policy is not valid JSON.', + + var statements = helpers.normalizePolicyDocument(getTopicAttributes.data.Attributes.Policy); + + if (!statements || !statements.length) { + helpers.addResult(results, 0, + 'The SNS Topic policy does not have trust relationship statements', region, topic.TopicArn); - - return cb(); + return; } - + var actions = []; - - if (policy.Statement && policy.Statement.length) { - for (var s in policy.Statement) { - var statement = policy.Statement[s]; - - // Evaluates whether the effect of the statement is to "allow" access to the SNS - var effectEval = (statement.Effect && statement.Effect == 'Allow' ? true : false); - - // Evaluates whether the principal is open to everyone/anonymous - var principalEval = (statement.Principal && statement.Principal.AWS && - (statement.Principal.AWS === '*' || statement.Principal.AWS === 'arn:aws:iam::*') ? true : false); - - // Evaluate the condition: - // Does the condition exist? - var conditionExists = (statement.Condition ? true : false); - // Is it a string condition (StringEquals)? Is the SourceOwner open to everyone? - var conditionString = ((statement.Condition && statement.Condition.StringEquals && - (statement.Condition.StringEquals['AWS:SourceOwner'] || !statement.Condition.StringEquals['AWS:SourceOwner'] == '*')) ? true : false); - // Is it an arn condition (ArnEquals)? Is the SourceArn open to all arns? - var conditionArn = false; - if (statement.Condition && statement.Condition.ArnEquals && - statement.Condition.ArnEquals['aws:SourceArn'] && - statement.Condition.ArnEquals['aws:SourceArn'] == '*') { - conditionArn = true; - } - // Summarize the condition results - var statementEval = ((conditionExists && (conditionString || conditionArn)) ? false : true); - - if (effectEval && principalEval && statementEval) { - if (statement.Action && typeof statement.Action === 'string') { - if (actions.indexOf(statement.Action) === -1) { - actions.push(statement.Action); - } - } else if (statement.Action && statement.Action.length) { - for (var a in statement.Action) { - if (actions.indexOf(statement.Action[a]) === -1) { - actions.push(statement.Action[a]); - } + + for (var statement of statements) { + // Evaluates whether the effect of the statement is to "allow" access to the SNS + var effectEval = (statement.Effect && statement.Effect == 'Allow' ? true : false); + + // Evaluates whether the principal is open to everyone/anonymous + var principalEval = helpers.globalPrincipal(statement.Principal, settings); + + // Evaluates whether condition is scoped or global + let scopedCondition; + if (statement.Condition) scopedCondition = helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, false, accountId, settings); + + if (!scopedCondition && principalEval && effectEval) { + if (statement.Action && typeof statement.Action === 'string') { + if (actions.indexOf(statement.Action) === -1) { + actions.push(statement.Action); + } + } else if (statement.Action && statement.Action.length) { + for (var a in statement.Action) { + if (actions.indexOf(statement.Action[a]) === -1) { + actions.push(statement.Action[a]); } } } } } - + if (actions.length) { helpers.addResult(results, 2, 'The SNS topic policy allows global access to the action(s): ' + actions, @@ -122,11 +126,9 @@ module.exports = { 'The SNS topic policy does not allow global access.', region, topic.TopicArn); } - - cb(); - }, function(){ - rcb(); }); + + rcb(); }, function(){ callback(null, results, source); }); diff --git a/plugins/aws/sns/topicPolicies.spec.js b/plugins/aws/sns/topicPolicies.spec.js new file mode 100644 index 000000000..ad3946e12 --- /dev/null +++ b/plugins/aws/sns/topicPolicies.spec.js @@ -0,0 +1,209 @@ +var expect = require('chai').expect; +var topicPolicies = require('./topicPolicies'); + + +const listTopics = [ + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' }, + { TopicArn: 'arn:aws:sns:us-east-1:111122223333:test-topic-2' } +]; + +const getTopicAttributes = [ + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"arn:aws:iam:112233445566"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-topic","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'alias/aws/sns', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-topic', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission","SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe","SNS:ListSubscriptionsByTopic","SNS:Publish","SNS:Receive"],"Resource":"arn:aws:sns:us-east-1:111122223333:test-138-cmk","Condition":{"StringEquals":{"AWS:SourceOwner":"111122223333"}}}]}', + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'arn:aws:kms:us-east-1:111122223333:key/b8789907-b7f7-438d-847e-7d468bac86b2', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-138-cmk', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__default_statement_ID\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":[\"SNS:GetTopicAttributes\",\"SNS:SetTopicAttributes\",\"SNS:AddPermission\",\"SNS:RemovePermission\",\"SNS:DeleteTopic\",\"SNS:Subscribe\",\"SNS:ListSubscriptionsByTopic\",\"SNS:Publish\",\"SNS:Receive\"],\"Resource\":\"arn:aws:sns:us-east-1:111122223333:test-spec\",\"Condition\":{}}]}", + "Owner": "111122223333", + "SubscriptionsPending": "0", + "TopicArn": "arn:aws:sns:us-east-1:111122223333:test-spec", + "EffectiveDeliveryPolicy": "{\"http\":{\"defaultHealthyRetryPolicy\":{\"minDelayTarget\":20,\"maxDelayTarget\":20,\"numRetries\":3,\"numMaxDelayRetries\":0,\"numNoDelayRetries\":0,\"numMinDelayRetries\":0,\"backoffFunction\":\"linear\"},\"disableSubscriptionOverrides\":false}}", + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + }, + { + "ResponseMetadata": "{\"RequestId\": '2a205b73-5c0d-55e2-8129-0ca612f4a41c' }", + "Attributes": { + "Owner": '111122223333', + "SubscriptionsPending": "0", + "KmsMasterKeyId": 'alias/aws/sns', + "TopicArn": 'arn:aws:sns:us-east-1:111122223333:test-topic', + "EffectiveDeliveryPolicy": '{"http":{"defaultHealthyRetryPolicy":{"minDelayTarget":20,"maxDelayTarget":20,"numRetries":3,"numMaxDelayRetries":0,"numNoDelayRetries":0,"numMinDelayRetries":0,"backoffFunction":"linear"},"disableSubscriptionOverrides":false}}', + "SubscriptionsConfirmed": "0", + "DisplayName": "", + "SubscriptionsDeleted": "0" + } + } +]; + +const createCache = (listTopics, getTopicAttributes) => { + var topicArn = (listTopics && listTopics.length) ? listTopics[0].TopicArn : null; + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [topicArn]: { + data: getTopicAttributes + }, + }, + } + }, + }; +}; + +const createErrorCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': { + err: { + message: 'error while listing topics' + }, + }, + }, + getTopicAttributes: { + 'us-east-1': { + err: { + message: 'error while getting topic attributes' + }, + } + } + }, + }; +}; + +const createTopicAttributesErrorCache = (listTopics) => { + return { + sns: { + listTopics: { + 'us-east-1': { + data: listTopics + }, + }, + getTopicAttributes: { + 'us-east-1': { + [listTopics[0].TopicArn]: { + err: { + message: 'error while getting topic attributes' + }, + } + } + } + }, + }; +}; + +const createNullCache = () => { + return { + sns: { + listTopics: { + 'us-east-1': null, + }, + getTopicAttributes: { + 'us-east-1': null, + }, + }, + }; +}; + + +describe('topicPolicies', function () { + describe('run', function () { + it('should PASS if SNS topic policy does not allow global access', function (done) { + const cache = createCache([listTopics[0]], getTopicAttributes[1]); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if SNS topic policy allows global access', function (done) { + const cache = createCache([listTopics[1]], getTopicAttributes[2]); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no SNS topics found', function (done) { + const cache = createCache([]); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list SNS topics', function (done) { + const cache = createErrorCache(); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to get SNS topic attributes', function (done) { + const cache = createTopicAttributesErrorCache([listTopics[0]]); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if SNS topic does not have a policy attached', function (done) { + const cache = createTopicAttributesErrorCache([listTopics[2]], getTopicAttributes[3]); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list SNS topics response is not found', function (done) { + const cache = createNullCache(); + topicPolicies.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/sqs/queueUnprocessedMessages.js b/plugins/aws/sqs/queueUnprocessedMessages.js new file mode 100644 index 000000000..dff4c16a6 --- /dev/null +++ b/plugins/aws/sqs/queueUnprocessedMessages.js @@ -0,0 +1,91 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQS Queue Unprocessed Messages', + category: 'SQS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Amazon SQS queue has not reached unprocessed messages limit.', + more_info: 'Amazon SQS queues should have unprocessed messages less than the limit to be highly available and responsive.', + recommended_action: 'Set up appropriate message polling time and set up dead letter queue for Amazon SQS queue to handle messages in time', + link: 'https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/working-with-messages.html', + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'STS:getCallerIdentity'], + settings: { + unprocessed_messages_limit: { + name: 'SQS Queue Unprocessed Messages Limit', + description: 'Maximum allowed limit for SQS queue unprocessed messages', + regex: '^[0-9]{1,5}', + default: 1000 + } + }, + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes', 'sqs:DeleteQueue'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var unprocessedLimit = settings.unprocessed_messages_limit || this.settings.unprocessed_messages_limit.default; + unprocessedLimit = parseInt(unprocessedLimit); + + async.each(regions.sqs, function(region, rcb){ + var listQueues = helpers.addSource(cache, source, + ['sqs', 'listQueues', region]); + + if (!listQueues) return rcb(); + + if (listQueues.err) { + helpers.addResult(results, 3, + `Unable to query for SQS queues: ${helpers.addError(listQueues)}`, region); + return rcb(); + } + + if (!listQueues.data || !listQueues.data.length) { + helpers.addResult(results, 0, 'No SQS queues found', region); + return rcb(); + } + + async.each(listQueues.data, function(queueUrl, cb){ + var queueName = queueUrl.split('/'); + queueName = queueName[queueName.length-1]; + + var resource = `arn:${awsOrGov}:sqs:${region}:${accountId}:${queueName}`; + + var getQueueAttributes = helpers.addSource(cache, source, + ['sqs', 'getQueueAttributes', region, queueUrl]); + + if (!getQueueAttributes || getQueueAttributes.err || !getQueueAttributes.data || + !getQueueAttributes.data.Attributes) { + helpers.addResult(results, 3, + `Unable to query queue attributes: ${helpers.addError(getQueueAttributes)}`, + region, resource); + return cb(); + } + + if (!getQueueAttributes.data.Attributes.ApproximateNumberOfMessages) return cb(); + + var unprocessedMessages = getQueueAttributes.data.Attributes.ApproximateNumberOfMessages; + if (parseInt(unprocessedMessages) <= unprocessedLimit) { + helpers.addResult(results, 0, + `SQS queue has ${unprocessedMessages} of ${unprocessedLimit} unprocessed messages limit`, + region, resource); + } else { + helpers.addResult(results, 2, + `SQS queue has ${unprocessedMessages} of ${unprocessedLimit} unprocessed messages limit`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/sqs/queueUnprocessedMessages.spec.js b/plugins/aws/sqs/queueUnprocessedMessages.spec.js new file mode 100644 index 000000000..ff7ea81f5 --- /dev/null +++ b/plugins/aws/sqs/queueUnprocessedMessages.spec.js @@ -0,0 +1,134 @@ +const expect = require('chai').expect; +var queueUnprocessedMessages = require('./queueUnprocessedMessages'); + +const listQueues = [ + "https://sqs.us-east-1.amazonaws.com/111122223333/akd-31" +]; + +const getQueueAttributes = [ + { + "Attributes": { + "QueueArn":"arn:aws:sqs:us-east-1:111122223333:akd-31-2", + "ApproximateNumberOfMessages":"60", + "ApproximateNumberOfMessagesNotVisible":"0", + "ApproximateNumberOfMessagesDelayed":"0", + "CreatedTimestamp":"1609212146", + "LastModifiedTimestamp":"1609212146", + "VisibilityTimeout":"30", + "MaximumMessageSize":"262144", + "MessageRetentionPeriod":"345600", + "DelaySeconds":"0", + "Policy":"{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:111122223333:akd-31-2\"}]}", + "RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:111122223333:akd-31\",\"maxReceiveCount\":10}", + "ReceiveMessageWaitTimeSeconds":"0" + } + }, + { + "Attributes": { + "QueueArn":"arn:aws:sqs:us-east-1:111122223333:akd-31-2", + "ApproximateNumberOfMessages":"1100", + "ApproximateNumberOfMessagesNotVisible":"0", + "ApproximateNumberOfMessagesDelayed":"0", + "CreatedTimestamp":"1609212146", + "LastModifiedTimestamp":"1609212146", + "VisibilityTimeout":"30", + "MaximumMessageSize":"262144", + "MessageRetentionPeriod":"345600", + "DelaySeconds":"0", + "Policy":"{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:111122223333:akd-31-2\"}]}", + "ReceiveMessageWaitTimeSeconds":"0" + } + } +]; + + + +const createCache = (listQueues, getQueueAttributes, listQueuesErr, getQueueAttributesErr) => { + var queueUrl = (listQueues && listQueues.length) ? listQueues[0] : null; + return { + sqs: { + listQueues: { + 'us-east-1': { + err: listQueuesErr, + data: listQueues + } + }, + getQueueAttributes: { + 'us-east-1': { + [queueUrl]: { + err: getQueueAttributesErr, + data: getQueueAttributes + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': null + } + } + }; +}; + +describe('queueUnprocessedMessages', function () { + describe('run', function () { + it('should PASS if Amazon SQS queue has unporcessed messages less than the limit', function (done) { + const cache = createCache([listQueues[0]], getQueueAttributes[0], null, null); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Amazon SQS queue has unporcessed messages more than the limit', function (done) { + const cache = createCache([listQueues[0]], getQueueAttributes[1], null, null); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Amazon SQS queues found', function (done) { + const cache = createCache([], null, null, null); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Amazon SQS queues', function (done) { + const cache = createCache(listQueues, getQueueAttributes[1], { message: 'Unable to query for Amazon SQS queues'}, null); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Amazon SQS queue', function (done) { + const cache = createCache(listQueues, getQueueAttributes[1], null, { message: 'Unable to query for Amazon SQS queue'}); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list queues response is not found', function (done) { + const cache = createNullCache(); + queueUnprocessedMessages.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/sqs/sqsCrossAccount.js b/plugins/aws/sqs/sqsCrossAccount.js index 6adcca511..2ea7d84d0 100644 --- a/plugins/aws/sqs/sqsCrossAccount.js +++ b/plugins/aws/sqs/sqsCrossAccount.js @@ -4,26 +4,72 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SQS Cross Account Access', category: 'SQS', + domain: 'Application Integration', + severity: 'High', description: 'Ensures SQS policies disallow cross-account access', more_info: 'SQS policies should be carefully restricted to prevent publishing or reading from the queue from unexpected sources. Queue policies can be used to limit these privileges.', recommended_action: 'Update the SQS policy to prevent access from external accounts.', link: 'http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-creating-custom-policies.html', - apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'STS:getCallerIdentity'], + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'STS:getCallerIdentity', 'Organizations:listAccounts'], compliance: { pci: 'PCI requires that cardholder data can only be accessed by those with ' + 'a legitimate business need. If SQS queues process this kind of data, ' + 'ensure that the queue policies do not allow reads by third-party accounts.' }, + settings: { + sqs_whitelisted_aws_account_principals: { + name: 'Whitelisted AWS Account Principals', + description: 'A comma-separated list of trusted cross account principals', + regex: '^.*$', + default: '' + }, + sqs_whitelist_aws_organization_accounts: { + name: 'SQS Whitelist All AWS Organization Accounts', + description: 'If true, trust all accounts in current AWS organization', + regex: '^(true|false)$', + default: 'false' + }, + sqs_queue_policy_condition_keys: { + name: 'SQS Queue Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount,aws:PrincipalArn', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner' + }, + }, + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes','sqs:DeleteQueue'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var config = { + sqs_whitelisted_aws_account_principals : settings.sqs_whitelisted_aws_account_principals || this.settings.sqs_whitelisted_aws_account_principals.default, + sqs_whitelist_aws_organization_accounts: settings.sqs_whitelist_aws_organization_accounts || this.settings.sqs_whitelist_aws_organization_accounts.default, + sqs_queue_policy_condition_keys: settings.sqs_queue_policy_condition_keys || this.settings.sqs_queue_policy_condition_keys.default, + }; + + var allowedConditionKeys = config.sqs_queue_policy_condition_keys.split(','); + var whitelistOrganization = (config.sqs_whitelist_aws_organization_accounts == 'true'); + var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + let organizationAccounts = []; + if (whitelistOrganization) { + var listAccounts = helpers.addSource(cache, source, + ['organizations', 'listAccounts', acctRegion]); + + if (!listAccounts || listAccounts.err || !listAccounts.data) { + helpers.addResult(results, 3, + `Unable to query organization accounts: ${helpers.addError(listAccounts)}`, acctRegion); + return callback(null, results, source); + } + + organizationAccounts = helpers.getOrganizationAccounts(listAccounts, accountId); + } + async.each(regions.sqs, function(region, rcb){ var listQueues = helpers.addSource(cache, source, ['sqs', 'listQueues', region]); @@ -41,23 +87,19 @@ module.exports = { return rcb(); } - async.each(listQueues.data, function(queue, cb){ - + listQueues.data.forEach(queue => { var getQueueAttributes = helpers.addSource(cache, source, ['sqs', 'getQueueAttributes', region, queue]); if (!getQueueAttributes || - (!getQueueAttributes.err && !getQueueAttributes.data)) return cb(); - - if (getQueueAttributes.err || + getQueueAttributes.err || !getQueueAttributes.data || !getQueueAttributes.data.Attributes || !getQueueAttributes.data.Attributes.QueueArn) { helpers.addResult(results, 3, 'Unable to query SQS for queue: ' + queue, region); - - return cb(); + return; } var queueArn = getQueueAttributes.data.Attributes.QueueArn; @@ -66,7 +108,7 @@ module.exports = { helpers.addResult(results, 0, 'The SQS queue does not use a custom policy', region, queueArn); - return cb(); + return; } try { @@ -76,7 +118,7 @@ module.exports = { 'The SQS queue policy could not be parsed to valid JSON.', region, queueArn); - return cb(); + return; } var globalActions = []; @@ -84,31 +126,62 @@ module.exports = { var statements = helpers.normalizePolicyDocument(policy); - for (var s in statements) { - var statement = statements[s]; - if (!statement.Effect || statement.Effect !== 'Allow') continue; - if (!statement.Principal) continue; - - if (helpers.globalPrincipal(statement.Principal)) { - if(!statement.Condition || - (statement.Condition.StringEquals && ( - !statement.Condition.StringEquals['AWS:SourceOwner'] || - statement.Condition.StringEquals['AWS:SourceOwner'] == '*') || - (statement.Condition.ArnEquals && ( - !statement.Condition.ArnEquals['aws:SourceArn'] || - statement.Condition.ArnEquals['aws:SourceArn'].indexOf(accountId) === -1)))) { + for (var statement of statements) { + if (!statement.Effect || statement.Effect !== 'Allow' || !statement.Principal) continue; + + var crossAccountAccess = false; + var conditionalPrincipals = (statement.Condition) ? + helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, true, accountId, settings) : []; + + if (helpers.globalPrincipal(statement.Principal, settings)) { + // if (statement.Condition && helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, false, accountId)) continue; + if (statement.Condition && conditionalPrincipals.length) { + for (let principal of conditionalPrincipals) { + if (helpers.crossAccountPrincipal(principal, accountId, undefined, settings)) { + crossAccountAccess = true; + break; + } + } + } else { for (var a in statement.Action) { if (globalActions.indexOf(statement.Action[a]) === -1) { globalActions.push(statement.Action[a]); } } } - } else { - if (helpers.crossAccountPrincipal(statement.Principal, accountId)) { - // Another account - for (a in statement.Action) { - if (crossAccountActions.indexOf(statement.Action[a]) === -1) { - crossAccountActions.push(statement.Action[a]); + } + + if (helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings)) crossAccountAccess = true; + + if (crossAccountAccess) { + if (helpers.crossAccountPrincipal(statement.Principal, accountId, undefined, settings) || + (conditionalPrincipals && conditionalPrincipals.length)) { + let crossAccountPrincipals = helpers.crossAccountPrincipal(statement.Principal, accountId, true, settings); + + if (conditionalPrincipals && conditionalPrincipals.length) { + conditionalPrincipals.forEach(conPrincipal => { + if (!conPrincipal.includes(accountId)) crossAccountPrincipals.push(conPrincipal); + }); + } + + if (!crossAccountPrincipals.length) continue; + + let crossAccount = false; + + for (let principal of crossAccountPrincipals) { + if (config.sqs_whitelisted_aws_account_principals.includes(principal)) continue; + if (whitelistOrganization && + organizationAccounts.find(account => principal.includes(account))) continue; + + crossAccount = true; + break; + } + + if (crossAccount) { + for (a in statement.Action) { + if (crossAccountActions.indexOf(statement.Action[a]) === -1) { + crossAccountActions.push(statement.Action[a]); + } } } } @@ -128,11 +201,9 @@ module.exports = { 'The SQS queue policy does not allow global or cross-account access.', region, queueArn); } - - cb(); - }, function(){ - rcb(); }); + + rcb(); }, function(){ callback(null, results, source); }); diff --git a/plugins/aws/sqs/sqsCrossAccount.spec.js b/plugins/aws/sqs/sqsCrossAccount.spec.js new file mode 100644 index 000000000..210efd16e --- /dev/null +++ b/plugins/aws/sqs/sqsCrossAccount.spec.js @@ -0,0 +1,249 @@ +const expect = require('chai').expect; +const sqsCrossAccount = require('./sqsCrossAccount'); + +const listQueues = [ + "https://sqs.us-east-1.amazonaws.com/112233445566/test1152", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1152.fifo", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1153.fifo", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1153" +]; + +const queueAttributes = [ + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1152", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601240312", + "LastModifiedTimestamp": "1601240664", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:112233445566:test1152\"}]}", + "ReceiveMessageWaitTimeSeconds": "0" + } + }, + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1152.fifo", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601242252", + "LastModifiedTimestamp": "1601242252", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::112233445566:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:112233445566:test1152.fifo\"}]}", + "ReceiveMessageWaitTimeSeconds": "0", + "FifoQueue": "true", + "ContentBasedDeduplication": "false" + } + }, + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1153", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601242252", + "LastModifiedTimestamp": "1601242252", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "ReceiveMessageWaitTimeSeconds": "0", + "FifoQueue": "true", + "ContentBasedDeduplication": "false" + } + }, + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1152.fifo", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601242252", + "LastModifiedTimestamp": "1601242252", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111222333444:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:112233445566:test1152.fifo\"}]}", + "ReceiveMessageWaitTimeSeconds": "0", + "FifoQueue": "true", + "ContentBasedDeduplication": "false" + } + } +]; + +const createCache = (queues, attributes) => { + var queueUrl = (queues && queues.length) ? queues[0] : null; + return { + sqs: { + listQueues: { + 'us-east-1': { + data: queues + } + }, + getQueueAttributes: { + 'us-east-1': { + [queueUrl]: { + data: attributes + } + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111222333444' + } + } + }, + organizations: { + listAccounts: { + 'us-east-1': { + data: [ + { + "Id": "111222333444", + "Arn": "arn:aws:organizations::111222333444:account/o-sb9qmv2zif/111222333444", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + }, + { + "Id": "112233445566", + "Arn": "arn:aws:organizations::112233445566:account/o-sb9qmv2zif/112233445566", + "Email": "xyz@gmail.com", + "Name": "test-role", + "Status": "ACTIVE", + "JoinedMethod": "INVITED", + "JoinedTimestamp": "2020-12-27T10:47:14.057Z" + } + ] + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': { + err: 'error while listing SQS queues' + } + } + } + }; +}; + +const createNullCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': null + } + } + }; +}; + +describe('sqsCrossAccount', function () { + describe('run', function () { + it('should FAIL if SQS queue SQS queue policy allows global access', function (done) { + const cache = createCache([listQueues[0]], queueAttributes[0]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if SQS queue policy allows cross-account access', function (done) { + const cache = createCache([listQueues[1]], queueAttributes[1]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if cross-account is whitelisted', function (done) { + const cache = createCache([listQueues[1]], queueAttributes[1]); + sqsCrossAccount.run(cache, { sqs_whitelisted_aws_account_principals:'arn:aws:iam::112233445566:root' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SQS queue policy allows cross-account access to organization account and setting is set to true', function (done) { + const cache = createCache([listQueues[1]], queueAttributes[1]); + sqsCrossAccount.run(cache, { sqs_whitelist_aws_organization_accounts: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SQS queue policy does not allow global or cross-account access', function (done) { + const cache = createCache([listQueues[1]], queueAttributes[3]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no SQS queues found', function (done) { + const cache = createCache([]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SQS queues does not use a custom policy', function (done) { + const cache = createCache([listQueues[3]], queueAttributes[2]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list SQS queues', function (done) { + const cache = createErrorCache(); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query attributes for SQS queue', function (done) { + const cache = createCache([listQueues[0]]); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list SQS queues response not found', function (done) { + const cache = createNullCache(); + sqsCrossAccount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/sqs/sqsDeadLetterQueue.js b/plugins/aws/sqs/sqsDeadLetterQueue.js new file mode 100644 index 000000000..5b4803198 --- /dev/null +++ b/plugins/aws/sqs/sqsDeadLetterQueue.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQS Dead Letter Queue', + category: 'SQS', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures that each Amazon SQS queue has Dead Letter Queue configured.', + more_info: 'Amazon SQS queues should have dead letter queue configured to avoid data loss for unprocessed messages.', + recommended_action: 'Update Amazon SQS queue and configure dead letter queue.', + link: 'https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html', + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'STS:getCallerIdentity'], + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes', 'sqs:DeleteQueue'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.sqs, function(region, rcb){ + var listQueues = helpers.addSource(cache, source, + ['sqs', 'listQueues', region]); + + if (!listQueues) return rcb(); + + if (listQueues.err) { + helpers.addResult(results, 3, + `Unable to query for Amazon SQS queues: ${helpers.addError(listQueues)}`, region); + return rcb(); + } + + if (!listQueues.data || !listQueues.data.length) { + helpers.addResult(results, 0, 'No Amazon SQS queues found', region); + return rcb(); + } + + async.each(listQueues.data, function(queueUrl, cb){ + var queueName = queueUrl.split('/'); + queueName = queueName[queueName.length-1]; + + var resource = `arn:${awsOrGov}:sqs:${region}:${accountId}:${queueName}`; + var getQueueAttributes = helpers.addSource(cache, source, + ['sqs', 'getQueueAttributes', region, queueUrl]); + + if (!getQueueAttributes || getQueueAttributes.err || !getQueueAttributes.data || + !getQueueAttributes.data.Attributes || !getQueueAttributes.data.Attributes.QueueArn) { + helpers.addResult(results, 3, + `Unable to query queue attributes for Amazon SQS queue: ${helpers.addError(getQueueAttributes)}`, + region, resource); + return cb(); + } + + if (getQueueAttributes.data.Attributes.RedrivePolicy && + getQueueAttributes.data.Attributes.RedrivePolicy.length) { + helpers.addResult(results, 0, + 'Amazon SQS queue has dead letter queue configured', + region, resource); + } else { + helpers.addResult(results, 2, + 'Amazon SQS queue does not have dead letter queue configured', + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/sqs/sqsDeadLetterQueue.spec.js b/plugins/aws/sqs/sqsDeadLetterQueue.spec.js new file mode 100644 index 000000000..9817c8815 --- /dev/null +++ b/plugins/aws/sqs/sqsDeadLetterQueue.spec.js @@ -0,0 +1,134 @@ +const expect = require('chai').expect; +var sqsDeadLetterQueue = require('./sqsDeadLetterQueue'); + +const listQueues = [ + "https://sqs.us-east-1.amazonaws.com/111122223333/akd-31" +]; + +const getQueueAttributes = [ + { + "Attributes": { + "QueueArn":"arn:aws:sqs:us-east-1:111122223333:akd-31-2", + "ApproximateNumberOfMessages":"0", + "ApproximateNumberOfMessagesNotVisible":"0", + "ApproximateNumberOfMessagesDelayed":"0", + "CreatedTimestamp":"1609212146", + "LastModifiedTimestamp":"1609212146", + "VisibilityTimeout":"30", + "MaximumMessageSize":"262144", + "MessageRetentionPeriod":"345600", + "DelaySeconds":"0", + "Policy":"{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:111122223333:akd-31-2\"}]}", + "RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:111122223333:akd-31\",\"maxReceiveCount\":10}", + "ReceiveMessageWaitTimeSeconds":"0" + } + }, + { + "Attributes": { + "QueueArn":"arn:aws:sqs:us-east-1:111122223333:akd-31-2", + "ApproximateNumberOfMessages":"0", + "ApproximateNumberOfMessagesNotVisible":"0", + "ApproximateNumberOfMessagesDelayed":"0", + "CreatedTimestamp":"1609212146", + "LastModifiedTimestamp":"1609212146", + "VisibilityTimeout":"30", + "MaximumMessageSize":"262144", + "MessageRetentionPeriod":"345600", + "DelaySeconds":"0", + "Policy":"{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:111122223333:akd-31-2\"}]}", + "ReceiveMessageWaitTimeSeconds":"0" + } + } +]; + + + +const createCache = (listQueues, getQueueAttributes, listQueuesErr, getQueueAttributesErr) => { + var queueUrl = (listQueues && listQueues.length) ? listQueues[0] : null; + return { + sqs: { + listQueues: { + 'us-east-1': { + err: listQueuesErr, + data: listQueues + } + }, + getQueueAttributes: { + 'us-east-1': { + [queueUrl]: { + err: getQueueAttributesErr, + data: getQueueAttributes + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': null + } + } + }; +}; + +describe('sqsDeadLetterQueue', function () { + describe('run', function () { + it('should PASS if Amazon SQS queue has dead letter queue configured', function (done) { + const cache = createCache([listQueues[0]], getQueueAttributes[0], null, null); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Amazon SQS queue does not have dead letter queue configured', function (done) { + const cache = createCache([listQueues[0]], getQueueAttributes[1], null, null); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Amazon SQS queues found', function (done) { + const cache = createCache([], null, null, null); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Amazon SQS queues', function (done) { + const cache = createCache(listQueues, getQueueAttributes[1], { message: 'Unable to query for Amazon SQS queues'}, null); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query for Amazon SQS queue', function (done) { + const cache = createCache(listQueues, getQueueAttributes[1], null, { message: 'Unable to query for Amazon SQS queue'}); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list queues response is not found', function (done) { + const cache = createNullCache(); + sqsDeadLetterQueue.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/sqs/sqsEncrypted.js b/plugins/aws/sqs/sqsEncrypted.js index 0343e16f0..0f497f79e 100644 --- a/plugins/aws/sqs/sqsEncrypted.js +++ b/plugins/aws/sqs/sqsEncrypted.js @@ -6,11 +6,13 @@ var defaultKmsKey = 'alias/aws/sqs'; module.exports = { title: 'SQS Encrypted', category: 'SQS', + domain: 'Application Integration', + severity: 'High', description: 'Ensures SQS encryption is enabled', more_info: 'Messages sent to SQS queues can be encrypted using KMS server-side encryption. Existing queues can be modified to add encryption with minimal overhead.', recommended_action: 'Enable encryption using KMS for all SQS queues.', link: 'http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-server-side-encryption.html', - apis: ['SQS:listQueues', 'SQS:getQueueAttributes'], + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'KMS:listKeys', 'KMS:describeKey'], compliance: { hipaa: 'SQS encryption must be used when processing any HIPAA-related data. ' + 'AWS KMS encryption ensures that the SQS message payload meets the ' + @@ -19,6 +21,26 @@ module.exports = { 'encryption should be enabled for all queues processing this type ' + 'of data.' }, + remediation_description: 'Encryption for the affected SQS queues will be enabled.', + remediation_min_version: '202010302230', + apis_remediate: ['SQS:listQueues', 'SQS:getQueueAttributes', 'KMS:listKeys', 'KMS:describeKey'], + remediation_inputs: { + kmsKeyIdforSqs: { + name: '(Optional) KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + actions: { + remediate: ['SQS:setQueueAttributes'], + rollback: ['SQS:setQueueAttributes'] + }, + permissions: { + remediate: ['sqs:SetQueueAttributes'], + rollback: ['sqs:SetQueueAttributes'] + }, + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes', 'sqs:DeleteQueue'], run: function(cache, settings, callback) { var results = []; @@ -86,5 +108,90 @@ module.exports = { }, function(){ callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'sqsEncrypted'; + var queueNameArr = resource.split(':'); + var queueName = queueNameArr[queueNameArr.length - 1]; + let defaultKeyDesc = 'Default master key that protects my SQS messages when no other key is defined'; + + // find the location of the Queue needing to be remediated + var queAttributes = cache['sqs']['getQueueAttributes']; + var queueLocation = queueNameArr[3]; + var queueUrl; + var err; + if (!queAttributes || queAttributes.err || !Object.keys(queAttributes).length){ + err = queAttributes.err || 'Unable to get queue location'; + return callback(err, null); + } + + for (var qUrl in queAttributes[queueLocation]){ + if (queAttributes[queueLocation][qUrl].data.Attributes.QueueArn === resource){ + queueUrl = qUrl; + break; + } + } + + if (!queueUrl) { + err = 'Unable to get queue url'; + return callback(err, null); + } + // add the location of the Queue to the config + config.region = queueLocation; + var params = {}; + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforSqs) { + params = { + Attributes: { + 'KmsMasterKeyId': settings.input.kmsKeyIdforSqs + }, + QueueUrl: queueUrl + }; + } else { + let defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default SQS key for the region ${config.region}`); + params = { + Attributes: { + 'KmsMasterKeyId': defaultKmsKeyId + }, + QueueUrl: queueUrl + }; + + } + + var remediation_file = settings.remediation_file; + + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Encryption': 'Disabled', + 'Queue': queueName + }; + + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'ENCRYPTED', + 'Queue': queueName + }; + settings.remediation_file = remediation_file; + return callback(null, action); + }); + }, + + rollback: function(config, cache, settings, resource, callback) { + console.log('Rollback support for this plugin has not yet been implemented'); + console.log(config, cache, settings, resource); + callback(); } }; \ No newline at end of file diff --git a/plugins/aws/sqs/sqsEncryptionEnabled.js b/plugins/aws/sqs/sqsEncryptionEnabled.js new file mode 100644 index 000000000..d4957752a --- /dev/null +++ b/plugins/aws/sqs/sqsEncryptionEnabled.js @@ -0,0 +1,149 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQS Encryption Enabled', + category: 'SQS', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure SQS queues are encrypted using keys of desired encryption level', + more_info: 'Messages sent to SQS queues can be encrypted using KMS server-side encryption. Existing queues can be modified to add encryption with minimal overhead.'+ + 'Use customer-managed keys instead in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Enable encryption using KMS Customer Master Keys (CMKs) for all SQS queues.', + link: 'http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-server-side-encryption.html', + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'KMS:listAliases', 'KMS:listKeys', + 'KMS:describeKey', 'STS:getCallerIdentity'], + settings: { + sqs_queues_desired_encryption_level: { + name: 'SQS Queues Target Encryption Level', + description: 'In order (lowest to highest) sse=SSE-SQS; awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes', 'sqs:DeleteQueue'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var config = { + desiredEncryptionLevelString: settings.sqs_queues_desired_encryption_level || this.settings.sqs_queues_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.sqs, function(region, rcb){ + var listQueues = helpers.addSource(cache, source, + ['sqs', 'listQueues', region]); + + if (!listQueues) return rcb(); + + if (listQueues.err) { + helpers.addResult(results, 3, + 'Unable to query SQS queues: ' + helpers.addError(listQueues), region); + return rcb(); + } + + if (!listQueues.data || !listQueues.data.length) { + helpers.addResult(results, 0, 'No SQS queues found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + `Unable to query for KMS aliases: ${helpers.addError(listAliases)}`, + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias){ + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasName] = keyArn; + }); + + for (let queue of listQueues.data) { + var queueName = queue.split('/'); + queueName = queueName[queueName.length-1]; + let resource = `arn:${awsOrGov}:sqs:${region}:${accountId}:${queueName}`; + + var getQueueAttributes = helpers.addSource(cache, source, + ['sqs', 'getQueueAttributes', region, queue]); + + if (!getQueueAttributes || getQueueAttributes.err || !getQueueAttributes.data || + !getQueueAttributes.data.Attributes) { + helpers.addResult(results, 3, + `Unable to get SQS queues description: ${helpers.addError(getQueueAttributes)}`, + region, resource); + continue; + } + + if (!getQueueAttributes.data.Attributes.KmsMasterKeyId && + getQueueAttributes.data.Attributes.SqsManagedSseEnabled === 'false') { + helpers.addResult(results, 2, + 'SQS queues does not have encryption enabled', + region, resource); + continue; + } + + if (getQueueAttributes.data.Attributes.KmsMasterKeyId) { + var encryptionKey = getQueueAttributes.data.Attributes.KmsMasterKeyId; + let kmsKeyArn = (encryptionKey.startsWith('alias/')) ? + (kmsAliasArnMap[encryptionKey]) ? kmsAliasArnMap[encryptionKey] : + encryptionKey : encryptionKey; + + var keyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, kmsKeyArn); + continue; + } + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + } else currentEncryptionLevel = 1; //sse + + let currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `SQS queue is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `SQS queue is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/sqs/sqsEncryptionEnabled.spec.js b/plugins/aws/sqs/sqsEncryptionEnabled.spec.js new file mode 100644 index 000000000..ae1ac7298 --- /dev/null +++ b/plugins/aws/sqs/sqsEncryptionEnabled.spec.js @@ -0,0 +1,244 @@ +var expect = require('chai').expect; +var sqsEncryptionEnabled = require('./sqsEncryptionEnabled'); + +const listQueues = [ + "https://sqs.us-east-1.amazonaws.com/000111222333/akhtarqueue" +]; + + +const getQueueAttributes = [ + { + "ResponseMetadata": { + "RequestId": "55fecb32-ba74-528f-bea8-9174eb61d3d5" + }, + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:000111222333:my-queue1", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1640774167", + "LastModifiedTimestamp": "1640774167", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "ReceiveMessageWaitTimeSeconds": "0", + "KmsMasterKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KmsDataKeyReusePeriodSeconds": "300", + "SqsManagedSseEnabled": "false" + } + }, + { + "ResponseMetadata": { + "RequestId": "8b46adeb-a313-5f7a-8319-f7adf206f5ed" + }, + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:000111222333:mine1", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1640704710", + "LastModifiedTimestamp": "1640775981", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::000011112222:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:000011112222:mine1\"}]}", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true" + } + }, + { + "ResponseMetadata": { + "RequestId": "d35d9102-91c9-5a3b-b8e0-cb44c0dcd106" + }, + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:000111222333:akhtarqueue", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1637571406", + "LastModifiedTimestamp": "1637577249", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2012-10-17\",\"Id\":\"appman-infrastructure-sqs-ce-status-sync\",\"Statement\":[{\"Sid\":\"appman-infrastructure-sqs-ce-status-sync-policy\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::000011112222:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:000011112222:akhtarqueue\"},{\"Effect\":\"Allow\",\"Action\":\"kms:*\",\"Resource\":\"arn:aws:sqs:us-east-1:000011112222:akhtarqueue\"},{\"Sid\":\"appman-infrastructure-sns-sqs-ce-status-sync-policy\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"SQS:SendMessage\",\"Resource\":\"arn:aws:sqs:us-east-1:000011112222:akhtarqueue\",\"Condition\":{\"ArnLike\":{\"aws:SourceArn\":\"arn:aws:sns:eu-west-1:827604522863:applicant-manager-status-changed\"}}}]}", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "false" + } + } +]; + +const listAliases = [ + { + "AliasName": "alias/sadeed-k1", + "AliasArn": "arn:aws:kms:us-east-1:000111222333:alias/sadeed-k1", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2021-11-15T17:05:31.308000+05:00", + "LastUpdatedDate": "2021-11-15T17:05:31.308000+05:00" + }, +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/ad013a33-b01d-4d88-ac97-127399c18b3e" + } +] + +const createCache = (queues, keys, kmsAliases, getQueueAttributes, describeKey, queuesErr, kmsAliasesErr, keysErr, describeKeyErr, getQueueAttributesErr) => { + + var keyId = (keys && keys.length ) ? keys[0].KeyId : null; + var queue = (queues && queues.length) ? queues[0]: null; + return { + sqs: { + listQueues: { + 'us-east-1': { + err: queuesErr, + data: queues + }, + }, + getQueueAttributes: { + 'us-east-1': { + [queue]: { + data: getQueueAttributes, + err: getQueueAttributesErr + } + } + } + }, + kms: { + listAliases: { + 'us-east-1': { + data: kmsAliases, + err: kmsAliasesErr + }, + }, + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('sqsEncryptionEnabled', function () { + describe('run', function () { + it('should PASS if SQS queue is encrypted with desired encryption level', function (done) { + const cache = createCache([listQueues[0]], listKeys, listAliases, getQueueAttributes[0], describeKey[0]); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQS queue is encrypted with awscmk'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SQS queue not encrypted with desired encryption level', function (done) { + const cache = createCache([listQueues[0]], listKeys, listAliases, getQueueAttributes[1], describeKey[1]); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQS queue is encrypted with sse'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SQS queue does not have encryption enabled', function (done) { + const cache = createCache([listQueues[0]], listKeys, listAliases, getQueueAttributes[2], describeKey[1]); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQS queues does not have encryption enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no SQS queues found', function (done) { + const cache = createCache([]); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQS queues found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query SQS queues', function (done) { + const cache = createCache([listQueues[0]], listKeys, listAliases, null, null, null, null, + null, null, { message: "Unable to query SQS queues" }); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQS queues'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listQueues, null, null, null, null, null, null, { message: "Unable to list KMS keys" }); + sqsEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list KMS keys'); + done(); + }); + }); + }); +}) diff --git a/plugins/aws/sqs/sqsHasTags.js b/plugins/aws/sqs/sqsHasTags.js new file mode 100644 index 000000000..ceda077b2 --- /dev/null +++ b/plugins/aws/sqs/sqsHasTags.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQS Has Tags', + category: 'SQS', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures that Amazon SQS queue has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Update SQS queue and add tags.', + link: 'https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-queue-tags.html', + apis: ['SQS:listQueues', 'STS:getCallerIdentity', 'ResourceGroupsTaggingAPI:getResources'], + realtime_triggers: ['sqs:CreateQueue', 'sqs:SetQueueAttributes', 'sqs:DeleteQueue'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.sqs, function(region, rcb){ + var listQueues = helpers.addSource(cache, source, + ['sqs', 'listQueues', region]); + + if (!listQueues) return rcb(); + + if (listQueues.err || !listQueues.data) { + helpers.addResult(results, 3, + `Unable to query for SQS queues: ${helpers.addError(listQueues)}`, region); + return rcb(); + } + + if (!listQueues.data.length) { + helpers.addResult(results, 0, 'No SQS queues found', region); + return rcb(); + } + + const arnList = []; + for (let queueUrl of listQueues.data){ + var queueName = queueUrl.split('/'); + queueName = queueName[queueName.length-1]; + + var resource = `arn:${awsOrGov}:sqs:${region}:${accountId}:${queueName}`; + arnList.push(resource); + } + + helpers.checkTags(cache, 'SQS queue', arnList, region, results, settings); + return rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/aws/sqs/sqsHasTags.spec.js b/plugins/aws/sqs/sqsHasTags.spec.js new file mode 100644 index 000000000..8b33aabb8 --- /dev/null +++ b/plugins/aws/sqs/sqsHasTags.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var sqsHasTags = require('./sqsHasTags'); + +const createCache = (listQueues, rgData) => { + return { + sqs: { + listQueues: { + 'us-east-1': { + err: null, + data: listQueues + } + }, + }, + resourcegroupstaggingapi: { + getResources: { + 'us-east-1':{ + err: null, + data: rgData + } + } + }, + sts: { + getCallerIdentity: { + 'us-east-1': { + data: '111122223333' + } + } + } + }; +}; + +describe('SQS Has Tags', function () { + describe('run', function () { + it('should PASS if SQS queues have tags', function (done) { + const cache = createCache(['https://sqs.us-east-1.amazonaws.com/111122223333/test-queue-1'], [ + { + "ResourceARN": "arn:aws:sqs:us-east-1:111122223333:test-queue-1", + "Tags": [{ key: 'name', value: 'test-queue-1' }], + } + ]); + sqsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('SQS queue has tags'); + done(); + }); + }); + + it('should FAIL if SQS queues do not have tags', function (done) { + const cache = createCache(['https://sqs.us-east-1.amazonaws.com/111122223333/test-queue-1'], [ + { + "ResourceARN": "arn:aws:sqs:us-east-1:111122223333:test-queue-1", + "Tags": [], + } + ]); + sqsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('SQS queue does not have any tags'); + done(); + }); + }); + + it('should PASS if no SQS queues found', function (done) { + const cache = createCache([], null); + sqsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('No SQS queues found'); + done(); + }); + }); + + it('should UNKNOWN if unable to query SQS queues', function (done) { + const cache = createCache(null, null); + sqsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for SQS queues'); + done(); + }); + }); + + it('should give unknown result if unable to query resource group tagging api', function (done) { + const cache = createCache(['https://sqs.us-east-1.amazonaws.com/111122223333/test-queue-1'], null); + sqsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query all resources'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/sqs/sqsPublicAccess.js b/plugins/aws/sqs/sqsPublicAccess.js new file mode 100644 index 000000000..5928dec95 --- /dev/null +++ b/plugins/aws/sqs/sqsPublicAccess.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SQS Public Access', + category: 'SQS', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that SQS queues are not publicly accessible', + more_info: 'SQS queues should be not be publicly accessible to prevent unauthorized actions.', + recommended_action: 'Update the SQS queue policy to prevent public access.', + link: 'http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-creating-custom-policies.html', + apis: ['SQS:listQueues', 'SQS:getQueueAttributes', 'STS:getCallerIdentity'], + settings: { + sqs_queue_policy_condition_keys: { + name: 'SQS Queue Policy Allowed Condition Keys', + description: 'Comma separated list of AWS IAM condition keys that should be allowed i.e. aws:SourceAccount.', + regex: '^.*$', + default: 'aws:PrincipalArn,aws:PrincipalAccount,aws:PrincipalOrgID,aws:SourceAccount,aws:SourceArn,aws:SourceOwner' + }, + }, + realtime_triggers: ['sqs:CreateQueue', 'sqs:AddPermission', 'sqs:RemovePermission', 'sqs:DeleteQueue'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + sqs_queue_policy_condition_keys: settings.sqs_queue_policy_condition_keys || this.settings.sqs_queue_policy_condition_keys.default + }; + var allowedConditionKeys = config.sqs_queue_policy_condition_keys.split(','); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, + ['sts', 'getCallerIdentity', acctRegion, 'data']); + var awsOrGov = helpers.defaultPartition(settings); + + async.each(regions.sqs, function(region, rcb){ + var listQueues = helpers.addSource(cache, source, + ['sqs', 'listQueues', region]); + + if (!listQueues) return rcb(); + + if (listQueues.err) { + helpers.addResult(results, 3, + `Unable to query for SQS queues: ${helpers.addError(listQueues)}`, region); + return rcb(); + } + + if (!listQueues.data || !listQueues.data.length) { + helpers.addResult(results, 0, 'No SQS queues found', region); + return rcb(); + } + + async.each(listQueues.data, function(queue, cb){ + var queueName = queue.substr(queue.lastIndexOf('/') + 1); + var resource = `arn:${awsOrGov}:sqs:${region}:${accountId}:${queueName}`; + + var getQueueAttributes = helpers.addSource(cache, source, + ['sqs', 'getQueueAttributes', region, queue]); + + if (!getQueueAttributes || + getQueueAttributes.err || + !getQueueAttributes.data || + !getQueueAttributes.data.Attributes) { + helpers.addResult(results, 3, + `Unable to query attributes for queue "${queueName}"`, + region, resource); + + return cb(); + } + + if (!getQueueAttributes.data.Attributes.Policy) { + helpers.addResult(results, 0, + `SQS queue "${queueName}" does not use a policy`, + region, resource); + return cb(); + } + + var statements = helpers.normalizePolicyDocument(getQueueAttributes.data.Attributes.Policy); + + var publicStatements = []; + for (var statement of statements) { + if (statement.Condition && helpers.isValidCondition(statement, allowedConditionKeys, helpers.IAM_CONDITION_OPERATORS, false, accountId, settings)) continue; + if (statement.Effect && + statement.Effect === 'Allow' && + helpers.globalPrincipal(statement.Principal, settings)) { + publicStatements.push(statement); + } + } + + if (!publicStatements.length) { + helpers.addResult(results, 0, + `SQS queue "${queueName}" is not publicly accessible`, + region, resource); + } else { + helpers.addResult(results, 2, + `SQS queue "${queueName}" is publicly accessible`, + region, resource); + } + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/sqs/sqsPublicAccess.spec.js b/plugins/aws/sqs/sqsPublicAccess.spec.js new file mode 100644 index 000000000..d4b69adcd --- /dev/null +++ b/plugins/aws/sqs/sqsPublicAccess.spec.js @@ -0,0 +1,172 @@ +const expect = require('chai').expect; +const sqsPublicAccess = require('./sqsPublicAccess'); + +const listQueues = [ + "https://sqs.us-east-1.amazonaws.com/112233445566/test1152", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1152.fifo", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1153.fifo", + "https://sqs.us-east-1.amazonaws.com/112233445566/test1153" +]; + +const queueAttributes = [ + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1152", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601240312", + "LastModifiedTimestamp": "1601240664", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:112233445566:test1152\"}]}", + "ReceiveMessageWaitTimeSeconds": "0" + } + }, + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1152.fifo", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601242252", + "LastModifiedTimestamp": "1601242252", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__owner_statement\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::112233445566:root\"},\"Action\":\"SQS:*\",\"Resource\":\"arn:aws:sqs:us-east-1:112233445566:test1152.fifo\"}]}", + "ReceiveMessageWaitTimeSeconds": "0", + "FifoQueue": "true", + "ContentBasedDeduplication": "false" + } + }, + { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:112233445566:test1153", + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "CreatedTimestamp": "1601242252", + "LastModifiedTimestamp": "1601242252", + "VisibilityTimeout": "30", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "DelaySeconds": "0", + "ReceiveMessageWaitTimeSeconds": "0", + "FifoQueue": "true", + "ContentBasedDeduplication": "false" + } + } +]; + +const createCache = (queues, attributes) => { + var queueUrl = (queues && queues.length) ? queues[0] : null; + return { + sqs: { + listQueues: { + 'us-east-1': { + data: queues + } + }, + getQueueAttributes: { + 'us-east-1': { + [queueUrl]: { + data: attributes + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': { + err: 'error while listing SQS queues' + } + } + } + }; +}; + +const createNullCache = () => { + return { + sqs: { + listQueues: { + 'us-east-1': null + } + } + }; +}; + +describe('sqsPublicAccess', function () { + describe('run', function () { + it('should FAIL if SQS queue is publically accessible', function (done) { + const cache = createCache([listQueues[0]], queueAttributes[0]); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if SQS queue is not publically accessible', function (done) { + const cache = createCache([listQueues[1]], queueAttributes[1]); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no SQS queues found', function (done) { + const cache = createCache([]); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if SQS queues does not use a custom policy', function (done) { + const cache = createCache([listQueues[3]], queueAttributes[2]); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to list SQS queues', function (done) { + const cache = createErrorCache(); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to query attributes for SQS queue', function (done) { + const cache = createCache([listQueues[2]]); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list SQS queues response not found', function (done) { + const cache = createNullCache(); + sqsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ssm/ssmActiveOnAllInstances.js b/plugins/aws/ssm/ssmActiveOnAllInstances.js index 245a935d0..7959e3ea3 100644 --- a/plugins/aws/ssm/ssmActiveOnAllInstances.js +++ b/plugins/aws/ssm/ssmActiveOnAllInstances.js @@ -3,7 +3,9 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SSM Agent Active All Instances', - category: 'SSM', + category: 'EC2', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures SSM agents are installed and active on all servers', more_info: 'SSM allows for centralized monitoring of all servers and should be activated on all EC2 instances.', link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-setting-up.html', @@ -17,11 +19,13 @@ module.exports = { default: 20 } }, + realtime_triggers: ['ec2:RunInstances', 'ssm:CreateAssociation', 'ssm:UpdateAssociation', 'ec2:TerminateInstance', 'ssm:DeleteAssociation'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); @@ -79,7 +83,7 @@ module.exports = { // See if every instance has SSM installed instanceList.forEach(function(id){ - var arn = 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + id; + var arn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + id; if (ssmMap[id] && ssmMap[id].PingStatus && ssmMap[id].PingStatus == 'Online') { instanceListPass.push(arn); diff --git a/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.js b/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.js new file mode 100644 index 000000000..2df0bcad7 --- /dev/null +++ b/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SSM Agent Auto Update Enabled', + category: 'EC2', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures the SSM agent is configured to automatically update to new versions', + more_info: 'To ensure the latest version of the SSM agent is installed, it should be configured to consume automatic updates.', + link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-agent-automatic-updates.html', + recommended_action: 'Update the SSM agent configuration for all managed instances to use automatic updates.', + apis: ['SSM:describeInstanceInformation', 'SSM:listAssociations', 'STS:getCallerIdentity'], + realtime_triggers: ['ssm:CreateAssoication', 'ssm:UpdateAssociation', 'ssm:DeleteAssociation'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ssm, function(region, rcb){ + var describeInstanceInformation = helpers.addSource(cache, source, + ['ssm', 'describeInstanceInformation', region]); + + var listAssociations = helpers.addSource(cache, source, + ['ssm', 'listAssociations', region]); + + if (!describeInstanceInformation || !listAssociations) return rcb(); + + if (describeInstanceInformation.err || !describeInstanceInformation.data) { + helpers.addResult(results, 3, + 'Unable to query SSM describe instance information: ' + helpers.addError(describeInstanceInformation), region); + return rcb(); + } + + if (!describeInstanceInformation.data.length) { + helpers.addResult(results, 0, + 'No managed instances found', region); + return rcb(); + } + + if (listAssociations.err || !listAssociations.data) { + helpers.addResult(results, 3, + 'Unable to query SSM list associations: ' + helpers.addError(listAssociations), region); + return rcb(); + } + + var associatedInstances = []; + + if (listAssociations.data.length) { + listAssociations.data.forEach(association => { + if (association.Name && association.Name === 'AWS-UpdateSSMAgent' && association.Targets && association.Targets.length) { + association.Targets.forEach(function(target){ + if (target.Key && target.Key === 'InstanceIds' && target.Values && target.Values.length) { + target.Values.forEach(function(instanceId){ + if (!associatedInstances.includes(instanceId) && association.ScheduleExpression){ + associatedInstances.push(instanceId); + } + }); + } + }); + } + }); + } + + describeInstanceInformation.data.forEach(function(instance) { + var resource = 'arn:' + awsOrGov + ':ec2:' + region + ':' + accountId + ':instance/' + instance.InstanceId; + + if (associatedInstances.includes(instance.InstanceId) || associatedInstances.includes('*')) { + helpers.addResult(results, 0, + 'Instance has SSM Agent auto update enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Instance does not have SSM Agent auto update enabled', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.spec.js b/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.spec.js new file mode 100644 index 000000000..54f121974 --- /dev/null +++ b/plugins/aws/ssm/ssmAgentAutoUpdateEnabled.spec.js @@ -0,0 +1,197 @@ +var expect = require('chai').expect; +const ssmAgentAutoUpdateEnabled = require('./ssmAgentAutoUpdateEnabled'); + +const describeInstanceInformation = [ + { + "InstanceId": "i-0c8993c98fdb46a97", + "PingStatus": "Online", + "LastPingDateTime": 1599395666.201, + "AgentVersion": "2.3.1644.0", + "IsLatestVersion": true, + "PlatformType": "Linux", + "PlatformName": "Amazon Linux", + "PlatformVersion": "2", + "ResourceType": "EC2Instance", + "IPAddress": "172.31.44.20", + "ComputerName": "ip-172-31-44-20.ec2.internal", + "AssociationStatus": "Success", + "LastAssociationExecutionDate": 1599393759.647, + "LastSuccessfulAssociationExecutionDate": 1599393759.647, + "AssociationOverview": { + "DetailedStatus": "Success", + "InstanceAssociationStatusAggregatedCount": { + "Success": 2 + } + } + } +]; + +const listAssociations = [ + { + "Name": "AWS-UpdateSSMAgent", + "AssociationId": "fbf42ad7-0e04-45e4-9a95-b5bea99fa311", + "AssociationVersion": "2", + "DocumentVersion": "$DEFAULT", + "Targets": [ + { + "Key": "InstanceIds", + "Values": [ + "*" + ] + } + ], + "LastExecutionDate": 1599393759.647, + "Overview": { + "Status": "Success", + "DetailedStatus": "Success", + "AssociationStatusAggregatedCount": { + "Success": 1 + } + }, + "ScheduleExpression": "rate(30 days)", + "AssociationName": "test-asso-959" + }, + { + "Name": "AWS-UpdateSSMAgent", + "AssociationId": "fbf42ad7-0e04-45e4-9a95-b5bea99fa311", + "AssociationVersion": "2", + "DocumentVersion": "$DEFAULT", + "Targets": [ + { + "Key": "InstanceIds", + "Values": [ + "*" + ] + } + ], + "LastExecutionDate": 1599393759.647, + "Overview": { + "Status": "Success", + "DetailedStatus": "Success", + "AssociationStatusAggregatedCount": { + "Success": 1 + } + }, + "AssociationName": "test-asso-959" + }, + { + "Name": "AWS-AttachEBSVolume", + "AssociationId": "a068476e-b759-478e-9ea0-d61e4381fefe", + "AssociationVersion": "1", + "Targets": [ + { + "Key": "aws:NoOpAutomationTag", + "Values": [ + "AWS-NoOpAutomationTarget-Value" + ] + } + ], + "LastExecutionDate": 1599378755.529, + "Overview": { + "Status": "Failed", + "DetailedStatus": "InvalidAutomationParameters", + "AssociationStatusAggregatedCount": {} + }, + "AssociationName": "test1-947" + } +]; + +const createCache = (instances, associations) => { + return { + ssm: { + describeInstanceInformation: { + 'us-east-1': { + data: instances + }, + }, + listAssociations: { + 'us-east-1': { + data: associations + } + } + }, + }; +}; + +const createErrorCache = () => { + return { + ssm: { + describeInstanceInformation: { + 'us-east-1': { + err: { + message: 'error describing instance information' + }, + }, + }, + listAssociations: { + 'us-east-1': { + err: { + message: 'error listing associations' + }, + }, + }, + }, + }; +}; + +const createNullCache = () => { + return { + ssm: { + describeInstanceInformation: { + 'us-east-1': null, + }, + listAssociations: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ssmAgentAutoUpdateEnabled', function () { + describe('run', function () { + it('should PASS if SSM Agent has SSM Agent auto update enabled', function (done) { + const cache = createCache([describeInstanceInformation[0]], listAssociations); + ssmAgentAutoUpdateEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if instance does not have SSM Agent auto update enabled', function (done) { + const cache = createCache([describeInstanceInformation[0]], listAssociations[1]); + ssmAgentAutoUpdateEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no managed instances found', function (done) { + const cache = createCache([], []); + ssmAgentAutoUpdateEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if error while fetching instance information', function (done) { + const cache = createErrorCache(); + ssmAgentAutoUpdateEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return any results if unable to fetch any instance information', function (done) { + const cache = createNullCache(); + ssmAgentAutoUpdateEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ssm/ssmAgentLatestVersion.js b/plugins/aws/ssm/ssmAgentLatestVersion.js index e3ec1869f..b07a397b9 100644 --- a/plugins/aws/ssm/ssmAgentLatestVersion.js +++ b/plugins/aws/ssm/ssmAgentLatestVersion.js @@ -3,17 +3,21 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SSM Agent Latest Version', - category: 'SSM', + category: 'EC2', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures SSM agents installed on Linux hosts are running the latest version', more_info: 'SSM agent software provides sensitive access to servers and should be kept up-to-date.', link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-agent-automatic-updates.html', recommended_action: 'Update the SSM agent on all Linux hosts to the latest version.', apis: ['SSM:describeInstanceInformation', 'STS:getCallerIdentity'], + realtime_triggers: ['ssm:CreateAssociation', 'ssm:UpdateAssociation', 'ssm:DeleteAssociation'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); @@ -41,7 +45,7 @@ module.exports = { for (var i in describeInstanceInformation.data) { var info = describeInstanceInformation.data[i]; // arn:${Partition}:ec2:${Region}:${Account}:instance/${InstanceId} - var arn = 'arn:aws:ec2:' + region + ':' + accountId + ':instance/' + info.InstanceId; + var arn = `arn:${awsOrGov}:ec2:` + region + ':' + accountId + ':instance/' + info.InstanceId; if (info.PlatformType && info.PlatformType == 'Linux' && info.PingStatus && info.PingStatus == 'Online') { diff --git a/plugins/aws/ssm/ssmDocumentPublicAccess.js b/plugins/aws/ssm/ssmDocumentPublicAccess.js new file mode 100644 index 000000000..56281fbb5 --- /dev/null +++ b/plugins/aws/ssm/ssmDocumentPublicAccess.js @@ -0,0 +1,46 @@ +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SSM Documents Public Access', + category: 'SSM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that SSM service has block public sharing setting enabled.', + more_info: 'Public documents can be viewed by all AWS accounts. To prevent unwanted access to your documents, turn on the block public access sharing setting.', + link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-share-block.html', + recommended_action: 'Enable block public sharing setting under SSM documents preferences.', + apis: ['SSM:getServiceSetting', 'STS:getCallerIdentity'], + realtime_triggers: ['ssm:UpdateServiceSetting'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const regions = helpers.regions(settings); + + const acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + const accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + for (const region of regions.ssm) { + const getServiceSetting = helpers.addSource(cache, source, + ['ssm', 'getServiceSetting', region]); + + if (!getServiceSetting) continue; + + const resource = 'arn:' + awsOrGov + ':ssm:' + region + ':' + accountId + ':servicesetting/ssm/documents/console/public-sharing-permission'; + + if (getServiceSetting.err || !getServiceSetting.data) { + helpers.addResult(results, 3, + 'Unable to query SSM service settings: ' + helpers.addError(getServiceSetting), region, resource); + continue; + } + + const isPublic = (getServiceSetting.data.SettingValue && getServiceSetting.data.SettingValue.toUpperCase() == 'ENABLE') ? + true : false; + helpers.addResult(results, isPublic ? 2 :0, + `SSM service has block public sharing ${isPublic ? 'enabled' : 'disabled'} for SSM documents`, region, resource); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/aws/ssm/ssmDocumentPublicAccess.spec.js b/plugins/aws/ssm/ssmDocumentPublicAccess.spec.js new file mode 100644 index 000000000..3052d588e --- /dev/null +++ b/plugins/aws/ssm/ssmDocumentPublicAccess.spec.js @@ -0,0 +1,109 @@ +const expect = require('chai').expect; +const ssmDocumentPublicAccess = require('./ssmDocumentPublicAccess'); + +const serviceSetting = [ + { + "SettingId": "/ssm/documents/console/public-sharing-permission", + "SettingValue": "Enable", + "LastModifiedDate": "2021-09-04T20:34:24.668000+00:00", + "LastModifiedUser": "arn:aws:iam::000111222333:user/umair", + "ARN": "arn:aws:ssm:us-east-1:000111222333:servicesetting/ssm/documents/console/public-sharing-permission", + "Status": "Customized" + }, + { + "SettingId": "/ssm/documents/console/public-sharing-permission", + "SettingValue": "Disable", + "LastModifiedDate": "2021-09-04T20:34:24.668000+00:00", + "LastModifiedUser": "arn:aws:iam::000111222333:user/umair", + "ARN": "arn:aws:ssm:us-east-1:000111222333:servicesetting/ssm/documents/console/public-sharing-permission", + "Status": "Customized" + }, + { + "SettingId": "/ssm/documents/console/public-sharing-permission", + "LastModifiedDate": "2021-09-04T20:34:24.668000+00:00", + "LastModifiedUser": "arn:aws:iam::000111222333:user/umair", + "ARN": "arn:aws:ssm:us-east-1:000111222333:servicesetting/ssm/documents/console/public-sharing-permission", + "Status": "Customized" + } +] + +const createCache = (serviceSetting) => { + return { + ssm:{ + getServiceSetting: { + 'us-east-1': { + data: serviceSetting + } + } + } + } +} + +const createErrorCache = () => { + return { + ssm:{ + getServiceSetting: { + 'us-east-1': { + err: { + message: 'error describing SSM service settings' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ssm:{ + getServiceSetting: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ssmDocumentPublicAccess', function () { + describe('run', function () { + it('should PASS if SSM service has block public sharing is disabled', function (done) { + const cache = createCache(serviceSetting[1]); + ssmDocumentPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SSM service has block public sharing disabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if SSM service has block public sharing is enabled', function (done) { + const cache = createCache(serviceSetting[0]); + ssmDocumentPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SSM service has block public sharing enabled for SSM documents'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query for SSM service settings', function (done) { + const cache = createErrorCache(); + ssmDocumentPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should not return anything if get service setting response not found', function (done) { + const cache = createNullCache(); + ssmDocumentPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ssm/ssmEncryptedParameters.js b/plugins/aws/ssm/ssmEncryptedParameters.js index f8c9d46b2..116e63095 100644 --- a/plugins/aws/ssm/ssmEncryptedParameters.js +++ b/plugins/aws/ssm/ssmEncryptedParameters.js @@ -4,17 +4,38 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'SSM Encrypted Parameters', category: 'SSM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures SSM Parameters are encrypted', more_info: 'SSM Parameters should be encrypted. This allows their values to be used by approved systems, while restricting access to other users of the account.', link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-about.html#sysman-paramstore-securestring', recommended_action: 'Recreate unencrypted SSM Parameters with Type set to SecureString.', - apis: ['SSM:describeParameters', 'STS:getCallerIdentity'], + apis: ['SSM:describeParameters', 'STS:getCallerIdentity', 'KMS:listAliases', 'KMS:listKeys', 'KMS:describeKey'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest', pci: 'PCI requires proper encryption of cardholder data at rest. SSM ' + - 'encryption should be enabled for all parameters storing this type ' + - 'of data.' + 'encryption should be enabled for all parameters storing this type ' + + 'of data.' }, + settings: { + ssm_encryption_level: { + name: 'SSM Minimum Encryption Level', + description: 'In order (lowest to highest) \ + awskms=AWS-managed KMS; \ + awscmk=Customer managed KMS; \ + externalcmk=Customer managed externally sourced KMS; \ + cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + }, + allow_ssm_non_secure_strings: { + name: 'Allow SSM Non-Secure Strings', + description: 'Allow for non-secure strings to pass', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['ssm:PutParameter', 'ssm:DeleteParameter'], run: function(cache, settings, callback) { var results = []; @@ -22,8 +43,19 @@ module.exports = { var regions = helpers.regions(settings); var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + var config = { + ssm_encryption_level: settings.ssm_encryption_level || this.settings.ssm_encryption_level.default, + allow_ssm_non_secure_strings: settings.allow_ssm_non_secure_strings || this.settings.allow_ssm_non_secure_strings.default + }; + + config.allow_ssm_non_secure_strings = (config.allow_ssm_non_secure_strings == 'true'); + + var desiredEncryptionLevelString = settings.ssm_encryption_level || this.settings.ssm_encryption_level.default; + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(desiredEncryptionLevelString); + async.each(regions.ssm, function(region, rcb){ var describeParameters = helpers.addSource(cache, source, ['ssm', 'describeParameters', region]); @@ -32,7 +64,7 @@ module.exports = { if (describeParameters.err || !describeParameters.data) { helpers.addResult(results, 3, - 'Unable to query for Parameters: ' + helpers.addError(describeParameters), region); + `Unable to query for Parameters: ${helpers.addError(describeParameters)}`, region); return rcb(); } @@ -41,18 +73,68 @@ module.exports = { return rcb(); } - for (var i in describeParameters.data) { - var param = describeParameters.data[i]; - var arn = 'arn:aws:ssm:' + region + ':' + accountId + ':parameter/' + param.Name; + var aliases = helpers.addSource(cache, source, ['kms', 'listAliases', region]); - if (param.Type != 'SecureString') { + if (!aliases || aliases.err || !aliases.data) { + helpers.addResult(results, 3, `Unable to query KMS Aliases: ${helpers.addError(aliases)}`, region); + return rcb(); + } + + async.each(describeParameters.data, function(param, pcb){ + var parameterName = param.Name.charAt(0) === '/' ? param.Name.substr(1) : param.Name; + var arn = `arn:${awsOrGov}:ssm:${region}:${accountId}:parameter/${parameterName}`; + + if (param.Type != 'SecureString' && !config.allow_ssm_non_secure_strings) { helpers.addResult(results, 2, 'Non-SecureString Parameters present', region, arn); + return pcb(); + } + + if (param.Type != 'SecureString' && config.allow_ssm_non_secure_strings) { + helpers.addResult(results, 0, 'Non-SecureString Parameters present but are allowed', region, arn); + return pcb(); + } + + var keyId; + if (!param.KeyId) { + helpers.addResult(results, 2, 'SSM Parameters is not encrypted', region, arn); + return pcb(); + } + + if (param.KeyId.includes('alias')) { + var alias = aliases.data.find(a => a.AliasName === param.KeyId); + if (!alias || !alias.TargetKeyId) { + helpers.addResult(results, 3, `Unable to locate alias: ${param.KeyId} for SSM Parameter`, region, arn); + return pcb(); + } + keyId = alias.TargetKeyId; } else { - helpers.addResult(results, 0, 'Parameter of Type SecureString', region, arn); + keyId = param.KeyId.split('/')[1]; + } + + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, `Unable to query KMS Key: ${keyId}`, region, arn); + return pcb(); + } + + var currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel < desiredEncryptionLevel) { + helpers.addResult(results, 2, + `SSM Parameter is encrypted to ${currentEncryptionLevelString}, which is lower than the desired ${desiredEncryptionLevelString} level`, + region, arn); + } else { + helpers.addResult(results, 0, + `SSM Parameter is encrypted to a minimum desired level of ${desiredEncryptionLevelString}`, + region, arn); } - } - rcb(); + pcb(); + }, function(){ + rcb(); + }); }, function(){ callback(null, results, source); }); diff --git a/plugins/aws/ssm/ssmEncryptedParameters.spec.js b/plugins/aws/ssm/ssmEncryptedParameters.spec.js new file mode 100644 index 000000000..c7a4d4b60 --- /dev/null +++ b/plugins/aws/ssm/ssmEncryptedParameters.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +var ssmEncryptedParameters = require('./ssmEncryptedParameters') + +const describeParameters = [ + { + "Name": "test-param2", + "Type": "SecureString", + "KeyId": "alias/aws/ssm", + "LastModifiedDate": "1605235841.342", + "LastModifiedUser": "arn:aws:iam::111122223333:user/cloudexploit", + "Version": 1, + "Tier": "Standard", + "Policies": [], + "DataType": "text" + }, + { + "Name": "test-param", + "Type": "String", + "LastModifiedDate": "1605235821.506", + "LastModifiedUser": "arn:aws:iam::111122223333:user/cloudexploit", + "Version": 1, + "Tier": "Standard", + "Policies": [], + "DataType": "text" + } +]; + +const listAliases = [ + { + "AliasName": "alias/aws/ssm", + "AliasArn": "arn:aws:kms:us-east-1:112233445566:alias/aws/ssm", + "TargetKeyId": "0723d7e2-8655-4553-b4e3-20084f6bddba" + }, +]; + +const listKeys = [ + { + "KeyId": "0723d7e2-8655-4553-b4e3-20084f6bddba", + "KeyArn": "arn:aws:kms:us-east-1:112233445566:key/0723d7e2-8655-4553-b4e3-20084f6bddba" + }, + { + "KeyId": "080891c0-b3a8-42a3-91be-c23aa7b46d3f", + "KeyArn": "arn:aws:kms:us-east-1:112233445566:key/080891c0-b3a8-42a3-91be-c23aa7b46d3f" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "KeyId": '0723d7e2-8655-4553-b4e3-20084f6bddba', + "Arn": 'arn:aws:kms:us-east-1:112233445566:key/0723d7e2-8655-4553-b4e3-20084f6bddba', + "Origin": 'AWS_KMS', + "KeyManager": 'AWS', + } + } +]; + +const createCache = (parameters, listKeys, listAliases, describeKey) => { + var keyId = (listKeys && listKeys.length) ? listKeys[0].KeyId : null; + return { + ssm: { + describeParameters: { + 'us-east-1': { + data: parameters + } + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: listKeys + } + }, + listAliases: { + 'us-east-1': { + data: listAliases + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + data: describeKey + } + } + } + }, + sts: { + getCallerIdentity: { + data: '112233445566' + } + } + }; +}; + +const createErrorCache = () => { + return { + ssm:{ + describeParameters: { + 'us-east-1': { + err: { + message: 'error describing SSM parameters' + }, + }, + } + }, + }; +}; + +const createNullCache = () => { + return { + ssm:{ + describeParameters: { + 'us-east-1': null, + }, + }, + }; +}; + +describe('ssmEncryptedParameters', function () { + describe('run', function () { + it('should PASS if parameter is encrypted to minimum desired encryption level', function (done) { + const cache = createCache([describeParameters[0]], listKeys, listAliases, describeKey[0]); + ssmEncryptedParameters.run(cache, { ssm_encryption_level: 'awskms' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if parameter is not encrypted to minimum desired encryption level', function (done) { + const cache = createCache([describeParameters[0]], listKeys, listAliases, describeKey[0]); + ssmEncryptedParameters.run(cache, { ssm_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if Non-SecureString parameter present', function (done) { + const cache = createCache([describeParameters[1]], listKeys, listAliases); + ssmEncryptedParameters.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if Non-SecureString parameter present but allowed', function (done) { + const cache = createCache([describeParameters[1]], listKeys, listAliases); + ssmEncryptedParameters.run(cache, { allow_ssm_non_secure_strings: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if no parameters present', function (done) { + const cache = createCache([]); + ssmEncryptedParameters.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if unable to query for SSM parameters', function (done) { + const cache = createErrorCache(); + ssmEncryptedParameters.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if describe parameters response not found', function (done) { + const cache = createNullCache(); + ssmEncryptedParameters.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/aws/ssm/ssmManagedInstances.js b/plugins/aws/ssm/ssmManagedInstances.js new file mode 100644 index 000000000..9cd7d932a --- /dev/null +++ b/plugins/aws/ssm/ssmManagedInstances.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SSM Managed Instances', + category: 'EC2', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure that all Amazon EC2 instances are managed by AWS Systems Manager (SSM).', + more_info: 'Systems Manager simplifies AWS cloud resource management, quickly detects and resolve operational problems, and makes it easier to operate and manage your instances securely at large scale.', + recommended_action: 'Configure AWS EC2 instance as SSM Managed Instances', + link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/managed_instances.html', + apis: ['EC2:describeInstances', 'SSM:describeInstanceInformation', 'STS:getCallerIdentity'], + realtime_triggers: ['ssm:CreateAssociation', 'ec2:RunInstances', 'ec2:AssociateIamInstanceProfile', 'ec2:TerminateInsatance', 'ssm:DeleteAssociation'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.ssm, function(region, rcb){ + var describeInstances = helpers.addSource(cache, source, + ['ec2', 'describeInstances', region]); + + if (!describeInstances) return rcb(); + + if (!describeInstances.data || describeInstances.err) { + helpers.addResult(results, 3, 'Unable to query for EC2 instances: ' + helpers.addError(describeInstances), region); + return rcb(); + } + + let ec2Instances = describeInstances.data.map((reservation) => reservation.Instances).flat(); + + if (!ec2Instances.length) { + helpers.addResult(results, 0, 'No EC2 instances found', region); + return rcb(); + } + + var describeInstanceInformation = helpers.addSource(cache, source, + ['ssm', 'describeInstanceInformation', region]); + + if (!describeInstanceInformation || describeInstanceInformation.err || !describeInstanceInformation.data) { + helpers.addResult(results, 3, + 'Unable to query instance information: ' + helpers.addError(describeInstanceInformation), region); + return rcb(); + } + + for (let ec2Instance of ec2Instances) { + const arn = `arn:${awsOrGov}:ec2:${region}:${accountId}:instance/${ec2Instance.InstanceId}`; + + if (ec2Instance.State.Name === 'running') { + let instanceInfo = describeInstanceInformation.data.find((instanceInfo) => instanceInfo.InstanceId && instanceInfo.InstanceId === ec2Instance.InstanceId); + + if (instanceInfo) { + helpers.addResult(results, 0, `EC2 Instance: ${ec2Instance.InstanceId} is managed by AWS Systems Manager`, region, arn); + } else { + helpers.addResult(results, 2, `EC2 Instance: ${ec2Instance.InstanceId} is not managed by AWS Systems Manager`, region, arn); + } + } else { + helpers.addResult(results, 0, `EC2 Instance: ${ec2Instance.InstanceId} is not in running state`, region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ssm/ssmManagedInstances.spec.js b/plugins/aws/ssm/ssmManagedInstances.spec.js new file mode 100644 index 000000000..17eab06a7 --- /dev/null +++ b/plugins/aws/ssm/ssmManagedInstances.spec.js @@ -0,0 +1,431 @@ +var expect = require('chai').expect; +const ssmManagedInstances = require('./ssmManagedInstances'); + +const describeInstances = [ + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0ed9277fb7eb570c9", + "InstanceId": "i-0ccdd1122ddccdd", + "InstanceType": "t2.micro", + "KeyName": "test", + "LaunchTime": "2021-12-19T19:49:14+00:00", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1a", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212", + "ProductCodes": [], + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIpAddress": "54.89.182.216", + "State": { + "Code": 16, + "Name": "running" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-02ed4181800d4658b", + "VpcId": "vpc-0f4f4575a74fac014", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2021-12-19T19:49:15+00:00", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-0ebea24b6b5ab89d5" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPARPGOCGXS55MJYEHU6" + }, + "NetworkInterfaces": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIp": "54.89.182.216" + }, + "Attachment": { + "AttachTime": "2021-12-19T19:49:14+00:00", + "AttachmentId": "eni-attach-0f5bb44c6fbee9f02", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached", + "NetworkCardIndex": 0 + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-06866e2098b1cf826" + } + ], + "Ipv6Addresses": [], + "MacAddress": "12:69:df:6f:57:67", + "NetworkInterfaceId": "eni-0686b6b3e47bdc6c9", + "OwnerId": "111222333444", + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212", + "PrivateIpAddresses": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIp": "54.89.182.216" + }, + "Primary": true, + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-02ed4181800d4658b", + "VpcId": "vpc-0f4f4575a74fac014", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-06866e2098b1cf826" + } + ], + "SourceDestCheck": true, + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled", + "HttpProtocolIpv6": "disabled" + }, + "EnclaveOptions": { + "Enabled": false + }, + "PlatformDetails": "Linux/UNIX", + "UsageOperation": "RunInstances", + "UsageOperationUpdateTime": "2021-12-19T19:49:14+00:00" + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-07a34e57731d9d38c" + }, + { + "Groups": [], + "Instances": [ + { + "AmiLaunchIndex": 0, + "ImageId": "ami-0ed9277fb7eb570c9", + "InstanceId": "i-0ccdd1122ddccdd", + "InstanceType": "t2.micro", + "KeyName": "test", + "LaunchTime": "2021-12-19T19:49:14+00:00", + "Monitoring": { + "State": "disabled" + }, + "Placement": { + "AvailabilityZone": "us-east-1a", + "GroupName": "", + "Tenancy": "default" + }, + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212", + "ProductCodes": [], + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIpAddress": "54.89.182.216", + "State": { + "Code": 16, + "Name": "stopped" + }, + "StateTransitionReason": "", + "SubnetId": "subnet-02ed4181800d4658b", + "VpcId": "vpc-0f4f4575a74fac014", + "Architecture": "x86_64", + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "AttachTime": "2021-12-19T19:49:15+00:00", + "DeleteOnTermination": true, + "Status": "attached", + "VolumeId": "vol-0ebea24b6b5ab89d5" + } + } + ], + "ClientToken": "", + "EbsOptimized": false, + "EnaSupport": true, + "Hypervisor": "xen", + "IamInstanceProfile": { + "Arn": "arn:aws:iam::111222333444:instance-profile/AmazonSSMRoleForInstancesQuickSetup", + "Id": "AIPARPGOCGXS55MJYEHU6" + }, + "NetworkInterfaces": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIp": "54.89.182.216" + }, + "Attachment": { + "AttachTime": "2021-12-19T19:49:14+00:00", + "AttachmentId": "eni-attach-0f5bb44c6fbee9f02", + "DeleteOnTermination": true, + "DeviceIndex": 0, + "Status": "attached", + "NetworkCardIndex": 0 + }, + "Description": "", + "Groups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-06866e2098b1cf826" + } + ], + "Ipv6Addresses": [], + "MacAddress": "12:69:df:6f:57:67", + "NetworkInterfaceId": "eni-0686b6b3e47bdc6c9", + "OwnerId": "111222333444", + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212", + "PrivateIpAddresses": [ + { + "Association": { + "IpOwnerId": "amazon", + "PublicDnsName": "ec2-54-89-182-216.compute-1.amazonaws.com", + "PublicIp": "54.89.182.216" + }, + "Primary": true, + "PrivateDnsName": "ip-172-31-91-212.ec2.internal", + "PrivateIpAddress": "172.31.91.212" + } + ], + "SourceDestCheck": true, + "Status": "in-use", + "SubnetId": "subnet-02ed4181800d4658b", + "VpcId": "vpc-0f4f4575a74fac014", + "InterfaceType": "interface" + } + ], + "RootDeviceName": "/dev/xvda", + "RootDeviceType": "ebs", + "SecurityGroups": [ + { + "GroupName": "launch-wizard-1", + "GroupId": "sg-06866e2098b1cf826" + } + ], + "SourceDestCheck": true, + "VirtualizationType": "hvm", + "CpuOptions": { + "CoreCount": 1, + "ThreadsPerCore": 1 + }, + "CapacityReservationSpecification": { + "CapacityReservationPreference": "open" + }, + "HibernationOptions": { + "Configured": false + }, + "MetadataOptions": { + "State": "applied", + "HttpTokens": "optional", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled", + "HttpProtocolIpv6": "disabled" + }, + "EnclaveOptions": { + "Enabled": false + }, + "PlatformDetails": "Linux/UNIX", + "UsageOperation": "RunInstances", + "UsageOperationUpdateTime": "2021-12-19T19:49:14+00:00" + } + ], + "OwnerId": "111222333444", + "ReservationId": "r-07a34e57731d9d38c" + } +]; + +const describeInstanceInformation = [ + { + "InstanceId": "i-0ccdd1122ddccdd", + "PingStatus": "Online", + "LastPingDateTime": "2021-12-19T15:20:09.764000-08:00", + "AgentVersion": "3.1.715.0", + "IsLatestVersion": true, + "PlatformType": "Linux", + "PlatformName": "Amazon Linux", + "PlatformVersion": "2", + "ResourceType": "EC2Instance", + "IPAddress": "172.31.91.212", + "ComputerName": "ip-172-31-91-212.ec2.internal", + "AssociationStatus": "Success", + "LastAssociationExecutionDate": "2021-12-19T15:17:22.848000-08:00", + "LastSuccessfulAssociationExecutionDate": "2021-12-19T15:17:22.848000-08:00", + "AssociationOverview": { + "DetailedStatus": "Success", + "InstanceAssociationStatusAggregatedCount": { + "Success": 4 + } + } + } +]; + + + +const createCache = (instances, instanceInfo) => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + data: instances + } + } + }, + ssm: { + describeInstanceInformation: { + 'us-east-1': { + data: instanceInfo + } + } + }, + sts: { + getCallerIdentity: { + data: '012345678911' + } + } + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeInstances: { + 'us-east-1': { + err: { + message: 'error describing ec2 instances' + }, + } + } + }, + ssm: { + describeInstanceInformation: { + 'us-east-1': { + err: { + message: 'error describing instance information' + }, + } + } + } + }; +}; + +describe('ssmManagedInstances', function () { + describe('run', function () { + it('should PASS if there are no ec2 Instance reservations', function (done) { + const cache = createCache([], describeInstanceInformation); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if there are no ec2 instances', function (done) { + const cache = createCache([], []); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + + it('should PASS if EC2 instance is being managed by AWS SSM', function (done) { + const cache = createCache([describeInstances[0]], [describeInstanceInformation[0]]); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if EC2 instance is not in the Running State', function (done) { + const cache = createCache([describeInstances[1]], [describeInstanceInformation[0]]); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if there are ec2 instances and ssmManagedInstances but ec2 instance is not managed by SSM', function (done) { + const cache = createCache([ + { + Instances: [ + { + InstanceId: 'i-abc1234', + "State": { + "Code": 16, + "Name": "running" + } + + } + ] + } + ], describeInstanceInformation); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if error while fetching ec2 instances', function (done) { + const cache = createErrorCache(); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if error while fetching ssm instance information', function (done) { + const cache = createErrorCache(); + ssmManagedInstances.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + }); +}); diff --git a/plugins/aws/ssm/ssmSessionDuration.js b/plugins/aws/ssm/ssmSessionDuration.js new file mode 100644 index 000000000..56978e073 --- /dev/null +++ b/plugins/aws/ssm/ssmSessionDuration.js @@ -0,0 +1,91 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'SSM Session Duration', + category: 'EC2', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure that all active sessions in the AWS Session Manager do not exceed the duration set in the settings.', + more_info: 'The session manager gives users the ability to either open a shell in a EC2 instance or execute commands in a ECS task. This can be useful for when debugging issues in a container or instance.', + recommended_action: 'Terminate all the sessions which exceed the specified duration mentioned in settings.', + link: 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-preferences-max-timeout.html', + apis: ['SSM:describeSessions'], + settings: { + ssm_session_max_duration: { + name: 'Max Duration for SSM Session', + description: 'Maximum duration in minutes for SSM session.', + regex: '^((1440)|(14[0-3][0-9]{1})|(1[0-3][0-9]{2})|([1-9][0-9]{2})|([1-9][0-9]{1})|([1-9]))$', + default: '5' + } + }, + realtime_triggers: ['ssm:StartSession', 'ssm:TerminateSession'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var sessionMaxDuration = settings.ssm_session_max_duration || this.settings.ssm_session_max_duration.default; + + if (!sessionMaxDuration || !sessionMaxDuration.trim().length) return callback(null, results, source); + + async.each(regions.ssm, function(region, rcb){ + var describeSessions = helpers.addSource(cache, source, + ['ssm', 'describeSessions', region]); + + if (!describeSessions) return rcb(); + + if (describeSessions.err || !describeSessions.data) { + helpers.addResult(results, 3, + 'Unable to query for active SSM sessions: ' + helpers.addError(describeSessions), region); + return rcb(); + } + + if (!describeSessions.data.length) { + helpers.addResult(results, 0, + 'No Active SSM sessions found: ' + helpers.addError(describeSessions), region); + return rcb(); + } + + const uniqInstances = describeSessions.data.filter((value, index, self) => + index === self.findIndex((t) => (t.Target && value.Target && t.Target === value.Target)) + ); + + const sessionsByInstances = uniqInstances.map((instance) => { + return { instanceId: instance.Target, sessions: describeSessions.data.filter(session => session.Target === instance.Target) }; + }); + + for (let instance of sessionsByInstances) { + var resource = `arn:${awsOrGov}:ec2:${region}:${accountId}:instance/${instance.instanceId}`; + + let failingSessions = ''; + for (let session of instance.sessions) { + let activeSessionTimeInMins = helpers.minutesBetween(new Date(), session.StartDate); + + if (sessionMaxDuration && sessionMaxDuration < activeSessionTimeInMins) { + failingSessions += `${session.SessionId} - ${activeSessionTimeInMins} mins\n`; + } + } + + if (failingSessions.length) { + helpers.addResult(results, 2, + `Following SSM Sessions duration length is greater than \ + the max time threshold: ${failingSessions}`, + region, resource); + } else { + helpers.addResult(results, 0, + 'All SSM Sessions duration length is less than the max time threshold', region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/ssm/ssmSessionDuration.spec.js b/plugins/aws/ssm/ssmSessionDuration.spec.js new file mode 100644 index 000000000..aeb0fad46 --- /dev/null +++ b/plugins/aws/ssm/ssmSessionDuration.spec.js @@ -0,0 +1,122 @@ +var expect = require('chai').expect; +const ssmSessionDuration = require('./ssmSessionDuration'); + +const describeSessions = [ + { + "SessionId": "test-0cc5ea893bcf25c12", + "Target": "i-0cabb616c72195cec", + "Status": "Connected", + "StartDate": new Date(Math.abs(new Date() - 30 * 60000)), + "Owner": "arn:aws:iam::111222333444:user/test", + "Details": "", + "OutputUrl": { + "S3OutputUrl": "", + "CloudWatchOutputUrl": "" + }, + "MaxSessionDuration": "20" + }, + { + "SessionId": "test-0cc5ea893bcf25c12", + "Target": "i-0cabb616c72195cec", + "Status": "Connected", + "StartDate": new Date(Math.abs(new Date() - 30 * 60000)), + "Owner": "arn:aws:iam::111222333444:user/test", + "Details": "", + "OutputUrl": { + "S3OutputUrl": "", + "CloudWatchOutputUrl": "" + }, + "MaxSessionDuration": "50" + }, + { + "SessionId": "test-0cc5ea893bcf25c12", + "Target": "i-0cabb616c72195cec", + "Status": "Connected", + "StartDate": new Date(Math.abs(new Date() - 30 * 60000)), + "Owner": "arn:aws:iam::111222333444:user/test", + "Details": "", + "OutputUrl": { + "S3OutputUrl": "", + "CloudWatchOutputUrl": "" + }, + "MaxSessionDuration": "50" + }, + { + "SessionId": "test-0cc5ea893bcf25c15", + "Target": "i-0cabb616c72195cec", + "Status": "Connected", + "StartDate": new Date(Math.abs(new Date() - 20 * 60000)), + "Owner": "arn:aws:iam::111222333444:user/test", + "Details": "", + "OutputUrl": { + "S3OutputUrl": "", + "CloudWatchOutputUrl": "" + } + }, +]; + +const createCache = (sessions) => { + return { + ssm: { + describeSessions: { + 'us-east-1': { + data: sessions + } + } + } + }; +}; + +const createErrorCache = () => { + return { + ssm: { + describeSessions: { + 'us-east-1': { + err: { + message: 'error describing instance information' + }, + } + } + } + }; +}; + +describe('ssmSessionDuration', function () { + describe('run', function () { + it('should PASS if there are no active sessions under SSM Session Manager', function (done) { + const cache = createCache([]); + ssmSessionDuration.run(cache, { ssm_session_max_duration: '40' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if the session`s active time is within the max time limit set in SSM Session Manager', function (done) { + const cache = createCache([describeSessions[1]]); + ssmSessionDuration.run(cache, { ssm_session_max_duration: '40' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if the session`s active time is greater than the max time limit set in SSM Session Manager', function (done) { + const cache = createCache([describeSessions[0]]); + ssmSessionDuration.run(cache, { ssm_session_max_duration: '20' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should UNKNOWN if error while fetching active sessions', function (done) { + const cache = createErrorCache(); + ssmSessionDuration.run(cache, { ssm_session_max_duration: '40' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.js b/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.js new file mode 100644 index 000000000..dd6b36ff2 --- /dev/null +++ b/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.js @@ -0,0 +1,106 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Timestream Database Encrypted', + category: 'Timestream', + domain: 'Databases', + severity: 'High', + description: 'Ensure that AWS Timestream databases are encrypted with KMS Customer Master Keys (CMKs) instead of AWS managed-keys.', + more_info: 'Timestream encryption at rest provides enhanced security by encrypting all your data at rest using encryption keys. ' + + 'This functionality helps reduce the operational burden and complexity involved in protecting sensitive data. ' + + 'With encryption at rest using customer-managed keys, you can build security-sensitive applications that meet strict encryption compliance and regulatory requirements. ', + recommended_action: 'Modify Timestream database encryption configuration to use desired encryption key', + link: 'https://docs.aws.amazon.com/timestream/latest/developerguide/EncryptionAtRest.html', + apis: ['TimestreamWrite:listDatabases', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + timestream_databases_desired_encryption_level: { + name: 'Timestream Database Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['timestreamwrite:CreateDatabase', 'timestreamwrite:UpdateDatabase', 'timestreamwrite:DeleteDatabase'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.timestream_databases_desired_encryption_level || this.settings.timestream_databases_desired_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.timestreamwrite, function(region, rcb){ + var listDatabases = helpers.addSource(cache, source, + ['timestreamwrite', 'listDatabases', region]); + + if (!listDatabases) return rcb(); + + if (listDatabases.err || !listDatabases.data) { + helpers.addResult(results, 3, `Unable to query Timestream databases: ${helpers.addError(listDatabases)}`, region); + return rcb(); + } + + if (!listDatabases.data.length) { + helpers.addResult(results, 0, 'No Timestream databases found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let database of listDatabases.data) { + if (!database.Arn) continue; + + let resource = database.Arn; + + if (database.KmsKeyId) { + var kmsKeyId = database.KmsKeyId.split('/')[1] ? database.KmsKeyId.split('/')[1] : database.KmsKeyId; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, database.KmsKeyId); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Timestream database is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Timestream database is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.spec.js b/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.spec.js new file mode 100644 index 000000000..2ad5982e9 --- /dev/null +++ b/plugins/aws/timestreamwrite/timestreamDatabaseEncrypted.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var timestreamDatabaseEncrypted = require('./timestreamDatabaseEncrypted'); + +const listDatabases = [ + { + "Arn": "arn:aws:timestream:us-east-1:000011112222:database/akhtar-db1", + "DatabaseName": "akhtar-db1", + "TableCount": 0, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/b6d7913b-5604-47c5-8291-8270a1abab58", + "CreationTime": "2021-11-12T03:27:12.190000-08:00", + "LastUpdatedTime": "2021-11-12T03:27:12.190000-08:00" + }, + { + "Arn": "arn:aws:timestream:us-east-1:000011112222:database/sampleDB", + "DatabaseName": "sampleDB", + "TableCount": 0, + "KmsKeyId": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationTime": "2021-11-12T02:59:33.357000-08:00", + "LastUpdatedTime": "2021-11-12T02:59:33.357000-08:00" + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + }, + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "2cff2321-73c6-4bac-95eb-bc9633d3e8a9", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/b6d7913b-5604-47c5-8291-8270a1abab58", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (databases, keys, describeKey, databasesErr, keysErr, describeKeyErr) => { + var keyId = (databases && databases.length && databases[0].KmsKeyId) ? databases[0].KmsKeyId.split('/')[1] : null; + return { + timestreamwrite: { + listDatabases: { + 'us-east-1': { + err: databasesErr, + data: databases + }, + }, + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + + +describe('timestreamDatabaseEncrypted', function () { + describe('run', function () { + it('should PASS if Timestream database is encrypted with desired encryption level', function (done) { + const cache = createCache([listDatabases[1]], listKeys, describeKey[0]); + timestreamDatabaseEncrypted.run(cache, { timestream_databases_desired_encryption_level: 'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Timestream database is encrypted with awscmk'); + done(); + }); + }); + + + it('should FAIL if Timestream database is not encrypted with desired encyption level', function (done) { + const cache = createCache([listDatabases[0]], listKeys, describeKey[1]); + timestreamDatabaseEncrypted.run(cache, { timestream_databases_desired_encryption_level:'awscmk' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Timestream database is encrypted with awskms'); + done(); + }); + }); + + it('should PASS if no Timestream Databases found', function (done) { + const cache = createCache([]); + timestreamDatabaseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Timestream databases found'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Timestream databases', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Timestream databases encryption" }); + timestreamDatabaseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list KMS keys" }); + timestreamDatabaseEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/transfer/transferLoggingEnabled.js b/plugins/aws/transfer/transferLoggingEnabled.js index 68141c3b8..aa73b91a3 100644 --- a/plugins/aws/transfer/transferLoggingEnabled.js +++ b/plugins/aws/transfer/transferLoggingEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Transfer Logging Enabled', category: 'Transfer', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures AWS Transfer servers have CloudWatch logging enabled.', more_info: 'AWS Transfer servers can log activity to CloudWatch if a proper IAM service role is provided. This role should be configured for all servers to ensure proper access logging.', link: 'https://docs.aws.amazon.com/transfer/latest/userguide/monitoring.html', @@ -13,6 +15,7 @@ module.exports = { hipaa: 'HIPAA requires that all data access is audited via proper logging configurations.', pci: 'PCI requires that all account access activity be logged.' }, + realtime_triggers: ['transfer:CreateServer', 'transfer:UpdateServer', 'transfer:DeleteServer'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/aws/transfer/transferLoggingEnabled.spec.js b/plugins/aws/transfer/transferLoggingEnabled.spec.js new file mode 100644 index 000000000..beb485953 --- /dev/null +++ b/plugins/aws/transfer/transferLoggingEnabled.spec.js @@ -0,0 +1,108 @@ +var expect = require('chai').expect; +var transferLoggingEnabled = require('./transferLoggingEnabled'); + +const listServers = [ + { + "Arn": "arn:aws:transfer:us-east-1:112233445566:server/s-c19caf494fe6450cb", + "IdentityProviderType": "SERVICE_MANAGED", + "EndpointType": "PUBLIC", + "LoggingRole": "arn:aws:iam::112233445566:role/service-role/testing-123-role-7t7oo29b", + "ServerId": "s-c19caf494fe6450cb", + "State": "ONLINE", + "UserCount": 0 + }, + { + "Arn": "arn:aws:transfer:us-east-1:112233445566:server/s-uyg23g3be231jb2", + "IdentityProviderType": "SERVICE_MANAGED", + "EndpointType": "PUBLIC", + "ServerId": "s-uyg23g3be231jb2", + "State": "ONLINE", + "UserCount": 0 + } +]; + +const createCache = (servers) => { + return { + transfer: { + listServers: { + 'us-east-1': { + data: servers + } + } + } + }; +}; + +const createErrorCache = () => { + return { + transfer: { + listServers: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + transfer: { + listServers: { + 'us-east-1': { + data: null + } + } + } + }; +}; + +describe('transferLoggingEnabled', function () { + describe('run', function () { + it('should PASS if logging role is properly configured for Transfer server', function (done) { + const cache = createCache([listServers[0]]); + transferLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if logging role is not properly configured for Transfer server', function (done) { + const cache = createCache([listServers[1]]); + transferLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Transfer servers found', function (done) { + const cache = createCache([]); + transferLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should unknown if unable to list Transfer servers', function (done) { + const cache = createErrorCache(); + transferLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Transfer servers response not found', function (done) { + const cache = createNullCache(); + transferLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/transfer/transferPrivateLinkInUse.js b/plugins/aws/transfer/transferPrivateLinkInUse.js new file mode 100644 index 000000000..1360a948c --- /dev/null +++ b/plugins/aws/transfer/transferPrivateLinkInUse.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'PrivateLink in Use for Transfer for SFTP Server Endpoints', + category: 'Transfer', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that AWS Transfer for SFTP server endpoints are configured to use VPC endpoints powered by AWS PrivateLink.', + more_info: 'PrivateLink provides secure and private connectivity between VPCs and other AWS resources using a dedicated network.', + link: 'https://docs.aws.amazon.com/transfer/latest/userguide/update-endpoint-type-vpc.html', + recommended_action: 'Configure the SFTP server endpoints to use endpoints powered by PrivateLink.', + apis: ['Transfer:listServers'], + realtime_triggers: ['transfer:CreateServer', 'trannsfer:UpdateServer', 'transfer:DeleteServer'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.transfer, function(region, rcb){ + var listServers = helpers.addSource(cache, source, + ['transfer', 'listServers', region]); + + if (!listServers) return rcb(); + + if (listServers.err || !listServers.data) { + helpers.addResult(results, 3, + 'Unable to query for Transfer servers: ' + helpers.addError(listServers), region); + return rcb(); + } + + if (!listServers.data.length) { + helpers.addResult(results, 0, 'No Transfer servers found', region); + return rcb(); + } + + listServers.data.forEach(server => { + const isPrivate = (server.EndpointType && server.EndpointType != 'PUBLIC') ? true : false; + helpers.addResult(results, isPrivate ? 0 : 2, + `Server '${server.ServerId}' is ${isPrivate ? '': 'not '}configured with private endpoint`, region, server.Arn); + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/transfer/transferPrivateLinkInUse.spec.js b/plugins/aws/transfer/transferPrivateLinkInUse.spec.js new file mode 100644 index 000000000..863cf5a08 --- /dev/null +++ b/plugins/aws/transfer/transferPrivateLinkInUse.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var transferPrivateLinkInUse = require('./transferPrivateLinkInUse'); + +const listServers = [ + { + "Arn": "arn:aws:transfer:us-east-1:112233445566:server/s-c19caf494fe6450cb", + "IdentityProviderType": "SERVICE_MANAGED", + "EndpointType": "PUBLIC", + "LoggingRole": "arn:aws:iam::112233445566:role/service-role/testing-123-role-7t7oo29b", + "ServerId": "s-c19caf494fe6450cb", + "State": "ONLINE", + "UserCount": 0 + }, + { + "Arn": "arn:aws:transfer:us-east-1:112233445566:server/s-uyg23g3be231jb2", + "IdentityProviderType": "SERVICE_MANAGED", + "EndpointType": "VPC-ENDPOINT", + "ServerId": "s-uyg23g3be231jb2", + "State": "ONLINE", + "UserCount": 0 + }, + { + "Arn": "arn:aws:transfer:us-east-1:112233445566:server/s-uyg23g3be231jb2", + "IdentityProviderType": "SERVICE_MANAGED", + "ServerId": "s-uyg23g3be231jb2", + "State": "ONLINE", + "UserCount": 0 + } +]; + +const createCache = (servers) => { + return { + transfer: { + listServers: { + 'us-east-1': { + data: servers + } + } + } + }; +}; + +const createErrorCache = () => { + return { + transfer: { + listServers: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + +const createNullCache = () => { + return { + transfer: { + listServers: { + 'us-east-1': { + data: null + } + } + } + }; +}; + +describe('transferPrivateLinkInUse', function () { + describe('run', function () { + it('should PASS if PrivateLink endpoints are used by server', function (done) { + const cache = createCache([listServers[1]]); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should FAIL if Public endpoints are used by server', function (done) { + const cache = createCache([listServers[0]]); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should FAIL if EndpointType property is not found in response', function (done) { + const cache = createCache([listServers[2]]); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + done(); + }); + }); + + it('should PASS if no Transfer servers found', function (done) { + const cache = createCache([]); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should unknown if unable to list Transfer servers', function (done) { + const cache = createErrorCache(); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list Transfer servers response not found', function (done) { + const cache = createNullCache(); + transferPrivateLinkInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/translate/translateJobOutputEncrypted.js b/plugins/aws/translate/translateJobOutputEncrypted.js new file mode 100644 index 000000000..e178e77e7 --- /dev/null +++ b/plugins/aws/translate/translateJobOutputEncrypted.js @@ -0,0 +1,116 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Translate Job Output Encrypted', + category: 'AI & ML', + domain: 'Compute', + severity: 'High', + description: 'Ensure that your Amazon Translate jobs have CMK encryption enabled for output data residing on S3.', + more_info: 'Amazon Translate encrypts your output data with AWS-manager keys by default. ' + + 'Encrypt your files using customer-managed keys in order to gain more granular control over encryption/decryption process.', + recommended_action: 'Create Translate jobs with customer-manager keys (CMKs).', + link: 'https://docs.aws.amazon.com/translate/latest/dg/encryption-at-rest.html', + apis: ['Translate:listTextTranslationJobs', 'KMS:listKeys', 'KMS:describeKey', 'STS:getCallerIdentity'], + settings: { + translate_job_encryption_level: { + name: 'Translate Job Target Encryption Level', + description: 'In order (lowest to highest) awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + realtime_triggers: ['translate:StartTextTranslationJob', 'translate:StopTextTranslationJob'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + desiredEncryptionLevelString: settings.translate_job_encryption_level || this.settings.translate_job_encryption_level.default + }; + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); + var currentEncryptionLevel; + + async.each(regions.translate, function(region, rcb){ + var listTextTranslationJobs = helpers.addSource(cache, source, + ['translate', 'listTextTranslationJobs', region]); + + if (!listTextTranslationJobs) return rcb(); + + if (listTextTranslationJobs.err || !listTextTranslationJobs.data) { + helpers.addResult(results, 3, + `Unable to list Translate text jobs: ${helpers.addError(listTextTranslationJobs)}`, region); + return rcb(); + } + + if (!listTextTranslationJobs.data.length) { + helpers.addResult(results, 0, + 'No Translate text jobs found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, + ['kms', 'listKeys', region]); + + if (!listKeys || listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, + `Unable to list KMS keys: ${helpers.addError(listKeys)}`, region); + return rcb(); + } + + for (let job of listTextTranslationJobs.data) { + if (!job.JobName) continue; + + var resource = `arn:${awsOrGov}:translate:${region}:${accountId}:job/${job.JobName}`; + + if (job.OutputDataConfig) { + if (job.OutputDataConfig && job.OutputDataConfig.EncryptionKey && job.OutputDataConfig.EncryptionKey.Id) { + var kmsKeyId = job.OutputDataConfig.EncryptionKey.Id.split('/')[1] ? job.OutputDataConfig.EncryptionKey.Id.split('/')[1] : job.OutputDataConfig.EncryptionKey.Id; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, job.OutputDataConfig.EncryptionKey.Id); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else { + currentEncryptionLevel = 2; //awskms + } + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Translate job is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Translate job is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find output data config for the job', + region, resource); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/translate/translateJobOutputEncrypted.spec.js b/plugins/aws/translate/translateJobOutputEncrypted.spec.js new file mode 100644 index 000000000..368bd424f --- /dev/null +++ b/plugins/aws/translate/translateJobOutputEncrypted.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var translateJobOutputEncrypted = require('./translateJobOutputEncrypted'); + +const listTextTranslationJobs = [ + { + "JobId": "03c3e7dea26241b22bcbc48624beb75f", + "JobName": "khulnasoft-job1", + "JobStatus": "IN_PROGRESS", + "JobDetails": { + "TranslatedDocumentsCount": 0, + "DocumentsWithErrorsCount": 0, + "InputDocumentsCount": 1 + }, + "SourceLanguageCode": "en", + "TargetLanguageCodes": [ + "ar" + ], + "SubmittedTime": "2021-11-11T12:50:47.661Z", + "InputDataConfig": { + "S3Uri": "s3://khulnasoftwebsite/data/", + "ContentType": "text/plain" + }, + "OutputDataConfig": { + "S3Uri": "s3://khulnasoftwebsite/data1/000011112222-TranslateText-03c3e7dea26241b22bcbc48624beb75f/", + "EncryptionKey": { + "Type": "KMS", + "Id": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } + }, + "DataAccessRoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonTranslateServiceRole-translate" + }, + { + "JobId": "03c3e7dea26241b22bcbc48624beb75f", + "JobName": "khulnasoft-job1", + "JobStatus": "IN_PROGRESS", + "JobDetails": { + "TranslatedDocumentsCount": 0, + "DocumentsWithErrorsCount": 0, + "InputDocumentsCount": 1 + }, + "SourceLanguageCode": "en", + "TargetLanguageCodes": [ + "ar" + ], + "SubmittedTime": "2021-11-11T12:50:47.661Z", + "InputDataConfig": { + "S3Uri": "s3://khulnasoftwebsite/data/", + "ContentType": "text/plain" + }, + "OutputDataConfig": { + "S3Uri": "s3://khulnasoftwebsite/data1/000011112222-TranslateText-03c3e7dea26241b22bcbc48624beb75f/", + }, + "DataAccessRoleArn": "arn:aws:iam::000011112222:role/service-role/AmazonTranslateServiceRole-translate" + } +]; + +const describeKey = [ + { + "KeyMetadata": { + "AWSAccountId": "000011112222", + "KeyId": "c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "Arn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250", + "CreationDate": "2020-12-15T01:16:53.045000+05:00", + "Enabled": true, + "Description": "Default master key that protects my Glue data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const listKeys = [ + { + "KeyId": "0604091b-8c1b-4a55-a844-8cc8ab1834d9", + "KeyArn": "arn:aws:kms:us-east-1:000011112222:key/c4750c1a-72e5-4d16-bc72-0e7b559e0250" + } +] + +const createCache = (jobs, keys, describeKey, jobsErr, keysErr, describeKeyErr) => { + var keyId = (keys && keys.length) ? keys[0].KeyArn.split('/')[1] : null; + return { + translate: { + listTextTranslationJobs: { + 'us-east-1': { + err: jobsErr, + data: jobs + }, + } + }, + kms: { + listKeys: { + 'us-east-1': { + data: keys, + err: keysErr + } + }, + describeKey: { + 'us-east-1': { + [keyId]: { + err: describeKeyErr, + data: describeKey + }, + }, + }, + }, + }; +}; + +describe('translateJobOutputEncrypted', function () { + describe('run', function () { + it('should PASS if Translate job is encrypted with desired encryption level', function (done) { + const cache = createCache([listTextTranslationJobs[0]], listKeys, describeKey[0]); + translateJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Translate job is not encrypted with desired encryption level', function (done) { + const cache = createCache([listTextTranslationJobs[1]], listKeys); + translateJobOutputEncrypted.run(cache, {translate_job_encryption_level: 'awscmk'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no Translate jobs found', function (done) { + const cache = createCache([]); + translateJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list Translate jobs', function (done) { + const cache = createCache(null, null, null, { message: "Unable to list Translate jobs" }); + translateJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list KMS keys', function (done) { + const cache = createCache(listTextTranslationJobs, null, null, null, { message: "Unable to list KMS keys" }); + translateJobOutputEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}) \ No newline at end of file diff --git a/plugins/aws/waf/wafInUse.js b/plugins/aws/waf/wafInUse.js new file mode 100644 index 000000000..11f263f47 --- /dev/null +++ b/plugins/aws/waf/wafInUse.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS WAF In Use', + category: 'WAF', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that AWS Web Application Firewall (WAF) is in use to achieve availability and security for AWS-powered web applications.', + more_info: 'Using WAF for your web application running in AWS environment can help against common web-based attacks, SQL injection attacks, DDOS attacks and more.', + link: 'https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html', + recommended_action: 'Create one or more WAF ACLs with proper actions and rules', + apis: ['WAF:listWebACLs', 'WAFRegional:listWebACLs'], + realtime_triggers: ['waf:CreateWebACL', 'waf:DeleteWebAcl'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.wafregional, function(region, rcb){ + var listRegionalWebACLs = helpers.addSource(cache, source, + ['wafregional', 'listWebACLs', region]); + + if (!listRegionalWebACLs) return rcb(); + + if (listRegionalWebACLs.err || !listRegionalWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to query for WAF: ' + helpers.addError(listRegionalWebACLs), region); + return rcb(); + } + + if (!listRegionalWebACLs.data.length) { + helpers.addResult(results, 2, 'WAF is not enabled', region); + } else { + helpers.addResult(results, 0, 'WAF is enabled', region); + } + + rcb(); + }, function(){ + var listGlobalWebACLs = helpers.addSource(cache, source, + ['waf', 'listWebACLs', regions.waf]); + + if (!listGlobalWebACLs) return callback(null, results, source); + + if (listGlobalWebACLs.err || !listGlobalWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to query for Global WAF: ' + helpers.addError(listGlobalWebACLs)); + return callback(null, results, source); + } + + if (!listGlobalWebACLs.data.length) { + helpers.addResult(results, 2, 'WAF is not enabled', 'global'); + } else { + helpers.addResult(results, 0, 'WAF is enabled', 'global'); + } + + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/waf/wafInUse.spec.js b/plugins/aws/waf/wafInUse.spec.js new file mode 100644 index 000000000..8796136bd --- /dev/null +++ b/plugins/aws/waf/wafInUse.spec.js @@ -0,0 +1,116 @@ +const expect = require('chai').expect; +var wafInUse = require('./wafInUse'); + +const webACLs = [ + { + Name: "WebACLexample", + WebACLId: "webacl-1472061481310" + }, + { + Name: "WebACLexample2", + WebACLId: "webacl-1472061481390" + } +] + +const createGlobalCache = (webACLs) => { + return { + waf: { + listWebACLs: { + 'us-east-1': { + data: webACLs + } + } + } + }; +}; + +const createRegionalCache = (webACLs) => { + return { + wafregional: { + listWebACLs: { + 'us-east-2': { + data: webACLs + }, + 'us-east-1': { + data: webACLs + } + } + } + }; +}; + +const createErrorCache = () => { + return { + waf: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + + +describe('wafInUse', function () { + describe('run', function () { + it('should PASS if WAF is being used globally', function (done) { + const cache = createGlobalCache(webACLs); + wafInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WAF is enabled'); + done(); + }); + }); + + it('should PASS if WAF is being used regionally', function (done) { + const cache = createRegionalCache(webACLs); + wafInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WAF is enabled'); + expect(results[0].region).to.include('us-east-1'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('WAF is enabled'); + expect(results[1].region).to.include('us-east-2'); + done(); + }); + }); + + it('should FAIL if no application is using WAF globally', function (done) { + const cache = createGlobalCache([]); + wafInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WAF is not enabled'); + done(); + }); + }); + + it('should FAIL if no application is using WAF regionally', function (done) { + const cache = createRegionalCache([]); + wafInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WAF is not enabled'); + expect(results[0].region).to.include('us-east-1'); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('WAF is not enabled'); + expect(results[1].region).to.include('us-east-2'); + done(); + }); + }); + + it('should unknown if unable to listWebACLs', function (done) { + const cache = createErrorCache(); + wafInUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/wafv2/aclRulesDefaultAction.js b/plugins/aws/wafv2/aclRulesDefaultAction.js new file mode 100644 index 000000000..c3b3bfcaf --- /dev/null +++ b/plugins/aws/wafv2/aclRulesDefaultAction.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web ACL Rules Default Action', + category: 'WAF', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that default Web ACL action is set to "Block" for ACL rules with allow action.', + more_info: 'Web ACL default action determines whether the incoming request is allowed or blocked when none of the rules are matched. As a security best practice, make sure it is set to ‘Block’ when you have configured web ACL rules with allow actions. This will limit the number of users accessing your web app and will reduce the scope of malicious attacks.', + link: 'https://docs.aws.amazon.com/waf/latest/APIReference/API_DefaultAction.html', + recommended_action: 'Modify Web ACL and set default action to block requests.', + apis: ['WAFV2:listWebACLs', 'WAFV2:getWebACL'], + realtime_triggers: ['wafv2:CreateWebACL', 'wafv2:UpdateWebACL','wafv2:DeleteWebACL'], + + run: function(cache, settings, callback) { + var results = []; + var source = { }; + var regions = helpers.regions(settings); + + async.each(regions.wafregional, function(region, rcb){ + var listWebACLs = helpers.addSource(cache, source, + ['wafv2', 'listWebACLs', region]); + + if (!listWebACLs) return rcb(); + + if (listWebACLs.err || !listWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to list WAFV2 web ACLs: ' + helpers.addError(listWebACLs), region); + return rcb(); + } + + for (let webAcl of listWebACLs.data){ + if (!webAcl.ARN) continue; + + let getWebACL = helpers.addSource(cache, source, + ['wafv2', 'getWebACL', region, webAcl.ARN]); + + if (!getWebACL || !getWebACL.data || getWebACL.err || !getWebACL.data.WebACL){ + helpers.addResult(results, 3, + 'Unable to get web ACL details: ' + helpers.addError(listWebACLs), region, webAcl.ARN); + continue; + } + + if (getWebACL.data.WebACL.DefaultAction && getWebACL.data.WebACL.DefaultAction.Block ){ + helpers.addResult(results, 0, 'Default action for web ACL rule is set to Block', region, webAcl.ARN); + } else { + helpers.addResult(results, 2, 'Default action for web ACL rule is not set to Block', region, webAcl.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/wafv2/aclRulesDefaultAction.spec.js b/plugins/aws/wafv2/aclRulesDefaultAction.spec.js new file mode 100644 index 000000000..37ae23181 --- /dev/null +++ b/plugins/aws/wafv2/aclRulesDefaultAction.spec.js @@ -0,0 +1,108 @@ +const expect = require('chai').expect; +var aclRulesDefaultAction = require('./aclRulesDefaultAction'); + +const webACLs = [ + { + Name: "WebACLexample", + Id: "234", + ARN: "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234" + }, +] +const getWebAcl = [ + { + WebACL: { + "ARN": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234", + "DefaultAction": { + "Block": {}, + }, + + } + }, + { + WebACL:{ + "ARN": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234", + "DefaultAction": { + "Allow": {}, + }, + + } + } +] + +const createCache = (webACLs, getWebAcl) => { + var arn = (webACLs && webACLs.length) ? webACLs[0].ARN : null; + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + data: webACLs + } + }, + getWebACL: { + 'us-east-1': { + [arn]: { + data: getWebAcl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + + +describe('aclRulesDefaultAction', function () { + describe('run', function () { + it('should PASS if Default action for web ACL rule is to Block', function (done) { + const cache = createCache([webACLs[0]], getWebAcl[0]); + aclRulesDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default action for web ACL rule is set to Block'); + done(); + }); + }); + + it('should FAIL if Default action for web ACL rule is not Block', function (done) { + const cache = createCache([webACLs[0]], getWebAcl[1]); + aclRulesDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default action for web ACL rule is not set to Block'); + done(); + }); + }); + + it('should unknown if unable to listWebACLs', function (done) { + const cache = createErrorCache(); + aclRulesDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list WAFV2 web ACLs:') + done(); + }); + }); + it('should unknown if unable to get web acl details', function (done) { + const cache = createCache([webACLs[0]], null) + aclRulesDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get web ACL details:') + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.js b/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.js new file mode 100644 index 000000000..3e8438c0b --- /dev/null +++ b/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS WAFV2 Cloudwatch Metrics Enabled', + category: 'WAF', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that AWS CloudWatch metrics is enabled for WAFV2 Web ACL rules.', + more_info: 'As a security best practice, make sure to enable CloudWatch metrics for all the configured Web ACL rules. These metrics are useful in understanding the access patterns for your web application like allowed, blocked or passed requests based on the rules evaluation.', + link: 'https://docs.aws.amazon.com/waf/latest/developerguide/monitoring-cloudwatch.html', + recommended_action: 'Modify WAFv2 and enable cloud watch metrics.', + apis: ['WAFV2:listWebACLs', 'WAFV2:getWebACL'], + realtime_triggers: ['wafv2:CreateWebACL','wafv2:updateWebACL', 'wafv2:DeleteWebACL'], + + run: function(cache, settings, callback) { + var results = []; + var source = { }; + var regions = helpers.regions(settings); + + async.each(regions.wafregional, function(region, rcb){ + var listWebACLs = helpers.addSource(cache, source, + ['wafv2', 'listWebACLs', region]); + + if (!listWebACLs) return rcb(); + + if (listWebACLs.err || !listWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to list WAFV2 web ACLs: ' + helpers.addError(listWebACLs), region); + return rcb(); + } + + for (let webAcl of listWebACLs.data){ + if (!webAcl.ARN) continue; + + let getWebACL = helpers.addSource(cache, source, + ['wafv2', 'getWebACL', region, webAcl.ARN]); + + if (!getWebACL || !getWebACL.data || getWebACL.err){ + helpers.addResult(results, 3, + 'Unable to get web acl details: ' + helpers.addError(listWebACLs), region, webAcl.ARN); + continue; + } + + if (getWebACL.data.WebACL && + getWebACL.data.WebACL.VisibilityConfig && getWebACL.data.WebACL.VisibilityConfig.CloudWatchMetricsEnabled){ + helpers.addResult(results, 0, 'WAFV2 web ACL rule has CloudWatch metrics enabled', region, webAcl.ARN); + } else { + helpers.addResult(results, 2, 'WAFV2 web ACL rule does not have CloudWatch metrics enabled', region, webAcl.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.spec.js b/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.spec.js new file mode 100644 index 000000000..b3d618a71 --- /dev/null +++ b/plugins/aws/wafv2/wafv2CloudwatchMetricsEnabled.spec.js @@ -0,0 +1,108 @@ +const expect = require('chai').expect; +var cloudwatchMetricsEnabled = require('./wafv2CloudwatchMetricsEnabled'); + +const webACLs = [ + { + Name: "WebACLexample", + Id: "234", + ARN: "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234" + }, +] +const getWebAcl = [ + { + WebACL: { + "ARN": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234", + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + }, + + } + }, + { + WebACL:{ + "ARN": "arn:aws:wafv2:us-east-1:111122223333:regional/webacl/test-poc/234", + "VisibilityConfig": { + "CloudWatchMetricsEnabled": false, + }, + + } + } +] + +const createCache = (webACLs, getWebAcl) => { + var arn = (webACLs && webACLs.length) ? webACLs[0].ARN : null; + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + data: webACLs + } + }, + getWebACL: { + 'us-east-1': { + [arn]: { + data: getWebAcl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + + +describe('cloudwatchMetricsEnabled', function () { + describe('run', function () { + it('should PASS if cloud watch metrics are enabled', function (done) { + const cache = createCache([webACLs[0]], getWebAcl[0]); + cloudwatchMetricsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WAFV2 web ACL rule has CloudWatch metrics enabled'); + done(); + }); + }); + + it('should FAIL if cloud watch metrics are not enabled for webacl', function (done) { + const cache = createCache([webACLs[0]], getWebAcl[1]); + cloudwatchMetricsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WAFV2 web ACL rule does not have CloudWatch metrics enabled'); + done(); + }); + }); + + it('should unknown if unable to listWebACLs', function (done) { + const cache = createErrorCache(); + cloudwatchMetricsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list WAFV2 web ACLs:') + done(); + }); + }); + it('should unknown if unable to get web acl details', function (done) { + const cache = createCache([webACLs[0]], null) + cloudwatchMetricsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to get web acl details:') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/wafv2/wafv2InUse.js b/plugins/aws/wafv2/wafv2InUse.js new file mode 100644 index 000000000..66eb2de44 --- /dev/null +++ b/plugins/aws/wafv2/wafv2InUse.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AWS WAFV2 In Use', + category: 'WAF', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that AWS Web Application Firewall V2 (WAFV2) is in use to achieve availability and security for AWS-powered web applications.', + more_info: 'Using WAF for your web application running in AWS environment can help you against common web-based attacks, SQL injection attacks, DDOS attacks and more.', + link: 'https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html', + recommended_action: 'Create one or more WAF ACLs with proper actions and rules', + apis: ['WAFV2:listWebACLs'], + realtime_triggers: ['wafv2:CreateWebACL', 'wafv2:DeleteWebAcl'], + + run: function(cache, settings, callback) { + var results = []; + var source = { }; + var regions = helpers.regions(settings); + + async.each(regions.wafregional, function(region, rcb){ + var listWebACLs = helpers.addSource(cache, source, + ['wafv2', 'listWebACLs', region]); + + if (!listWebACLs) return rcb(); + + if (listWebACLs.err || !listWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to list WAFV2 web ACLs: ' + helpers.addError(listWebACLs), region); + return rcb(); + } + + if (!listWebACLs.data.length) { + helpers.addResult(results, 2, 'WAFV2 is not enabled', region); + } else { + helpers.addResult(results, 0, 'WAFV2 is enabled', region); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/wafv2/wafv2InUse.spec.js b/plugins/aws/wafv2/wafv2InUse.spec.js new file mode 100644 index 000000000..f5d788c44 --- /dev/null +++ b/plugins/aws/wafv2/wafv2InUse.spec.js @@ -0,0 +1,76 @@ +const expect = require('chai').expect; +var wafv2InUse = require('./wafv2InUse'); + +const webACLs = [ + { + Name: "WebACLexample", + WebACLId: "webacl-1472061481310" + }, + { + Name: "WebACLexample2", + WebACLId: "webacl-1472061481390" + } +] + +const createCache = (webACLs) => { + return { + wafv2: { + listWebACLs: { + 'us-east-2': { + data: webACLs + }, + 'us-east-1': { + data: webACLs + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'Error listing transfer servers' + } + } + } + } + }; +}; + + +describe('wafv2InUse', function () { + describe('run', function () { + it('should PASS if WAF is being used', function (done) { + const cache = createCache(webACLs); + wafv2InUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WAFV2 is enabled'); + done(); + }); + }); + + it('should FAIL if no application is using WAF', function (done) { + const cache = createCache([]); + wafv2InUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WAFV2 is not enabled'); + done(); + }); + }); + + it('should unknown if unable to listWebACLs', function (done) { + const cache = createErrorCache(); + wafv2InUse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.js b/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.js new file mode 100644 index 000000000..67a4b4ec8 --- /dev/null +++ b/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Web ACL Logging Enabled', + category: 'WAF', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that AWS WAFV2 Web Access Control Lists (ACLs) have logging enabled.', + more_info: 'Enabling logging for Web ACL allows detailed logging of web requests that match defined rules within the WAF Web ACL. This enables comprehensive monitoring, analysis, and troubleshooting of security threats and traffic patterns within your web application.', + link: 'https://docs.aws.amazon.com/waf/latest/developerguide/logging-management.html', + recommended_action: 'Modify WAFV2 Web ACL and enable logging.', + apis: ['WAFV2:listWebACLs','WAFV2:getLoggingConfiguration'], + realtime_triggers: ['wafv2:CreateWebACL', 'wafv2:DeleteWebAcl', 'wafv2:PutLoggingConfiguration'], + + run: function(cache, settings, callback) { + var results = []; + var source = { }; + var regions = helpers.regions(settings); + + async.each(regions.wafregional, function(region, rcb){ + var listWebACLs = helpers.addSource(cache, source, + ['wafv2', 'listWebACLs', region]); + + if (!listWebACLs) return rcb(); + + if (listWebACLs.err || !listWebACLs.data) { + helpers.addResult(results, 3, + 'Unable to list WAFV2 web ACLs: ' + helpers.addError(listWebACLs), region); + return rcb(); + } + + for (let webAcl of listWebACLs.data){ + if (!webAcl.ARN) continue; + + let getLoggingConfiguration = helpers.addSource(cache, source, + ['wafv2', 'getLoggingConfiguration', region, webAcl.ARN]); + + if (getLoggingConfiguration && getLoggingConfiguration.err && + getLoggingConfiguration.err.code === 'WAFNonexistentItemException') { + helpers.addResult(results, 2, 'Logging for web ACL is disabled', region, webAcl.ARN); + continue; + + } else if (!getLoggingConfiguration || + !getLoggingConfiguration.data || + getLoggingConfiguration.err || + !getLoggingConfiguration.data.LoggingConfiguration){ + helpers.addResult(results, 3, + 'Unable to get WAFV2 web ACL logging configuration: ' + helpers.addError(listWebACLs), region, webAcl.ARN); + continue; + } + + if (getLoggingConfiguration.data.LoggingConfiguration){ + helpers.addResult(results, 0, 'Logging for web ACL is enabled', region, webAcl.ARN); + } else { + helpers.addResult(results, 2, 'Logging for web ACL is disabled', region, webAcl.ARN); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.spec.js b/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.spec.js new file mode 100644 index 000000000..c9040989d --- /dev/null +++ b/plugins/aws/wafv2/wafv2WebAclLoggingEnabled.spec.js @@ -0,0 +1,146 @@ +const expect = require('chai').expect; +const wafv2WebAclLoggingEnabled = require('./wafv2WebAclLoggingEnabled'); + +const listWebACLsResponse = [ + { + "Id": "abcd1234", + "ARN": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/abcd1234", + "Name": "TestWebACL", + "Description": "Test Web ACL", + "VisibilityConfig": { + "SampledRequestsEnabled": true, + "CloudWatchMetricsEnabled": false, + } + }, +]; + +const loggingEnabledResponse = { + "LoggingConfiguration": { + "LogDestinationConfigs": [ + "arn:aws:logs:us-east-1:123456789012:log-group:/aws/wafv2/abcd1234/web-acl" + ], + "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/abcd1234" + } +}; + + +const createCache = (listWebACLsResponse, loggingConfigurations) => { + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + data: listWebACLsResponse, + err: null + } + }, + getLoggingConfiguration: { + 'us-east-1': { + 'arn:aws:wafv2:us-east-1:123456789012:regional/webacl/abcd1234': { + data: loggingConfigurations, + err: null + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + err: { + message: 'error listing Web ACLs' + } + } + }, + getLoggingConfiguration: { + 'us-east-1': { + err: { + message: 'error getting logging configuration' + } + } + } + } + }; +}; + +const createFalseCache = ()=>{ + return { + wafv2: { + listWebACLs: { + 'us-east-1': { + data: listWebACLsResponse, + err: null + } + }, + getLoggingConfiguration: { + 'us-east-1': { + 'arn:aws:wafv2:us-east-1:123456789012:regional/webacl/abcd1234': { + err: { + code: 'WAFNonexistentItemException' + } + } + } + } + } + }; +} + +describe('wafv2WebAclLoggingEnabled', function () { + describe('run', function () { + + it('should PASS if logging is enabled for all Web ACLs', function (done) { + const cache = createCache(listWebACLsResponse, loggingEnabledResponse); + wafv2WebAclLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(listWebACLsResponse.length); + results.forEach(result => { + expect(result.status).to.equal(0); + expect(result.region).to.equal('us-east-1'); + expect(result.message).to.include('Logging for web ACL is enabled'); + }); + done(); + }); + }); + + it('should FAIL if logging is disabled for any Web ACL', function (done) { + const cache = createFalseCache(); + wafv2WebAclLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(listWebACLsResponse.length); + results.forEach(result => { + expect(result.status).to.equal(2); + expect(result.region).to.equal('us-east-1'); + expect(result.message).to.include('Logging for web ACL is disabled'); + + }); + done(); + }); + }); + + it('should handle error if unable to list Web ACLs', function (done) { + const cache = createErrorCache(); + wafv2WebAclLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to list WAFV2 web ACLs:'); + done(); + }); + }); + + it('should handle error if unable to get logging configuration for Web ACLs', function (done) { + const cache = createCache(listWebACLsResponse, { + }); + wafv2WebAclLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(listWebACLsResponse.length); + results.forEach(result => { + expect(result.status).to.equal(3); + expect(result.region).to.equal('us-east-1'); + expect(result.message).to.include('Unable to get WAFV2 web ACL logging configuration:'); + }); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/workspaces/unusedWorkspaces.js b/plugins/aws/workspaces/unusedWorkspaces.js new file mode 100644 index 000000000..4259660f5 --- /dev/null +++ b/plugins/aws/workspaces/unusedWorkspaces.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Unused WorkSpaces', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure that there are no unused AWS WorkSpaces instances available within your AWS account.', + more_info: 'An AWS WorkSpaces instance is considered unused if it has 0 known user connections registered within the configured inactivity threshold. Remove these instances to avoid unnecessary billing.', + link: 'https://aws.amazon.com/workspaces/pricing/', + recommended_action: 'Identify and remove unused Workspaces instance', + apis: ['WorkSpaces:describeWorkspacesConnectionStatus','STS:getCallerIdentity'], + settings: { + workspaces_inactivity_threshold_days: { + name: 'WorkSpaces Inactivity Threshold (Days)', + description: 'Number of days of inactivity before a WorkSpace is considered unused', + regex: '^[0-9]{1,4}$', + default: '30' + } + }, + realtime_triggers: ['workspace:CreateWorkSpaces','workspace:TerminateWorkspaces'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var config = { + workspaces_inactivity_threshold_days: parseInt(settings.workspaces_inactivity_threshold_days || this.settings.workspaces_inactivity_threshold_days.default) + }; + + var awsOrGov = helpers.defaultPartition(settings); + var acctRegion = helpers.defaultRegion(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion , 'data']); + + async.each(regions.workspaces, function(region, rcb){ + var describeWorkspacesConnectionStatus = helpers.addSource(cache, source, + ['workspaces', 'describeWorkspacesConnectionStatus', region]); + + if (!describeWorkspacesConnectionStatus) return rcb(); + + if (describeWorkspacesConnectionStatus.err || !describeWorkspacesConnectionStatus.data) { + helpers.addResult(results, 3, + 'Unable to query for WorkSpaces instance connection status: ' + helpers.addError(describeWorkspacesConnectionStatus), region); + return rcb(); + } + + if (!describeWorkspacesConnectionStatus.data.length) { + helpers.addResult(results, 0, 'No WorkSpaces instance connection status found', region); + return rcb(); + } + + describeWorkspacesConnectionStatus.data.forEach(workspace => { + if (!workspace.WorkspaceId) return; + + let resource = `arn:${awsOrGov}:region:${region}:${accountId}:worskpace/${workspace.WorkspaceId}`; + + if (!workspace.LastKnownUserConnectionTimestamp) { + helpers.addResult(results, 2, + 'WorkSpace does not have any known user connection', region, resource); + } else { + var daysSinceLastConnection = helpers.daysBetween(new Date(), workspace.LastKnownUserConnectionTimestamp); + if (daysSinceLastConnection > config.workspaces_inactivity_threshold_days) { + helpers.addResult(results, 2, + `WorkSpace is not in use for last ${daysSinceLastConnection} days (threshold: ${config.workspaces_inactivity_threshold_days} days)`, + region, resource); + } else { + helpers.addResult(results, 0, + 'WorkSpace is in use', region, resource); + } + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/unusedWorkspaces.spec.js b/plugins/aws/workspaces/unusedWorkspaces.spec.js new file mode 100644 index 000000000..9cc134007 --- /dev/null +++ b/plugins/aws/workspaces/unusedWorkspaces.spec.js @@ -0,0 +1,108 @@ +var expect = require('chai').expect; +var unusedWorkspaces = require('./unusedWorkspaces.js'); + +const describeWorkspacesConnectionStatus = [ + { + WorkspaceId: "test01", + ConnectionState: "CONNECTED", + ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z", + LastKnownUserConnectionTimestamp: new Date() + }, + { + WorkspaceId: "test02", + ConnectionState:"DISCONNECTED", + ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z", + LastKnownUserConnectionTimestamp: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString() + }, + { + WorkspaceId: "test03", + ConnectionState:"DISCONNECTED", + ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z", + LastKnownUserConnectionTimestamp: new Date(Date.now() - 150 * 24 * 60 * 60 * 1000).toISOString() + }, + { + WorkspaceId: "test04", + ConnectionState:"DISCONNECTED", + ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z" + }, + +]; + +const createCache = (data, err) => { + return { + workspaces: { + describeWorkspacesConnectionStatus: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +describe('unusedWorkspaces', function () { + describe('run', function () { + it('should PASS if no workspace connection found', function (done) { + const cache = createCache([]); + unusedWorkspaces.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for WorkSpaces instance connection status', function (done) { + const cache = createCache(null,""); + unusedWorkspaces.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + it('should PASS if Workspace is in use', function (done) { + const cache = createCache([describeWorkspacesConnectionStatus[0]]); + unusedWorkspaces.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Workspace is not in use for last 30 days', function (done) { + const cache = createCache([describeWorkspacesConnectionStatus[1]]); + unusedWorkspaces.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('threshold: 30 days'); + done(); + }); + }); + + it('should FAIL if Workspace is not in use for 150 days with 120 day threshold', function (done) { + const cache = createCache([describeWorkspacesConnectionStatus[2]]); + const settings = { + workspaces_inactivity_threshold_days: '120' + }; + unusedWorkspaces.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('threshold: 120 days'); + done(); + }); + }); + + it('should FAIL if WorkSpace does not have any known user connection', function (done) { + const cache = createCache([describeWorkspacesConnectionStatus[3]]); + unusedWorkspaces.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/workspaces/workSpacesHealthyInstances.js b/plugins/aws/workspaces/workSpacesHealthyInstances.js new file mode 100644 index 000000000..25036a35a --- /dev/null +++ b/plugins/aws/workspaces/workSpacesHealthyInstances.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'WorkSpaces Healthy Instances', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that the AWS WorkSpace have healthy instances.', + more_info: 'Amazon WorkSpace should have healthy and running instances to ensure proper connection. The WorkSpace is marked as Unhealthy if response isn’t received from the WorkSpace in a timely manner. When the WorkSpaces instance’s status is unhealthy, it fails to respond to the HealthCheck requests.', + recommended_action: 'Troubleshoot and resolve the unhealthy workspace issues.', + link: 'https://docs.aws.amazon.com/workspaces/latest/adminguide/cloudwatch-dashboard.html', + apis: ['WorkSpaces:describeWorkspaces','STS:getCallerIdentity'], + realtime_triggers: ['workspaces:CreateWorkspaces','workspaces:DeleteWorkspaces'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.workspaces, function(region, rcb){ + var listWorkspaces = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaces', region]); + + if (!listWorkspaces) return rcb(); + + if (!listWorkspaces || listWorkspaces.err || !listWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to list Workspaces: ' + helpers.addError(listWorkspaces), region); + return rcb(); + } + + if (!listWorkspaces.data.length) { + helpers.addResult(results, 0, + 'No WorkSpaces instances found', region); + return rcb(); + } + listWorkspaces.data.forEach(workspace => { + if (!workspace.WorkspaceId) return; + + let resource = `arn:${awsOrGov}:region:${region}:${accountId}:worskpace/${workspace.WorkspaceId}`; + + if (workspace.State === 'UNHEALTHY') { + helpers.addResult(results, 2, + 'Workspace instance is not in healthy state', region, resource); + } else { + helpers.addResult(results, 0, + 'WorkSpace instance is in healthy state', region, resource); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/workSpacesHealthyInstances.spec.js b/plugins/aws/workspaces/workSpacesHealthyInstances.spec.js new file mode 100644 index 000000000..65c2aece5 --- /dev/null +++ b/plugins/aws/workspaces/workSpacesHealthyInstances.spec.js @@ -0,0 +1,101 @@ + +var expect = require('chai').expect; +var workSpacesHealthyInstances = require('./workSpacesHealthyInstances'); + +const describeWorkspaces = [ + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'AVAILABLE', + BundleId: 'wsb-clj85qzj1', + SubnetId: 'subnet-017fd5eda595ac73f', + ModificationStates: [] + }, + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'UNHEALTHY', + BundleId: 'wsb-clj85qzj1', + ModificationStates: [] + }, +]; + +const createCache = (data, err) => { + return { + workspaces: { + describeWorkspaces: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +const createErrorCache = () => { + return { + workspaces: { + describeWorkspaces: { + 'us-east-1': { + data: [], + err: { + message: 'error describing workspaces' + }, + } + } + } + }; +}; + +describe('workSpacesHealthyInstances', function () { + describe('run', function () { + it('should PASS if no workspace instances found', function (done) { + const cache = createCache([]); + workSpacesHealthyInstances.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('No WorkSpaces instances found') + done(); + }); + }); + + it('should UNKNOWN if Unable to query for WorkSpaces instances', function (done) { + const cache = createErrorCache(); + workSpacesHealthyInstances.run(cache,{}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.include('us-east-1') + expect(results[0].message).to.include('Unable to list Workspaces') + done(); + }); + }); + + it('should PASS if the Workspace is operational', function (done) { + const cache = createCache([describeWorkspaces[0]]); + workSpacesHealthyInstances.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('WorkSpace instance is in healthy state') + done(); + }); + }); + + it('should FAIL if Workspace is not operational', function (done) { + const cache = createCache([describeWorkspaces[1]]); + workSpacesHealthyInstances.run(cache, { }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1') + expect(results[0].message).to.include('Workspace instance is not in healthy state') + done(); + }); + }); + }); +}); diff --git a/plugins/aws/workspaces/workspacesDesiredBundleType.js b/plugins/aws/workspaces/workspacesDesiredBundleType.js new file mode 100644 index 000000000..dd1c5ad75 --- /dev/null +++ b/plugins/aws/workspaces/workspacesDesiredBundleType.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'WorkSpaces Desired Bundle Type', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that AWS WorkSpaces bundles are of desired types.', + more_info: 'A bundle in AWS WorkSpaces defines the hardware and software for AWS WorkSpaces. You can create a WorkSpaces instance using a predefined or custom bundle. Setting a limit to the types that can be used will help you control billing and address internal compliance requirements.', + recommended_action: 'Ensure that WorkSpaces instances are using desired bundle types', + link: 'https://docs.aws.amazon.com/workspaces/latest/adminguide/amazon-workspaces-bundles.html', + apis: ['WorkSpaces:describeWorkspaces', 'STS:getCallerIdentity','workspace:TerminateWorkspaces'], + settings: { + workspace_desired_bundle_type: { + name: 'Workspaces desired bundle type', + description: 'Comma separated list of desired Workspace bundle types', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['workspace:CreateWorkSpaces', 'workspace:UpdateWorkspaceBundle'], + + run: function(cache, settings, callback) { + var workspace_desired_bundle_type = settings.workspace_desired_bundle_type || this.settings.workspace_desired_bundle_type.default; + + if (!workspace_desired_bundle_type.length) return callback(null, results, source); + + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.workspaces, function(region, rcb){ + var listWorkspaces = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaces', region]); + + if (!listWorkspaces) { + return rcb(); + } + + if (listWorkspaces.err || !listWorkspaces.data) { + helpers.addResult( + results, 3, 'Unable to query for WorkSpaces information: ' + helpers.addError(listWorkspaces), region); + return rcb(); + } + + if (!listWorkspaces.data.length) { + helpers.addResult( + results, 0, 'No WorkSpaces instances found', region); + return rcb(); + } + + listWorkspaces.data.forEach(workspace => { + var resource = 'arn:' + awsOrGov + ':workspaces:' + region + ':' + accountId + ':workspace/' + workspace.WorkspaceId; + + if (workspace.WorkspaceProperties && workspace.WorkspaceProperties.ComputeTypeName && workspace_desired_bundle_type.toUpperCase().includes(workspace.WorkspaceProperties.ComputeTypeName.toUpperCase())) { + helpers.addResult(results, 0, + 'WorkSpaces instance is using the desired bundle type', region, resource); + } else { + helpers.addResult(results, 2, + 'WorkSpaces instance is not using the desired bundle type', region, resource); + } + }); + + return rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/workspacesDesiredBundleType.spec.js b/plugins/aws/workspaces/workspacesDesiredBundleType.spec.js new file mode 100644 index 000000000..0d0e81c4d --- /dev/null +++ b/plugins/aws/workspaces/workspacesDesiredBundleType.spec.js @@ -0,0 +1,112 @@ + +var expect = require('chai').expect; +var workspacesDesiredBundleType = require('./workspacesDesiredBundleType.js'); + +const describeWorkspaces = [ + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'AVAILABLE', + BundleId: 'wsb-clj85qzj1', + SubnetId: 'subnet-017fd5eda595ac73f', + ComputerName: 'test', + WorkspaceProperties: { + RunningMode: 'AUTO_STOP', + RunningModeAutoStopTimeoutInMinutes: 60, + RootVolumeSizeGib: 80, + UserVolumeSizeGib: 50, + ComputeTypeName: 'STANDARD' + }, + ModificationStates: [] + }, + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'AVAILABLE', + BundleId: 'wsb-clj85qzj1', + SubnetId: 'subnet-017fd5eda595ac73f', + ComputerName: 'test', + WorkspaceProperties: { + RunningMode: 'AUTO_STOP', + RunningModeAutoStopTimeoutInMinutes: 60, + RootVolumeSizeGib: 80, + UserVolumeSizeGib: 50, + ComputeTypeName: 'PERFORMANCE' + }, + ModificationStates: [] + }, +]; + +const createCache = (data, err) => { + return { + workspaces: { + describeWorkspaces: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +const createErrorCache = () => { + return { + workspaces: { + describeWorkspaces: { + 'us-east-1': { + data: [], + err: { + message: 'error describing workspaces' + }, + } + } + } + }; +}; + +describe('workspacesDesiredBundleType', function () { + describe('run', function () { + it('should PASS if no workspace instances found', function (done) { + const cache = createCache([]); + workspacesDesiredBundleType.run(cache, { workspace_desired_bundle_type: 'PERFORMANCE' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for WorkSpaces instances', function (done) { + const cache = createErrorCache(); + workspacesDesiredBundleType.run(cache, { workspace_desired_bundle_type: 'PERFORMANCE' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should PASS if the Workspace is using desired bundle type', function (done) { + const cache = createCache([describeWorkspaces[1]]); + workspacesDesiredBundleType.run(cache, { workspace_desired_bundle_type: 'PERFORMANCE, STANDARD' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if Workspace is not using desired bundle type', function (done) { + const cache = createCache([describeWorkspaces[1]]); + workspacesDesiredBundleType.run(cache, { workspace_desired_bundle_type: 'STANDARD' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/workspaces/workspacesInstanceCount.js b/plugins/aws/workspaces/workspacesInstanceCount.js new file mode 100644 index 000000000..f875fa50a --- /dev/null +++ b/plugins/aws/workspaces/workspacesInstanceCount.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'WorkSpaces Instance Count', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that the number of Amazon WorkSpaces provisioned in your AWS account has not reached set limit.', + more_info: 'In order to manage your WorkSpaces compute resources efficiently and prevent unexpected charges on your AWS bill, monitor and configure limits for the maximum number of WorkSpaces instances provisioned within your AWS account.', + recommended_action: 'Ensure that number of WorkSpaces created within your AWS account is within set limit', + link: 'https://docs.aws.amazon.com/workspaces/latest/adminguide/workspaces-limits.html', + apis: ['WorkSpaces:describeWorkspaces', 'workspace:TerminateWorkspaces'], + settings: { + workspace_instance_limit: { + name: 'Limit for the number of WorkSpaces instances.', + description: 'Desired threshold for the number of WorkSpace instances in AWS account.', + regex: '/[0-9]+/', + default: '50' + } + }, + realtime_triggers: ['workspaces:CreateWorkspaces'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var workspace_instance_limit = parseInt(settings.workspace_instance_limit || this.settings.workspace_instance_limit.default); + var instanceCount = 0; + + async.each(regions.workspaces, function(region, rcb){ + var listWorkspaces = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaces', region]); + + if (!listWorkspaces) { + return rcb(); + } + + if (!listWorkspaces || listWorkspaces.err || !listWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to list Workspaces: ' + helpers.addError(listWorkspaces), region); + return rcb(); + } + + if (!listWorkspaces.data.length) { + helpers.addResult(results, 0, + 'No WorkSpaces instances found', region); + return rcb(); + } + + instanceCount += listWorkspaces.data.length; + + rcb(); + }, function(){ + if (instanceCount > workspace_instance_limit){ + helpers.addResult(results, 2, `WorkSpaces Instance count is ${instanceCount} of ${workspace_instance_limit} desired threshold`, 'global'); + } else { + helpers.addResult(results, 0, `WorkSpaces Instance count is ${instanceCount} of ${workspace_instance_limit} desired threshold`, 'global'); + } + + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/workspacesInstanceCount.spec.js b/plugins/aws/workspaces/workspacesInstanceCount.spec.js new file mode 100644 index 000000000..3c8bc7c0b --- /dev/null +++ b/plugins/aws/workspaces/workspacesInstanceCount.spec.js @@ -0,0 +1,88 @@ + +var expect = require('chai').expect; +var workspacesInstanceCount = require('./workspacesInstanceCount.js'); + +const describeWorkspaces = [ + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'AVAILABLE', + BundleId: 'wsb-clj85qzj1', + SubnetId: 'subnet-017fd5eda595ac73f', + ComputerName: 'test', + WorkspaceProperties: { + RunningMode: 'AUTO_STOP', + RunningModeAutoStopTimeoutInMinutes: 60, + RootVolumeSizeGib: 80, + UserVolumeSizeGib: 50, + ComputeTypeName: 'STANDARD' + }, + ModificationStates: [] + }, + { + WorkspaceId: 'ws-f7hsrphp6', + DirectoryId: 'd-9067552532', + UserName: 'test', + IpAddress: '172.16.1.134', + State: 'AVAILABLE', + BundleId: 'wsb-clj85qzj1', + SubnetId: 'subnet-017fd5eda595ac73f', + ComputerName: 'test', + WorkspaceProperties: { + RunningMode: 'AUTO_STOP', + RunningModeAutoStopTimeoutInMinutes: 60, + RootVolumeSizeGib: 80, + UserVolumeSizeGib: 50, + ComputeTypeName: 'PERFORMANCE' + }, + ModificationStates: [] + }, +]; + +const createCache = (data, err) => { + return { + workspaces: { + describeWorkspaces: { + 'us-east-1': { + data: data, + err: err + } + } + } + }; +}; + +describe('workspacesInstanceCount', function () { + describe('run', function () { + it('should PASS if all Workspaces count is upto the desired threshold i.e. 10', function (done) { + const cache = createCache([describeWorkspaces[0]]); + workspacesInstanceCount.run(cache, { workspace_instance_limit: 10 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WorkSpaces Instance count is 1 of 10 desired threshold'); + done(); + }); + }); + + it('should FAIL if Workspaces count is greater than the desired threshold', function (done) { + const cache = createCache(Array(3).fill(describeWorkspaces[0])); + workspacesInstanceCount.run(cache, { workspace_instance_limit: 2 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WorkSpaces Instance count is 3 of 2 desired threshold'); + done(); + }); + }); + + it('should PASS if no WorkSpaces instances found', function (done) { + const cache = createCache([]); + workspacesInstanceCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/workspaces/workspacesIpAccessControl.js b/plugins/aws/workspaces/workspacesIpAccessControl.js new file mode 100644 index 000000000..33def0c3c --- /dev/null +++ b/plugins/aws/workspaces/workspacesIpAccessControl.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Workspaces IP Access Control', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures enforced IP Access Control on Workspaces', + more_info: 'Checking the existence of IP Access control on Workspaces and ensuring that no Workspaces are open', + link: 'https://docs.aws.amazon.com/workspaces/latest/adminguide/amazon-workspaces-ip-access-control-groups.html', + recommended_action: 'Enable proper IP Access Controls for all workspaces', + apis: ['WorkSpaces:describeWorkspaces', 'WorkSpaces:describeWorkspaceDirectories', 'WorkSpaces:describeIpGroups', 'STS:getCallerIdentity'], + realtime_triggers: ['workspaces:CreateWorkspaces', 'workspaces:ModifyWorkspaceAccessProperties', 'workspace:TerminateWorkspaces'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + const enabledString = 'IP Access Control is enabled'; + const enabledButAllowsWorldString = 'IP Access Control is enabled but 0.0.0.0/0 is allowed'; + const disabledString = 'IP Access Control is disabled'; + + async.each(regions.workspaces, function(region, rcb){ + var listWorkspaces = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaces', region, 'data']); + var listDirectories = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaceDirectories', region, 'data']); + var listIPGroups = helpers.addSource(cache, source, ['workspaces', 'describeIpGroups', region, 'data']); + + if (!listWorkspaces) { + return rcb(); + } + + if (listWorkspaces.err) { + helpers.addResult( + results, 3, 'Unable to query for WorkSpaces information: ' + helpers.addError(listWorkspaces), region); + return rcb(); + } + + if (!listWorkspaces.length) { + helpers.addResult( + results, 0, 'No Workspaces found.', region); + return rcb(); + } + + for (var workspace of listWorkspaces) { + var arn = 'arn:' + awsOrGov + ':workspaces:' + region + ':' + accountId + ':workspace/' + workspace.WorkspaceId; + + if (!workspace.DirectoryId){ + helpers.addResult(results, 2, disabledString, region, arn); + continue; + } + + var workspaceDirectory = listDirectories.find(directory => directory.DirectoryId === workspace.DirectoryId); + + if (workspaceDirectory && workspaceDirectory.ipGroupIds) { + let openToEverything = false; + for (var workspaceIPGroup of workspaceDirectory.ipGroupIds){ + var ipGroup = listIPGroups.find(o => o.groupId === workspaceIPGroup); + + if (ipGroup && ipGroup.userRules) { + if (ipGroup.userRules.find(o => o.ipRule === '0.0.0.0/0')) { + openToEverything = true; + break; + } + } + } + + if (openToEverything){ + helpers.addResult(results, 2, enabledButAllowsWorldString, region, arn); + } else { + helpers.addResult(results, 0, enabledString, region, arn); + } + } else { + helpers.addResult(results, 2, disabledString, region, arn); + } + } + + return rcb(); + + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/workspacesIpAccessControl.spec.js b/plugins/aws/workspaces/workspacesIpAccessControl.spec.js new file mode 100644 index 000000000..c33934df2 --- /dev/null +++ b/plugins/aws/workspaces/workspacesIpAccessControl.spec.js @@ -0,0 +1,210 @@ +var expect = require("chai").expect; +var workspacesIpAccessControl = require("./workspacesIpAccessControl.js") + + +const errorWorkspaces = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + }, + }, + }, + } +}; + +const noWorkspaces = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + data: [] + }, + }, + }, + } +}; + +const testWorkspaces = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + data: [ + { + WorkspaceId: "test01", + DirectoryId: "d-01", + }, + { + WorkspaceId: "test02", + DirectoryId: "d-02", + }, + ] + }, + }, + describeWorkspaceDirectories: { + "us-east-1":{ + data:[ + { + DirectoryId: "d-01", + DirectoryName: "corp.amazonworkspaces.com", + CustomerUserName: "Administrator", + ipGroupIds: [ + "ipgroup01" + ] + }, + { + DirectoryId: "d-02", + DirectoryName: "corp.amazonworkspaces.com", + CustomerUserName: "Administrator", + ipGroupIds: [ + "ipgroup02", + "ipgroup03" + ] + } + ] + } + }, + describeIpGroups:{ + "us-east-1":{ + data: [ + { + "groupId": "ipgroup01", + "groupName": "testIPControlAccess" + }, + { + "groupId": "ipgroup02", + "groupName": "testIPControlAccess" + }, + { + "groupId": "ipgroup03", + "groupName": "testIPControlAccess", + "userRules": [ + { + "ipRule": "192.45.32.10", + "ruleDesc": "Open for one." + } + ] + } + ] + } + } + }, + } +}; + +const testWorkspaces2 = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + data: [ + { + WorkspaceId: "test01", + DirectoryId: "d-01", + }, + { + WorkspaceId: "test02", + DirectoryId: "d-02", + }, + ] + }, + }, + describeWorkspaceDirectories: { + "us-east-1":{ + data:[ + { + DirectoryId: "d-01", + DirectoryName: "corp.amazonworkspaces.com", + CustomerUserName: "Administrator", + ipGroupIds: [ + "ipgroup01" + ] + }, + { + DirectoryId: "d-02", + DirectoryName: "corp.amazonworkspaces.com", + CustomerUserName: "Administrator", + ipGroupIds: [ + "ipgroup02" + ] + } + ] + } + }, + describeIpGroups:{ + "us-east-1":{ + data: [ + { + "groupId": "ipgroup01", + "groupName": "testIPControlAccess" + }, + { + "groupId": "ipgroup02", + "groupName": "testIPControlAccess", + "userRules": [ + { + "ipRule": "0.0.0.0/0", + "ruleDesc": "Open for all" + } + ] + } + ] + } + } + }, + } +}; + + +describe("workspacesIPAccessControl", function () { + describe("run", function () { + it("should give a general error if it can not get workspaces", function (done) { + const settings = {}; + const cache = errorWorkspaces(); + const callback = (err, results) => { + expect(results.length).to.equal(0) + }; + workspacesIpAccessControl.run(cache, settings, callback); + done(); + }); + + it("should give an output of no available workspaces", function (done) { + const settings = {}; + const cache = noWorkspaces(); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }; + + workspacesIpAccessControl.run(cache, settings, callback); + done(); + }); + + it("should give IP access controls on both the workspaces", function (done) { + const settings = {}; + const cache = testWorkspaces(); + + const callback = (err, results) => { + expect(results.length).to.equal(2) + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + }; + + workspacesIpAccessControl.run(cache, settings, callback); + done(); + }) + + it("should give IP access control enabled on one and no IP access control on the other", function (done) { + const settings = {}; + const cache = testWorkspaces2(); + + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(2); + }; + + + workspacesIpAccessControl.run(cache, settings, callback); + done(); + }) + }) +}) diff --git a/plugins/aws/workspaces/workspacesVolumeEncryption.js b/plugins/aws/workspaces/workspacesVolumeEncryption.js new file mode 100644 index 000000000..25ce691ed --- /dev/null +++ b/plugins/aws/workspaces/workspacesVolumeEncryption.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'WorkSpaces Volume Encryption', + category: 'WorkSpaces', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensures volume encryption on WorkSpaces for data protection.', + more_info: 'AWS WorkSpaces should have volume encryption enabled in order to protect data from unauthorized access.', + link: 'https://docs.aws.amazon.com/workspaces/latest/adminguide/encrypt-workspaces.html', + recommended_action: 'Modify WorkSpaces to enable volume encryption', + apis: ['WorkSpaces:describeWorkspaces', 'STS:getCallerIdentity', 'KMS:describeKey', 'KMS:listKeys'], + settings: { + workspace_encryption_level: { + name: 'Workspace Minimum Default Encryption Level', + description: 'In order (lowest to highest) \ + awskms=AWS-managed KMS; \ + awscmk=Customer managed KMS; \ + externalcmk=Customer managed externally sourced KMS; \ + cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms' + } + }, + realtime_triggers: ['workspace:CreateWorkSpaces', 'workspace:TerminateWorkspaces'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var desiredEncryptionLevelString = settings.workspace_encryption_level || this.settings.workspace_encryption_level.default; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(desiredEncryptionLevelString); + var currentEncryptionLevel; + + var regions = helpers.regions(settings); + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + const enabledString = 'Volume encryption enabled on User and Root volumes'; + const enabledUser = 'Volume encryption enabled on User volume but not on Root volume'; + const enabledRoot = 'Volume encryption enabled on Root volume but not on User volume'; + const disabledString = 'Volume encryption not enabled on any volumes'; + const unknownStatusString = 'Unable to query encryption status for volumes'; + + async.each(regions.workspaces, function(region, rcb) { + var listWorkspaces = helpers.addSource(cache, source, ['workspaces', 'describeWorkspaces', region]); + + if (!listWorkspaces) { + return rcb(); + } + + if (!listWorkspaces.data || listWorkspaces.err) { + helpers.addResult(results, 3, 'Unable to query for WorkSpaces information: ' + helpers.addError(listWorkspaces), region); + return rcb(); + } + + if (!listWorkspaces.data.length) { + helpers.addResult(results, 0, 'No WorkSpaces found', region); + return rcb(); + } + + var listKeys = helpers.addSource(cache, source, ['kms', 'listKeys', region]); + + if (!listKeys) return rcb(); + + if ( listKeys.err || !listKeys.data) { + helpers.addResult(results, 3, 'Unable to query KMS keys' + helpers.addError(listKeys), region); + return rcb(); + } + + for (var workspace of listWorkspaces.data) { + var arn = 'arn:' + awsOrGov + ':workspaces:' + region + ':' + accountId + ':workspace/' + workspace.WorkspaceId; + + if (!workspace.VolumeEncryptionKey) { + helpers.addResult(results, 2, disabledString, region, arn); + continue; + } + + var queryKeys = listKeys.data.find(key => key.KeyArn === workspace.VolumeEncryptionKey); + + if (!queryKeys) { + helpers.addResult(results, 3, `Unable to find key with key arn: ${workspace.VolumeEncryptionKey}`, region, arn); + } else { + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, queryKeys['KeyId']]); + + if (!describeKey || describeKey.err || !describeKey.data) { + helpers.addResult(results, 3, 'Unable to query for Key information: ' + helpers.addError(describeKey), region, arn); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + + if (workspace.UserVolumeEncryptionEnabled && workspace.RootVolumeEncryptionEnabled && (desiredEncryptionLevel <= currentEncryptionLevel)) { + helpers.addResult(results, 0, enabledString, region, arn); + } else if (workspace.UserVolumeEncryptionEnabled && !workspace.RootVolumeEncryptionEnabled) { + helpers.addResult(results, 2, enabledUser, region, arn); + } else if (!workspace.UserVolumeEncryptionEnabled && workspace.RootVolumeEncryptionEnabled) { + helpers.addResult(results, 2, enabledRoot, region, arn); + } else if (!workspace.UserVolumeEncryptionEnabled && !workspace.RootVolumeEncryptionEnabled) { + helpers.addResult(results, 2, disabledString, region, arn); + } else if (desiredEncryptionLevel >= currentEncryptionLevel) { + helpers.addResult(results, 2, `Volume encryption is enabled at level ${currentEncryptionLevel}, which is lower than the desired level ${desiredEncryptionLevel}`, region, arn); + } else { + helpers.addResult(results, 3, unknownStatusString, region, arn); + } + } + } + + return rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/workspaces/workspacesVolumeEncryption.spec.js b/plugins/aws/workspaces/workspacesVolumeEncryption.spec.js new file mode 100644 index 000000000..02852225c --- /dev/null +++ b/plugins/aws/workspaces/workspacesVolumeEncryption.spec.js @@ -0,0 +1,130 @@ +var expect = require("chai").expect; +var metrics = require("./workspacesVolumeEncryption.js") + + +const errorWorkspaces = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + }, + }, + }, + } +}; + +const noWorkspaces = (statement) => { + return {workspaces:{ + describeWorkspaces: { + "us-east-1":{ + data: [] + }, + }, + }, + kms:{describeKey: {"us-east-1":{data: []}}, + listKeys: {"us-east-1":{data:[]}},} + } +}; + +const testWorkspaces = (statement) => { + return {workspaces:{describeWorkspaces: {"us-east-1":{data: [ + { + WorkspaceId: "test01", + UserVolumeEncryptionEnabled: true, + RootVolumeEncryptionEnabled: true, + VolumeEncryptionKey: "arn:aws:kms:us-east-1:null:key/12345" + }, + { + WorkspaceId: "test02", + RootVolumeEncryptionEnabled: true, + VolumeEncryptionKey: "arn:aws:kms:us-east-1:null:key/12345" + },]},},}, + kms:{describeKey: {"us-east-1":{"12345": {data: {KeyMetadata:{ + KeyId: "12345", + Arn: "arn:aws:kms:us-east-1:null:key/12345", + KeyState: "Enabled", + Origin: "AWS_KMS", + KeyManager: "AWS", + } + }}}}, + listKeys: {"us-east-1":{data:[{ + "KeyId": "12345", + "KeyArn": "arn:aws:kms:us-east-1:null:key/12345"}]}},} + }}; + +const testWorkspaces2 = (statement) => { + return {workspaces:{ + describeWorkspaces: {"us-east-1":{data: [ + { + WorkspaceId: "test01", + UserVolumeEncryptionEnabled: true, + RootVolumeEncryptionEnabled: true, + VolumeEncryptionKey: "arn:aws:kms:us-east-1:null:key/12345" + },]},}, + }, + kms:{describeKey: {"us-east-1": {12345: {data: {KeyMetadata:{ + KeyId: "12345", + Arn: "arn:aws:kms:us-east-1:null:key/12345", + KeyState: "Enabled", + Origin: "AWS_KMS", + KeyManager: "AWS", + } + }}}}, + listKeys: {"us-east-1":{data:[{ + "KeyId": "12345", + "KeyArn": "arn:aws:kms:us-east-1:null:key/12345"}]}},} + }}; + + +describe("workspacesVolumeEncryption", function () { + describe("run", function () { + it("should give a general error if it can not get workspaces", function (done) { + const settings = {}; + const cache = errorWorkspaces(); + const callback = (err, results) => { + expect(results.length).to.equal(1) + }; + + metrics.run(cache, settings, callback); + done(); + }); + + it("should give an output of no workspaces found", function (done) { + const settings = {}; + const cache = noWorkspaces(); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + }; + + metrics.run(cache, settings, callback); + done(); + }); + + it("should give volume encryption enabled for first workspace and not enabled on second workspace", function (done) { + const settings = {}; + const cache = testWorkspaces(); + + const callback = (err, results) => { + expect(results.length).to.equal(2) + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(2); + }; + + metrics.run(cache, settings, callback); + done(); + }) + + it("should give a fail because the current encryption level is lower than desired", function (done) { + const cache = testWorkspaces2(); + + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + }; + + metrics.run(cache, {workspace_encryption_level: "externalcmk"}, callback); + done(); + }) + }) +}) \ No newline at end of file diff --git a/plugins/aws/xray/xrayEncryptionEnabled.js b/plugins/aws/xray/xrayEncryptionEnabled.js index 7f1525eb9..8180f3c9f 100644 --- a/plugins/aws/xray/xrayEncryptionEnabled.js +++ b/plugins/aws/xray/xrayEncryptionEnabled.js @@ -4,13 +4,51 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'XRay Encryption Enabled', category: 'XRay', + domain: 'Application Integration', + severity: 'High', description: 'Ensures CMK-based encryption is enabled for XRay traces.', more_info: 'AWS XRay supports default encryption based on an AWS-managed KMS key as well as encryption using a customer managed key (CMK). For maximum security, the CMK-based encryption should be used.', link: 'https://docs.aws.amazon.com/xray/latest/devguide/xray-console-encryption.html', recommended_action: 'Update XRay encryption configuration to use a CMK.', - apis: ['XRay:getEncryptionConfig'], + apis: ['XRay:getEncryptionConfig', 'KMS:listKeys', 'KMS:describeKey'], + settings: { + xray_encryption_level: { + name: 'X-Ray Minimum Encryption Level', + description: 'In order (lowest to highest) sse=Default Encryption; awskms=AWS-managed KMS; awscmk=Customer managed KMS; externalcmk=Customer managed externally sourced KMS; cloudhsm=Customer managed CloudHSM sourced KMS', + regex: '^(sse|awskms|awscmk|externalcmk|cloudhsm)$', + default: 'awskms', + } + }, + remediation_description: 'Encryption for the affected XRay traces will be enabled.', + remediation_min_version: '202011271430', + apis_remediate: ['XRay:getEncryptionConfig', 'KMS:listKeys', 'KMS:describeKey'], + actions: { + remediate: ['XRay:putEncryptionConfig'], + rollback: ['XRay:putEncryptionConfig'] + }, + permissions: { + remediate: ['xray:PutEncryptionConfig'], + rollback: ['xray:PutEncryptionConfig'] + }, + remediation_inputs: { + kmsKeyIdforXray: { + name: '(Optional) XRay KMS Key ID', + description: 'The KMS Key ID used for encryption', + regex: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$', + required: false + } + }, + realtime_triggers: ['xray:PutEncryptionConfig'], run: function(cache, settings, callback) { + var config = { + xray_encryption_level: settings.xray_encryption_level || this.settings.xray_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.xray_encryption_level); + var currentEncryptionLevel; + var currentEncryptionLevelString; + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -29,19 +67,91 @@ module.exports = { if (getEncryptionConfig.data && getEncryptionConfig.data.Type && - getEncryptionConfig.data.Type == 'KMS') { - if (getEncryptionConfig.data.KeyId) { - helpers.addResult(results, 0, 'XRay is configured to use KMS encryption with a CMK', region); + getEncryptionConfig.data.Type == 'KMS' && + getEncryptionConfig.data.KeyId) { + var kmsKeyId = getEncryptionConfig.data.KeyId.split('/')[1]; + + var describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, kmsKeyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, region); + return rcb(); + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, `XRay is configured to use encryption at level ${currentEncryptionLevelString} which is greater than or equal to desired level ${config.xray_encryption_level}`, region); } else { - helpers.addResult(results, 2, 'XRay is configured to use KMS encryption but is not using a CMK', region); + helpers.addResult(results, 2, `XRay is configured to use encryption at level ${currentEncryptionLevelString} which is less than desired level ${config.xray_encryption_level}`, region); } } else { - helpers.addResult(results, 2, 'XRay is configured to use default encryption without CMK', region); + currentEncryptionLevel = 1; //sse + currentEncryptionLevelString = 'sse'; + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, `XRay is configured to use encryption at level ${currentEncryptionLevelString} which is greater than or equal to desired level ${config.xray_encryption_level}`, region); + } else { + helpers.addResult(results, 2, `XRay is configured to use encryption at level ${currentEncryptionLevelString} which is less than desired level ${config.xray_encryption_level}`, region); + } } rcb(); }, function(){ callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var putCall = this.actions.remediate; + var pluginName = 'xrayEncryptionEnabled'; + let defaultKeyDesc = 'Default master key that protects my X-Ray data when no other key is defined'; + + var region = settings.region; + + // add the location of the xray to the config + config.region = region; + var params = {}; + + // create the params necessary for the remediation + if (settings.input && + settings.input.kmsKeyIdforXray) { + params = { + 'Type': 'KMS', + 'KeyId': settings.input.kmsKeyIdforXray, + }; + } else { + let defaultKmsKeyId = helpers.getDefaultKeyId(cache, config.region, defaultKeyDesc); + if (!defaultKmsKeyId) return callback(`No default X-Ray key for the region ${config.region}`); + params = { + 'Type': 'KMS', + 'KeyId': defaultKmsKeyId + }; + } + + var remediation_file = settings.remediation_file; + remediation_file['pre_remediate']['actions'][pluginName][region] = { + 'Encryption': 'Default', + 'XRay': region + }; + // passes the config, put call, and params to the remediate helper function + helpers.remediatePlugin(config, putCall[0], params, function(err) { + if (err) { + remediation_file['remediate']['actions'][pluginName]['error'] = err; + return callback(err, null); + } + + let action = params; + action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][region] = action; + remediation_file['remediate']['actions'][pluginName][region] = { + 'Action': 'KMSENCRYPTED', + 'XRay': region + }; + + settings.remediation_file = remediation_file; + return callback(null, action); + }); } }; diff --git a/plugins/aws/xray/xrayEncryptionEnabled.spec.js b/plugins/aws/xray/xrayEncryptionEnabled.spec.js index df4e8a6b3..64dd7a483 100644 --- a/plugins/aws/xray/xrayEncryptionEnabled.spec.js +++ b/plugins/aws/xray/xrayEncryptionEnabled.spec.js @@ -1,60 +1,109 @@ -var assert = require('assert'); var expect = require('chai').expect; -var xrayEncryptionEnabled = require('./xrayEncryptionEnabled') +var xrayEncryptionEnabled = require('./xrayEncryptionEnabled'); -const createCache = (data) => { +var getEncryptionConfig = [ + { + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/a7c4862e-02e6-4a21-b0be-f2db95d6cf43", + "Status": "ACTIVE", + "Type": "KMS" + } +]; + +var describeKey = [ + { + "KeyMetadata": { + "KeyId": "a7c4862e-02e6-4a21-b0be-f2db95d6cf43", + "Arn": "arn:aws:kms:us-east-1:111122223333:key/a7c4862e-02e6-4a21-b0be-f2db95d6cf43", + "Description": "Default master key that protects my X-Ray data when no other key is defined", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": "Enabled", + "Origin": "AWS_KMS", + "KeyManager": "AWS", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ] + } + } +]; + +const createCache = (getEncryptionConfig, describeKey) => { return { xray: { getEncryptionConfig: { 'us-east-1': { - data: data + data: getEncryptionConfig + } + } + }, + kms: { + describeKey: { + 'us-east-1': { + 'a7c4862e-02e6-4a21-b0be-f2db95d6cf43': { + data: describeKey + } } } } }; }; +const createNullCache = () => { + return { + xray: { + 'us-east-1': { + getEncryptionConfig: null + } + } + } +} + describe('xrayEncryptionEnabled', function () { describe('run', function () { - it('should FAIL if there is no regional data', function (done) { - const cache = createCache({}); - xrayEncryptionEnabled.run(cache, {}, (err, results) => { + it('should FAIL if current encryption level is less than desired encryption level', function (done) { + const cache = createCache(getEncryptionConfig[0], describeKey[0]); + xrayEncryptionEnabled.run(cache, { xray_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should FAIL if xray encryption is using NONE type', function (done) { - const cache = createCache({ - Type: 'NONE' - }); - xrayEncryptionEnabled.run(cache, {}, (err, results) => { + it('should PASS if current encryption level is greater than or equal to desired encryption level', function (done) { + const cache = createCache(getEncryptionConfig[0], describeKey[0]); + xrayEncryptionEnabled.run(cache, { xray_encryption_level: 'awskms' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should FAIL if xray encryption is using KMS type without a KeyId', function (done) { - const cache = createCache({ - Type: 'KMS' - }); - xrayEncryptionEnabled.run(cache, {}, (err, results) => { + it('should UNKNOWN if Unable to query for XRay encryption configuration', function (done) { + const cache = createCache(null); + xrayEncryptionEnabled.run(cache, { xray_encryption_level: 'awskms' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); done(); }); }); - it('should PASS if xray encryption is using KMS type', function (done) { - const cache = createCache({ - Type: 'KMS', - KeyId: 'my-key' - }); - xrayEncryptionEnabled.run(cache, {}, (err, results) => { + it('should UNKNOWN if Unable to query KMS key', function (done) { + const cache = createCache(getEncryptionConfig[0]); + xrayEncryptionEnabled.run(cache, { xray_encryption_level: 'awskms' }, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should return nothing if get XRay encryption config result not found', function (done) { + const cache = createNullCache(); + xrayEncryptionEnabled.run(cache, { xray_encryption_level: 'awskms' }, (err, results) => { + expect(results.length).to.equal(0); done(); }); }); diff --git a/plugins/azure/advisor/checkAdvisorRecommendations.js b/plugins/azure/advisor/checkAdvisorRecommendations.js new file mode 100644 index 000000000..99bd9cf34 --- /dev/null +++ b/plugins/azure/advisor/checkAdvisorRecommendations.js @@ -0,0 +1,43 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Active Advisor Recommendations', + category: 'Advisor', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that all Microsoft Azure Advisor recommendations found are implemented to optimize your cloud deployments, increase security, and reduce costs.', + more_info: 'Advisor service analyzes your Azure cloud configurations and resource usage telemetry to provide personalized and actionable recommendations that can help you optimize your cloud resources for security, reliability and high availability, operational excellence, performance efficiency, and cost.', + recommended_action: 'Implement all Microsoft Azure Advisor recommendations.', + link: 'https://learn.microsoft.com/en-us/azure/advisor/advisor-get-started', + apis: ['advisor:recommendationsList'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.advisor, function(location, rcb) { + const recommendationList = helpers.addSource(cache, source, + ['advisor', 'recommendationsList', location]); + + if (!recommendationList) return rcb(); + + if (recommendationList.err || !recommendationList.data) { + helpers.addResult(results, 3, 'Unable to query for Advisor Recommendations: ' + helpers.addError(recommendationList), location); + return rcb(); + } + + if (!recommendationList.data.length) { + helpers.addResult(results, 0, 'No Advisor Recommendations found', location); + return rcb(); + } else { + helpers.addResult(results, 2, 'Active Advisor Recommendations found', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/advisor/checkAdvisorRecommendations.spec.js b/plugins/azure/advisor/checkAdvisorRecommendations.spec.js new file mode 100644 index 000000000..54753ec2a --- /dev/null +++ b/plugins/azure/advisor/checkAdvisorRecommendations.spec.js @@ -0,0 +1,82 @@ +var expect = require('chai').expect; +var checkAdvisorRecommendations = require('./checkAdvisorRecommendations'); + +const recommendationsList = [ + { + "category": "HighAvailability", + "extendedProperties": { + "assessmentKey": "abcd1234-abcd-1234-abcd-abcd1234abcd", + "score": "50" + }, + "id": "/subscriptions/abcdabcd-1234-abcd-1234-abcdabcdabcd/providers/Microsoft.Advisor/recommendations/abcdabcd-abcd-abcd-abcd-abcdabcdabcd", + "impact": "High", + "impactedField": "Microsoft.Subscriptions/subscriptions", + "impactedValue": "abcdabcd-1234-abcd-1234-abcdabcdabcd", + "lastUpdated": "2020-06-02T15:10:46.164346+00:00", + "metadata": null, + "name": "abcdabcd-abcd-abcd-abcd-abcdabcdabcd", + "recommendationTypeId": "abcd1234-abcd-1234-abcd-abcd1234abcd", + "risk": null, + "shortDescription": { + "problem": "Enable Soft Delete", + "solution": "Use soft delete on your Azure Storage Account to save and recover data after accidental overwrite or deletion" + }, + "suppressionIds": null, + "type": "Microsoft.Advisor/recommendations" + }, +]; + +const createCache = (err, recommendationsList) => { + return { + advisor: { + recommendationsList: { + 'global': { + data: recommendationsList + } + } + } + } +}; + +describe('checkAdvisorRecommendations', function() { + describe('run', function() { + it('should give passing result if no Advisor Recommendations are found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No Advisor Recommendations found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache(null, []); + checkAdvisorRecommendations.run(cache, {}, callback); + }); + + it('should give failing result if Advisor Recommendations found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Advisor Recommendations found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache(null, recommendationsList); + checkAdvisorRecommendations.run(cache, {}, callback); + }); + + it('should give UNKNOWN result if unable to queyr for Advisor Recommendations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for Advisor Recommendations') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache(null); + checkAdvisorRecommendations.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/apiManagement/apiInstanceHasTags.js b/plugins/azure/apiManagement/apiInstanceHasTags.js new file mode 100644 index 000000000..f1903dcc3 --- /dev/null +++ b/plugins/azure/apiManagement/apiInstanceHasTags.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'API Management Instance Has Tags', + category: 'API Management', + domain: 'Developer Tools', + severity: 'Medium', + description: 'Ensures that Azure API Management instance has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify API Management instance and add tags.', + apis: ['apiManagementService:list'], + realtime_triggers: ['microsoftapimanagement:service:write','microsoftapimanagement:service:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.apiManagementService, function(location, rcb){ + var apiManagementService = helpers.addSource(cache, source, + ['apiManagementService', 'list', location]); + + if (!apiManagementService) return rcb(); + + if (apiManagementService.err || !apiManagementService.data) { + helpers.addResult(results, 3, 'Unable to query API Management instances:' + helpers.addError(apiManagementService), location); + return rcb(); + } + + if (!apiManagementService.data.length) { + helpers.addResult(results, 0, 'No existing API Management instances found', location); + return rcb(); + } + + for (let apiInstance of apiManagementService.data) { + if (!apiInstance.id) continue; + + if (apiInstance.tags && Object.entries(apiInstance.tags).length > 0) { + helpers.addResult(results, 0, 'API Management instance has tags associated', location, apiInstance.id); + } else { + helpers.addResult(results, 2, 'API Management instance does not have tags associated', location, apiInstance.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/apiManagement/apiInstanceHasTags.spec.js b/plugins/azure/apiManagement/apiInstanceHasTags.spec.js new file mode 100644 index 000000000..b6fc340cc --- /dev/null +++ b/plugins/azure/apiManagement/apiInstanceHasTags.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var apiInstanceHasTags = require('./apiInstanceHasTags.js'); + +const apiManagementService = [ + { + "etag": "AAAAAAGIUI4=", + "publisherEmail": "dummy.@khulnasoft.com", + "publisherName": "dummy", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "provisioningState": "Succeeded", + "targetProvisioningState": "", + "identity": null, + "zones": null, + "tags": {}, + "location": "East US", + "id": "/subscriptions/123456/resourceGroups/testfunction_group/providers/Microsoft.ApiManagement/service/test", + "name": "meerab", + "type": "Microsoft.ApiManagement/service" + }, + { + "etag": "AAAAAAGIUI4=", + "publisherEmail": "dummy.@khulnasoft.com", + "publisherName": "dummy", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "provisioningState": "Succeeded", + "targetProvisioningState": "", + "identity": { + "type": "SystemAssigned", + "principalId": "fdd1f197-d0e0-4d04-a5ef-9dbb654afd14", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8" + }, + "zones": null, + "location": "East US", + "tags": {"key": "value"}, + "id": "/subscriptions/123456/resourceGroups/testfunction_group/providers/Microsoft.ApiManagement/service/test", + "name": "meerab", + "type": "Microsoft.ApiManagement/service" + } +]; + +const createCache = (apiManagementService, err) => { + return { + apiManagementService: { + list: { + 'eastus': { + data: apiManagementService, + err: err + } + } + } + } +}; + +describe('apiInstanceHasTags', function () { + describe('run', function () { + + it('should give pass result if No existing API Management service instances found', function (done) { + const cache = createCache([]); + apiInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing API Management instances found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query API Management service instances', function (done) { + const cache = createCache(null, 'Error'); + apiInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Management instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if API Management service instances has tags associated', function (done) { + const cache = createCache([apiManagementService[1]]); + apiInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Management instance has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if API Management service instances does not have tags associated', function (done) { + const cache = createCache([apiManagementService[0]]); + apiInstanceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Management instance does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/apiManagement/apiInstanceManagedIdentity.js b/plugins/azure/apiManagement/apiInstanceManagedIdentity.js new file mode 100644 index 000000000..b08f064e4 --- /dev/null +++ b/plugins/azure/apiManagement/apiInstanceManagedIdentity.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'API Management Instance Managed Identity', + category: 'API Management', + domain: 'Developer Tools', + severity: 'Medium', + description: 'Ensures that Azure API Management instance has managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-use-managed-service-identity', + recommended_action: 'Modify API Management instance and add managed identity.', + apis: ['apiManagementService:list'], + realtime_triggers: ['microsoftapimanagement:service:write','microsoftapimanagement:service:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.apiManagementService, function(location, rcb){ + var apiManagementService = helpers.addSource(cache, source, + ['apiManagementService', 'list', location]); + + if (!apiManagementService) return rcb(); + + if (apiManagementService.err || !apiManagementService.data) { + helpers.addResult(results, 3, 'Unable to query API Management instances:' + helpers.addError(apiManagementService), location); + return rcb(); + } + + if (!apiManagementService.data.length) { + helpers.addResult(results, 0, 'No existing API Management instances found', location); + return rcb(); + } + + for (let apiInstance of apiManagementService.data) { + if (!apiInstance.id) continue; + + if (apiInstance.identity) { + helpers.addResult(results, 0, 'API Management instance has managed identity enabled', location, apiInstance.id); + } else { + helpers.addResult(results, 2, 'API Management instance does not have managed identity enabled', location, apiInstance.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/apiManagement/apiInstanceManagedIdentity.spec.js b/plugins/azure/apiManagement/apiInstanceManagedIdentity.spec.js new file mode 100644 index 000000000..b5e953992 --- /dev/null +++ b/plugins/azure/apiManagement/apiInstanceManagedIdentity.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var apiInstanceManagedIdentity = require('./apiInstanceManagedIdentity.js'); + +const apiManagementService = [ + { + "etag": "AAAAAAGIUI4=", + "publisherEmail": "dummy.@khulnasoft.com", + "publisherName": "dummy", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "provisioningState": "Succeeded", + "targetProvisioningState": "", + "identity": null, + "zones": null, + "location": "East US", + "tags": {}, + "id": "/subscriptions/123456/resourceGroups/testfunction_group/providers/Microsoft.ApiManagement/service/test", + "name": "meerab", + "type": "Microsoft.ApiManagement/service" + }, + { + "etag": "AAAAAAGIUI4=", + "publisherEmail": "dummy.@khulnasoft.com", + "publisherName": "dummy", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "provisioningState": "Succeeded", + "targetProvisioningState": "", + "identity": { + "type": "SystemAssigned", + "principalId": "fdd1f197-d0e0-4d04-a5ef-9dbb654afd14", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8" + }, + "zones": null, + "location": "East US", + "tags": {}, + "id": "/subscriptions/123456/resourceGroups/testfunction_group/providers/Microsoft.ApiManagement/service/test", + "name": "meerab", + "type": "Microsoft.ApiManagement/service" + } +]; + +const createCache = (apiManagementService, err) => { + return { + apiManagementService: { + list: { + 'eastus': { + data: apiManagementService, + err: err + } + } + } + } +}; + +describe('apiInstanceManagedIdentity', function () { + describe('run', function () { + + it('should give pass result if No existing API Management service instances found', function (done) { + const cache = createCache([]); + apiInstanceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing API Management instances found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query API Management service instances:', function (done) { + const cache = createCache(null, 'Error'); + apiInstanceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Management instances:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if API Management service instances has managed identity enabled', function (done) { + const cache = createCache([apiManagementService[1]]); + apiInstanceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Management instance has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if API Management service instances does not have managed identity enabled', function (done) { + const cache = createCache([apiManagementService[0]]); + apiInstanceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Management instance does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js new file mode 100644 index 000000000..6ba03cc4b --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configuration Access Key Authentication Disabled', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'Low', + description: 'Ensures that access key authentication is disabled for App Configuration.', + more_info: 'By default, requests can be authenticated with either Microsoft Entra credentials, or by using an access key. For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication for Azure App Configurations.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-disable-access-key-authentication', + recommended_action: 'Ensure that Azure App Configurations have access key authentication disabled.', + apis: ['appConfigurations:list'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftappconfiguration:configurationstores:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb){ + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + if (appConfiguration.disableLocalAuth){ + helpers.addResult(results, 0, 'App Configuration has access key authentication disabled', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration does not have access key authentication disabled', location, appConfiguration.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.spec.js b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.spec.js new file mode 100644 index 000000000..46fe2afb8 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var appConfigAccessKeyAuthDisabled = require('./appConfigAccessKeyAuthDisabled.js'); + +const appConfigurations = [ + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "tags": { "key": "value" }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "disableLocalAuth": false, + + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "disableLocalAuth": true, + + } +]; + +const createCache = (appConfigurations,err) => { + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations, + err: err + } + } + } + } +}; + +describe('appConfigAccessKeyAuthDisabled', function () { + describe('run', function () { + + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigAccessKeyAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigAccessKeyAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration has access key authentication disabled', function (done) { + const cache = createCache([appConfigurations[1]]); + appConfigAccessKeyAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration has access key authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Configuration does not have access key authentication disabled', function (done) { + const cache = createCache([appConfigurations[0]]); + appConfigAccessKeyAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration does not have access key authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigHasTags.js b/plugins/azure/appConfigurations/appConfigHasTags.js new file mode 100644 index 000000000..f02694f63 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigHasTags.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configurations Has Tags', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'Low', + description: 'Ensures that Azure App Configurations has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify app configurations and add tags.', + apis: ['appConfigurations:list'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftappconfiguration:configurationstores:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb){ + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + if (appConfiguration.tags && Object.entries(appConfiguration.tags).length > 0){ + helpers.addResult(results, 0, 'App Configuration has tags associated', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration does not have tags associated', location, appConfiguration.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigHasTags.spec.js b/plugins/azure/appConfigurations/appConfigHasTags.spec.js new file mode 100644 index 000000000..e6501d88e --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigHasTags.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var appConfigHasTags = require('./appConfigHasTags.js'); + +const appConfigurations = [ + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "tags": { "key": "value" }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + + } +]; + +const createCache = (appConfigurations,err) => { + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations, + err: err + } + } + } + } +}; + +describe('appConfigHasTags', function () { + describe('run', function () { + + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration has tags associated', function (done) { + const cache = createCache([appConfigurations[0]]); + appConfigHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Configuration does not have tags associated', function (done) { + const cache = createCache([appConfigurations[1]]); + appConfigHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigManagedIdentity.js b/plugins/azure/appConfigurations/appConfigManagedIdentity.js new file mode 100644 index 000000000..2bd8e7b51 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigManagedIdentity.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configurations Managed Identity', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'Medium', + description: 'Ensures that Azure App Configurations have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview-managed-identity', + recommended_action: 'Modify App Configuration store and add managed identity.', + apis: ['appConfigurations:list'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftappconfiguration:configurationstores:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb){ + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + if (appConfiguration.identity) { + helpers.addResult(results, 0, 'App Configuration has managed identity enabled', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration does not have managed identity enabled', location, appConfiguration.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigManagedIdentity.spec.js b/plugins/azure/appConfigurations/appConfigManagedIdentity.spec.js new file mode 100644 index 000000000..47c4c96aa --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigManagedIdentity.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var appConfigManagedIdentity = require('./appConfigManagedIdentity.js'); + +const appConfigurations = [ + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "properties": { + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false + }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "name": "meerab-test-rg", + "tags": {} + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "properties": { + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false + }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "name": "meerab-test-rg", + "tags": {}, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "12345", + "tenantId": "123456", + "userAssignedIdentities": { + "/subscriptions/123/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "1234567", + "ClientId": "123456789" + } + } + } + } +]; + +const createCache = (appConfigurations,err) => { + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations, + err: err + } + } + } + } +}; + +describe('appConfigManagedIdentity', function () { + describe('run', function () { + + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration has managed identity enabled', function (done) { + const cache = createCache([appConfigurations[1]]); + appConfigManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Configuration does not have managed identity enabled', function (done) { + const cache = createCache([appConfigurations[0]]); + appConfigManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.js b/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.js new file mode 100644 index 000000000..494ab3a93 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configuration Encryption At Rest with CMK', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'Medium', + description: 'Ensures that Azure App Configuration stores are encrypted with CMK.', + more_info: 'App Configuration encrypts sensitive information at rest by default using Azure managed key. The use of customer-managed keys provides enhanced data protection by allowing you to manage your encryption keys. When managed key encryption is used, all sensitive information in App Configuration is encrypted with a user-provided Azure Key Vault key.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/concept-customer-managed-keys', + recommended_action: 'Ensure that Azure App Configuration store has CMK encryption enabled.', + apis: ['appConfigurations:list'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftappconfiguration:configurationstores:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb){ + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + if (appConfiguration.sku && + appConfiguration.sku.name && + appConfiguration.sku.name.toLowerCase() === 'free') { + helpers.addResult(results, 0, 'App Configuration tier is free', location, appConfiguration.id); + } else { + + if (appConfiguration.encryption && appConfiguration.encryption.keyVaultProperties && appConfiguration.encryption.keyVaultProperties.keyIdentifier) { + helpers.addResult(results, 0, 'App Configuration is encrypted using CMK', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration is not encrypted using CMK', location, appConfiguration.id); + } + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.spec.js b/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.spec.js new file mode 100644 index 000000000..90abfbed8 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationCmkEncrypted.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var appConfigurationCmkEncrypted = require('./appConfigurationCmkEncrypted.js'); + +const appConfigurations = [ + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": { + "keyIdentifier": "https://dummy-test-key.vault.azure.net/keys/test-key", + "identityClientId": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false + }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "name": "meerab-test-rg", + "tags": {} + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "name": "meerab-test-rg", + "tags": {}, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "12345", + "tenantId": "123456", + "userAssignedIdentities": { + "/subscriptions/123/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "1234567", + "ClientId": "123456789" + } + } + } + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": { + "keyIdentifier": "https://dummy-test-key.vault.azure.net/keys/test-key", + "identityClientId": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false + }, + "id": "/subscriptions/123/resourceGroups/meerab-rg/providers/Microsoft.AppConfiguration/configurationStores/meerab-test-rg", + "name": "meerab-test-rg", + "tags": {}, + "sku": { + "name": "free" + } + }, +]; + +const createCache = (appConfigurations,err) => { + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations, + err: err + } + } + } + } +}; + +describe('appConfigurationCmkEncrypted', function () { + describe('run', function () { + + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigurationCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigurationCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration is encrypted using CMK', function (done) { + const cache = createCache([appConfigurations[0]]); + appConfigurationCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration tier is free', function (done) { + const cache = createCache([appConfigurations[2]]); + appConfigurationCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration tier is free'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Configuration is not encrypted using CMK', function (done) { + const cache = createCache([appConfigurations[1]]); + appConfigurationCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.js b/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.js new file mode 100644 index 000000000..0bafc8202 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configuration Diagnostic Logs', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'Medium', + description: 'Ensures that Azure App Configuration have diagnostic logs enabled.', + more_info: 'Enabling diagnostic logging for App Configuration helps with performance monitoring, troubleshooting, and security optimization.', + recommended_action: 'Enable diagnostic logging for all App Configurations.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/monitor-app-configuration?tabs=portal#monitoringdata', + apis: ['appConfigurations:list','diagnosticSettings:listByAppConfigurations'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete','microsoftappconfiguration:configurationstores:delete'], + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb) { + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAppConfigurations', location, appConfiguration.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for App Configuration diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, appConfiguration.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'App Configuration has diagnostic logs enabled', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration does not have diagnostic logs enabled', location, appConfiguration.id); + } + + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.spec.js b/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.spec.js new file mode 100644 index 000000000..06e453b60 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationDiagnosticLogs.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var appConfigurationDiagnosticLogs = require('./appConfigurationDiagnosticLogs'); + +const appConfigurations = [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedappConfigurations/absbaks2", + }, +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ContainerService/appConfigurations/absbaks2/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'server-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (appConfigurations, ds) => { + const id = appConfigurations && appConfigurations.length ? appConfigurations[0].id : null; + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations + } + } + }, + diagnosticSettings: { + listByAppConfigurations: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + appConfigurations: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('appConfigurationDiagnosticLogs', function() { + describe('run', function() { + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigurationDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigurationDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([appConfigurations[0]], null); + appConfigurationDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Configuration diagnostic settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([appConfigurations[0]], [diagnosticSettings[0]]); + appConfigurationDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([appConfigurations[0]], [[]]); + appConfigurationDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigurationPublicAccess.js b/plugins/azure/appConfigurations/appConfigurationPublicAccess.js new file mode 100644 index 000000000..57a7080ec --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationPublicAccess.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Configurations Public Access', + category: 'App Configuration', + domain: 'Developer Tools', + severity: 'High', + description: 'Ensures that Azure App Configurations have public access disabled.', + more_info: 'Disabling public network access improves security by ensuring that the app configuration isn\'t exposed on the public internet. Limit exposure of your resources by creating private endpoints instead.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-disable-public-access?tabs=azure-portal', + recommended_action: 'Modify App Configuration and disable public access.', + apis: ['appConfigurations:list'], + realtime_triggers: ['microsoftappconfiguration:configurationstores:write','microsoftappconfiguration:configurationstores:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.appConfigurations, function(location, rcb){ + var appConfigurations = helpers.addSource(cache, source, + ['appConfigurations', 'list', location]); + + if (!appConfigurations) return rcb(); + + if (appConfigurations.err || !appConfigurations.data) { + helpers.addResult(results, 3, 'Unable to query App Configuration: ' + helpers.addError(appConfigurations), location); + return rcb(); + } + + if (!appConfigurations.data.length) { + helpers.addResult(results, 0, 'No existing App Configurations found', location); + return rcb(); + } + + for (let appConfiguration of appConfigurations.data) { + if (!appConfiguration.id) continue; + + if (appConfiguration.publicNetworkAccess && appConfiguration.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'App Configuration has public network access disabled', location, appConfiguration.id); + } else { + helpers.addResult(results, 2, 'App Configuration does not have public network access disabled', location, appConfiguration.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appConfigurations/appConfigurationPublicAccess.spec.js b/plugins/azure/appConfigurations/appConfigurationPublicAccess.spec.js new file mode 100644 index 000000000..7503b9237 --- /dev/null +++ b/plugins/azure/appConfigurations/appConfigurationPublicAccess.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +var appConfigurationPublicAccess = require('./appConfigurationPublicAccess.js'); + +const appConfigurations = [ + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Enabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false, + "id": "/subscriptions/123/resourceGroups/dummy-rg/providers/Microsoft.AppConfiguration/configurationStores/dummy-test-rg", + "name": "dummy-test-rg", + "tags": {} + }, + { + "type": "Microsoft.AppConfiguration/configurationStores", + "location": "eastus", + "provisioningState": "Succeeded", + "creationDate": "2023-12-27T09:26:54+00:00", + "endpoint": "https://dummy-test-rg.azconfig.io", + "encryption": { + "keyVaultProperties": null + }, + "privateEndpointConnections": null, + "publicNetworkAccess": "Disabled", + "disableLocalAuth": false, + "softDeleteRetentionInDays": 0, + "enablePurgeProtection": false, + "id": "/subscriptions/123/resourceGroups/dummy-rg/providers/Microsoft.AppConfiguration/configurationStores/dummy-test-rg", + "name": "dummy-test-rg", + "tags": {}, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "1234", + "tenantId": "1234", + "userAssignedIdentities": { + "/subscriptions/123/resourcegroups/dummy-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testdummy": { + "PrincipalId": "12344", + "ClientId": "123445" + } + } + } + } +]; + +const createCache = (appConfigurations,err) => { + return { + appConfigurations: { + list: { + 'eastus': { + data: appConfigurations, + err: err + } + } + } + } +}; + +describe('appConfigurationPublicAccess', function () { + describe('run', function () { + + it('should give pass result if No existing app configurations found', function (done) { + const cache = createCache([]); + appConfigurationPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Configurations found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query app configurations:', function (done) { + const cache = createCache(null, 'Error'); + appConfigurationPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Configuration:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Configuration has public network access disabled', function (done) { + const cache = createCache([appConfigurations[1]]); + appConfigurationPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Configuration has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Configuration does not have public network access disabled', function (done) { + const cache = createCache([appConfigurations[0]]); + appConfigurationPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Configuration does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/applicationGateway/agHttpsListenerOnly.js b/plugins/azure/applicationGateway/agHttpsListenerOnly.js new file mode 100644 index 000000000..87012d4ec --- /dev/null +++ b/plugins/azure/applicationGateway/agHttpsListenerOnly.js @@ -0,0 +1,59 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway HTTPS Listener', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Application Gateway is configured to use HTTPS liteners only.', + more_info: 'Using non-HTTPS listeners leaves the traffic between the client and the application unencrypted. Using HTTPS listeners instead providing an additional layer of security by ensuring encryption, reducing risk of security attacks and breaches.', + recommended_action: 'Remove all non-HTTPS listeners from application gateway and replace them with https listeners.', + link: 'https://learn.microsoft.com/en-us/azure/application-gateway/configuration-listeners#protocol', + apis: ['applicationGateway:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write', 'microsoftnetwork:applicationgateways:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.applicationGateway, (location, rcb) => { + var appGateways = helpers.addSource(cache, source, + ['applicationGateway', 'listAll', location]); + + if (!appGateways) return rcb(); + + if (appGateways.err || !appGateways.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway: ' + helpers.addError(appGateways), location); + return rcb(); + } + + if (!appGateways.data.length) { + helpers.addResult(results, 0, 'No existing Application Gateway found', location); + return rcb(); + } + + for (let appGateway of appGateways.data) { + if (!appGateway.id) continue; + + if (appGateway.httpListeners && appGateway.httpListeners.length) { + var httpListeners = appGateway.httpListeners + .filter(listener => listener.properties.protocol && listener.properties.protocol.toLowerCase() !== 'https') + .map(listener => listener.name); + + if (httpListeners && httpListeners.length) { + helpers.addResult(results, 2, `Application Gateway is using following non-https listeners: ${httpListeners.join(',')}`, location, appGateway.id); + } else { + helpers.addResult(results, 0, 'Application Gateway is using https listeners only', location, appGateway.id); + } + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agHttpsListenerOnly.spec.js b/plugins/azure/applicationGateway/agHttpsListenerOnly.spec.js new file mode 100644 index 000000000..5a8bdbc54 --- /dev/null +++ b/plugins/azure/applicationGateway/agHttpsListenerOnly.spec.js @@ -0,0 +1,141 @@ +var expect = require('chai').expect; +var agHttpsListenerOnly = require('./agHttpsListenerOnly'); + +const appGateway = [ + { "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "httpListeners": [ + { + "name": "listenerhttp", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-app-gateway/httpListeners/listenerhttp", + "etag": "W/\"9a09a0a2-7baa-44a2-b37b-88308429d799\"", + "properties": { + "protocol": "Http", + "hostNames": [], + "requireServerNameIndication": false, + }, + "type": "Microsoft.Network/applicationGateways/httpListeners" + }, + { + "name": "listenerhttp2", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-app-gateway/httpListeners/listenerhttp", + "etag": "W/\"9a09a0a2-7baa-44a2-b37b-88308429d799\"", + "properties": { + "protocol": "Http", + "hostNames": [], + "requireServerNameIndication": false, + }, + "type": "Microsoft.Network/applicationGateways/httpListeners" + }, + { + "name": "listenerhttp3", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-app-gateway/httpListeners/listenerhttp", + "etag": "W/\"9a09a0a2-7baa-44a2-b37b-88308429d799\"", + "properties": { + "protocol": "Https", + "hostNames": [], + "requireServerNameIndication": false, + }, + "type": "Microsoft.Network/applicationGateways/httpListeners" + } + ], + }, + { + "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "httpListeners": [ + { + "name": "listenerhttp", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-app-gateway/httpListeners/listenerhttp", + "etag": "W/\"9a09a0a2-7baa-44a2-b37b-88308429d799\"", + "properties": { + "protocol": "Https", + "hostNames": [], + "requireServerNameIndication": false, + }, + "type": "Microsoft.Network/applicationGateways/httpListeners" + } + ], + }, +]; + +const createCache = (gt) => { + return { + applicationGateway: { + listAll: { + 'eastus': { + data: gt + } + } + } + }; +}; + +const createErrorCache = () => { + return { + applicationGateway: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agHttpsListenerOnly', function() { + describe('run', function() { + it('should give passing result if no Application Gateway found', function(done) { + const cache = createCache([]); + agHttpsListenerOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Application Gateway found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application Gateway is using following non-https listeners', function(done) { + const cache = createCache([appGateway[0]]); + agHttpsListenerOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application Gateway is using following non-https listeners: listenerhttp,listenerhttp2'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Application Gateway', function(done) { + const cache = createErrorCache(); + agHttpsListenerOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway is using https listeners only', function(done) { + const cache = createCache([appGateway[1]]); + agHttpsListenerOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application Gateway is using https listeners only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); + diff --git a/plugins/azure/applicationGateway/agPreventionModeEnabled.js b/plugins/azure/applicationGateway/agPreventionModeEnabled.js new file mode 100644 index 000000000..5c53520e0 --- /dev/null +++ b/plugins/azure/applicationGateway/agPreventionModeEnabled.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway WAF Prevention Mode Enabled', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure that WAF policy for Microsoft Azure Application gateway is set to Prevention mode.', + more_info: 'Azure Web Application Firewall (WAF) on Azure Application Gateway provides centralized protection of your web applications from common exploits and vulnerabilities. Web applications are increasingly targeted by malicious attacks that exploit commonly known vulnerabilities.', + recommended_action: 'Modify application gateway WAF policy and enable prevention mode.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/ag-overview', + apis: ['wafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:write','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.wafPolicies, (location, rcb) => { + + var wafPolicies = helpers.addSource(cache, source, + ['wafPolicies', 'listAll', location]); + + if (!wafPolicies) return rcb(); + + if (wafPolicies.err || !wafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway WAF policies: ' + helpers.addError(wafPolicies), location); + return rcb(); + } + if (!wafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing WAF policies found', location); + return rcb(); + } + + for (let policy of wafPolicies.data) { + if (!policy.id) continue; + + if (policy.policySettings && policy.policySettings.mode && policy.policySettings.mode.toLowerCase() === 'prevention') { + helpers.addResult(results, 0, 'Prevention mode enabled for application gateway WAF policy', location, policy.id); + } else { + helpers.addResult(results, 2, 'Prevention mode not enabled for application gateway WAF policy', location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agPreventionModeEnabled.spec.js b/plugins/azure/applicationGateway/agPreventionModeEnabled.spec.js new file mode 100644 index 000000000..13d8eb0c2 --- /dev/null +++ b/plugins/azure/applicationGateway/agPreventionModeEnabled.spec.js @@ -0,0 +1,100 @@ +var expect = require('chai').expect; +var agPreventionModeEnabled = require('./agPreventionModeEnabled.js'); + +const wafPolicy = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true, + "policySettings":{ + "mode": "prevention" + } + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (waf) => { + return { + wafPolicies: { + listAll: { + 'eastus': { + data: waf + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafPolicies: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agPreventionModeEnabled', function() { + describe('run', function() { + it('should give passing result if no WAF policy found', function(done) { + const cache = createCache([]); + agPreventionModeEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing WAF policies found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if application gateway WAF Policy prevention mode not enabled', function(done) { + const cache = createCache([wafPolicy[1]]); + agPreventionModeEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Prevention mode not enabled for application gateway WAF policy'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result if Unable to query for WAF policy', function(done) { + const cache = createErrorCache(); + agPreventionModeEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway WAF policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway WAF policy prevention mode enabled', function(done) { + const cache = createCache([wafPolicy[0]]); + agPreventionModeEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Prevention mode enabled for application gateway WAF policy'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/applicationGateway/agRequestBodyInspection.js b/plugins/azure/applicationGateway/agRequestBodyInspection.js new file mode 100644 index 000000000..f23b9829d --- /dev/null +++ b/plugins/azure/applicationGateway/agRequestBodyInspection.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway Request Body Inspection', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that request body inspection is enabled for Application Gateway WAF policy.', + more_info: 'Application Gateway WAF policy with disabled request body inspection doesn\'t evaluate the contents of an HTTP message\'s body. Enabling it allows us to inspect properties that may not be evaluated in the HTTP headers, cookies, or URI.', + recommended_action: 'Modify application gateway WAF policy and enable request body inspection in policy settings.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-waf-request-size-limits#request-body-inspection', + apis: ['wafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:write','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.wafPolicies, (location, rcb) => { + + var wafPolicies = helpers.addSource(cache, source, + ['wafPolicies', 'listAll', location]); + + if (!wafPolicies) return rcb(); + + if (wafPolicies.err || !wafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway WAF policies: ' + helpers.addError(wafPolicies), location); + return rcb(); + } + if (!wafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing WAF policies found', location); + return rcb(); + } + + for (let policy of wafPolicies.data) { + if (!policy.id) continue; + + if (policy.policySettings && policy.policySettings.requestBodyCheck) { + helpers.addResult(results, 0, 'Application gateway WAF policy has request body inspection enabled', location, policy.id); + } else { + helpers.addResult(results, 2, 'Application gateway WAF policy does not have request body inspection enabled', location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agRequestBodyInspection.spec.js b/plugins/azure/applicationGateway/agRequestBodyInspection.spec.js new file mode 100644 index 000000000..ab16fd91b --- /dev/null +++ b/plugins/azure/applicationGateway/agRequestBodyInspection.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +var agRequestBodyInspection = require('./agRequestBodyInspection.js'); + +const wafPolicy = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true, + "policySettings":{ + "mode": "prevention", + "requestBodyCheck": true + } + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false, + "policySettings":{ + "mode": "prevention", + "requestBodyCheck": false + } + } +]; + +const createCache = (waf) => { + return { + wafPolicies: { + listAll: { + 'eastus': { + data: waf + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafPolicies: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agRequestBodyInspection', function() { + describe('run', function() { + it('should give passing result if no WAF policy found', function(done) { + const cache = createCache([]); + agRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing WAF policies found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application gateway WAF policy does not have request body inspection enabled', function(done) { + const cache = createCache([wafPolicy[1]]); + agRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application gateway WAF policy does not have request body inspection enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result if Unable to query for WAF policy', function(done) { + const cache = createErrorCache(); + agRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway WAF policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application gateway WAF policy has request body inspection enabled', function(done) { + const cache = createCache([wafPolicy[0]]); + agRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application gateway WAF policy has request body inspection enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/applicationGateway/agRequestBodySize.js b/plugins/azure/applicationGateway/agRequestBodySize.js new file mode 100644 index 000000000..c87e5c755 --- /dev/null +++ b/plugins/azure/applicationGateway/agRequestBodySize.js @@ -0,0 +1,63 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway Request Body Size', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that Application Gateway WAF policy have desired request body size configured.', + more_info: 'Application Gateway WAF policy includes a maximum request body size field, specified in kilobytes. This setting controls the overall request size limit, excluding any file uploads. Configuring an appropriate value for this field is crucial for optimizing security and performance.', + recommended_action: 'Modify application gateway WAF policy and set the max body size to desired value.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-waf-request-size-limits', + apis: ['wafPolicies:listAll'], + settings: { + max_request_body_size: { + name: 'Max Request Body Size', + description: 'The default value for request body size is 128 KB. The setting checks for request body size and produces pass result if it is greater than or equal to the desired value.', + regex: '^(12[8-9]|1[3-9]{1,2}|2000)$', + default: '128', + }, + }, + realtime_triggers: ['microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:write', 'microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + var config = { + max_request_body_size: settings.max_request_body_size || this.settings.max_request_body_size.default, + }; + + async.each(locations.wafPolicies, (location, rcb) => { + + var wafPolicies = helpers.addSource(cache, source, + ['wafPolicies', 'listAll', location]); + + if (!wafPolicies) return rcb(); + + if (wafPolicies.err || !wafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway WAF policies: ' + helpers.addError(wafPolicies), location); + return rcb(); + } + if (!wafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing WAF policies found', location); + return rcb(); + } + + for (let policy of wafPolicies.data) { + if (!policy.id) continue; + var maxRequestBodySize = config.max_request_body_size; + if (policy.policySettings && policy.policySettings.maxRequestBodySizeInKb && policy.policySettings.maxRequestBodySizeInKb <= maxRequestBodySize) { + helpers.addResult(results, 0, `Application gateway WAF policy has max request body size of ${policy.policySettings.maxRequestBodySizeInKb} which is less than or equal to ${maxRequestBodySize}`, location, policy.id); + } else { + helpers.addResult(results, 2, `Application gateway WAF policy has max request body size of ${policy.policySettings.maxRequestBodySizeInKb} which is greater than ${maxRequestBodySize}`, location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agRequestBodySize.spec.js b/plugins/azure/applicationGateway/agRequestBodySize.spec.js new file mode 100644 index 000000000..0fb6b93ad --- /dev/null +++ b/plugins/azure/applicationGateway/agRequestBodySize.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var agRequestBodySize = require('./agRequestBodySize.js'); + +const wafPolicy = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true, + "policySettings":{ + "mode": "prevention", + "requestBodyCheck": true, + "maxRequestBodySizeInKb": 128 + } + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false, + "policySettings":{ + "mode": "prevention", + "requestBodyCheck": true, + "maxRequestBodySizeInKb": 800 + + } + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false, + "policySettings":{ + "mode": "prevention", + "requestBodyCheck": false, + "maxRequestBodySizeInKb": 128 + + } + }, +]; + +const createCache = (waf) => { + return { + wafPolicies: { + listAll: { + 'eastus': { + data: waf + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafPolicies: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agRequestBodySize', function() { + describe('run', function() { + it('should give passing result if no WAF policy found', function(done) { + const cache = createCache([]); + agRequestBodySize.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing WAF policies found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for WAF policy', function(done) { + const cache = createErrorCache(); + agRequestBodySize.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway WAF policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application gateway WAF policy has max request body size of 128 - without setting', function(done) { + const cache = createCache([wafPolicy[0]]); + agRequestBodySize.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application gateway WAF policy has max request body size of 128 which is less than or equal to 128'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application gateway WAF policy has max request body size greater than 500 - with setting', function(done) { + const cache = createCache([wafPolicy[1]]); + agRequestBodySize.run(cache, {max_request_body_size: 500}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application gateway WAF policy has max request body size of 800 which is greater than 500'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/applicationGateway/agSecurityLoggingEnabled.js b/plugins/azure/applicationGateway/agSecurityLoggingEnabled.js new file mode 100644 index 000000000..aef52e489 --- /dev/null +++ b/plugins/azure/applicationGateway/agSecurityLoggingEnabled.js @@ -0,0 +1,68 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway Security Logging Enabled', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Application Gateway Access and Firewall logs are enabled.', + more_info: 'Application Gateway access logs helps to analyze important information including the caller\'s IP, requested URL, response latency, return code, and bytes in and out. Web application firewall (WAF) logs can be used to detect potential attacks, and false positive detections that might indicate legitimate requests that the WAF blocked.', + recommended_action: 'Modify Application Gateway and add diagnostic settings for Access and Firewall Logs.', + link: 'https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-diagnostics', + apis: ['applicationGateway:listAll', 'diagnosticSettings:listByApplicationGateways'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.applicationGateway, (location, rcb) => { + const applicationGateways = helpers.addSource(cache, source, + ['applicationGateway', 'listAll', location]); + + if (!applicationGateways) return rcb(); + + if (applicationGateways.err || !applicationGateways.data) { + helpers.addResult(results, 3, + 'Unable to query Application Gateway: ' + helpers.addError(applicationGateways), location); + return rcb(); + } + + if (!applicationGateways.data.length) { + helpers.addResult(results, 0, 'No existing Application Gateway found', location); + return rcb(); + } + + applicationGateways.data.forEach(function(appGateway) { + if (!appGateway.id) return; + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByApplicationGateways', location, appGateway.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Application Gateway diagnostics settings: ' + helpers.addError(diagnosticSettings), location, appGateway.id); + } else { + //First consider that all the logs are missing then remove the ones that are present + var missingLogs = ['ApplicationGatewayAccessLog', 'ApplicationGatewayFirewallLog']; + + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + if (missingLogs.length) { + helpers.addResult(results, 2, `Application Gateway does not have security logging enabled. Missing Logs ${missingLogs}`, location, appGateway.id); + } else { + helpers.addResult(results, 0, 'Application Gateway has security logging enabled', location, appGateway.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/applicationGateway/agSecurityLoggingEnabled.spec.js b/plugins/azure/applicationGateway/agSecurityLoggingEnabled.spec.js new file mode 100644 index 000000000..0e7f3c13c --- /dev/null +++ b/plugins/azure/applicationGateway/agSecurityLoggingEnabled.spec.js @@ -0,0 +1,236 @@ +var expect = require('chai').expect; +var agSecurityLoggingEnabled = require('./agSecurityLoggingEnabled'); + +const appGateway = [ + { + "name": "meerab-test", + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourceGroups/meerab-rg/providers/Microsoft.Network/applicationGateways/meerab-test", + "etag": "W/\"b3bb388c-f5ff-495a-8163-98edbeb32047\"", + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "tags": {}, + "provisioningState": "Succeeded", + "resourceGuid": "c166b007-4ecd-45c2-9faa-74664407558b", + "sku": { + "name": "WAF_v2", + "tier": "WAF_v2", + "family": "Generation_1" + }, + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/microsoft.network/applicationgateways/meerab-test/providers/microsoft.insights/diagnosticSettings/app-ds", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "ApplicationGatewayAccessLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "ApplicationGatewayFirewallLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, + {}, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/microsoft.network/applicationgateways/meerab-test/providers/microsoft.insights/diagnosticSettings/app-ds", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "ApplicationGatewayAccessLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/microsoft.network/applicationgateways/meerab-test/providers/microsoft.insights/diagnosticSettings/app-ds", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "", + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, +] +const createCache = (applicationGateway, diagnostics) => { + let diagnostic = {}; + if (applicationGateway.length) { + diagnostic[applicationGateway[0].id] = { + data: diagnostics + }; + } + + return { + applicationGateway: { + listAll: { + 'eastus': { + data: applicationGateway + } + } + }, + diagnosticSettings: { + listByApplicationGateways: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'appGateway') { + return { + applicationGateway: { + listAll: { + 'eastus': {} + } + } + }; + } else if (key === 'noGateway'){ + return { + applicationGateway: { + listAll: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + applicationGateway: { + listAll: { + 'eastus': { + data: [appGateway[0]] + } + } + }, + diagnosticSettings: { + listByApplicationGateways: { + 'eastus': {} + } + } + }; + } else { + const appId = (appGateway && appGateway.length) ? appGateway[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + applicationGateway: { + listAll: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByApplicationGateways: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('agSecurityLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if no Application Gateway found', function(done) { + const cache = createErrorCache('noGateway'); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Application Gateway found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result unable to query Application Gateway:', function(done) { + const cache = createErrorCache('appGateway'); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Application Gateway'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result unable to query Application Gateway diagnostics settings:', function(done) { + const cache = createErrorCache('diagnostic'); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Application Gateway diagnostics settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give pass result if Application Gateway has security logging enabled', function(done) { + const cache = createCache([appGateway[0]],[diagnosticSettings[0]]); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application Gateway has security logging enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give fail result if Application Gateway have missing logs', function(done) { + const cache = createCache([appGateway[0]],[diagnosticSettings[2]]); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application Gateway does not have security logging enabled. Missing Logs ApplicationGatewayFirewallLog'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give pass result if Application Gateway have allLogs Enabled', function(done) { + const cache = createCache([appGateway[0]],[diagnosticSettings[3]]); + agSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application Gateway has security logging enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); + diff --git a/plugins/azure/applicationGateway/agSslPolicy.js b/plugins/azure/applicationGateway/agSslPolicy.js new file mode 100644 index 000000000..6dd058e55 --- /dev/null +++ b/plugins/azure/applicationGateway/agSslPolicy.js @@ -0,0 +1,72 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway SSL Policy', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Application Gateway is using minimum TLS version of TLSv1_2.', + more_info: 'Transport Layer Security (TLS), previously known as Secure Sockets Layer (SSL), is the standard security technology for establishing an encrypted link between a web server and a browser. This link ensures that all data passed between the web server and browsers remain private and encrypted.', + recommended_action: 'Modify Application Gateway with latest SSL policy which supports minimum TLS version.', + link: 'https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-ssl-policy-overview', + apis: ['applicationGateway:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + const recommendedSSLPolicies = ['AppGwSslPolicy20170401S', 'AppGwSslPolicy20220101' , 'AppGwSslPolicy20220101S']; + + async.each(locations.applicationGateway, (location, rcb) => { + var appGateways = helpers.addSource(cache, source, + ['applicationGateway', 'listAll', location]); + + if (!appGateways) return rcb(); + + if (appGateways.err || !appGateways.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway: ' + helpers.addError(appGateways), location); + return rcb(); + } + + if (!appGateways.data.length) { + helpers.addResult(results, 0, 'No existing Application Gateway found', location); + return rcb(); + } + + for (let appGateway of appGateways.data) { + if (!appGateway.id) continue; + + var found = false; + if (appGateway.sslPolicy && appGateway.sslPolicy.policyType) { + const sslPolicy = appGateway.sslPolicy; + if (sslPolicy.policyType == 'Predefined' && sslPolicy.policyName && recommendedSSLPolicies.indexOf(sslPolicy.policyName) > -1) { + found = true; + } else if ((sslPolicy.policyType == 'Custom' || sslPolicy.policyType == 'CustomV2') && sslPolicy.minProtocolVersion) { + // Check for protocol version if it matches the regex TLSV1.2 and then split on letter v + var regexMatched = /^(tls)(v(\d+)_(\d+))$/i.test(sslPolicy.minProtocolVersion)? sslPolicy.minProtocolVersion.replace('_', '.').split(/v/i): ''; + if (regexMatched){ + var tlsVersion = parseFloat(regexMatched[1]); + if (tlsVersion >= 1.2){ + found = true; + } + } else { + helpers.addResult(results, 2, 'Application Gateway TLS version cannot be parsed', location, appGateway.id); + break; + } + } + } + if (found){ + helpers.addResult(results, 0, 'Application Gateway is using SSL policy which supports latest TLS version', location, appGateway.id); + } else { + helpers.addResult(results, 2, 'Application Gateway is using SSL policy which does not support latest TLS version', location, appGateway.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agSslPolicy.spec.js b/plugins/azure/applicationGateway/agSslPolicy.spec.js new file mode 100644 index 000000000..6aca0bb20 --- /dev/null +++ b/plugins/azure/applicationGateway/agSslPolicy.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var agSslPolicy = require('./agSslPolicy'); + +const appGateway = [ + { "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "sslPolicy": { + "policyType": "Predefined", + "policyName": "AppGwSslPolicy20220101" + }, + }, + { + "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "sslPolicy": { + "policyType": "Predefined", + "policyName": "AppGwSslPolicy20150101" + }, + }, + { "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "sslPolicy": { + "policyType": "Custom", + "minProtocolVersion": "TLSV1_3" + }, + }, + { "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "sslPolicy": { + "policyType": "Custom", + "minProtocolVersion": "TLSV13" + }, + } +]; + +const createCache = (gt) => { + return { + applicationGateway: { + listAll: { + 'eastus': { + data: gt + } + } + } + }; +}; + +const createErrorCache = () => { + return { + applicationGateway: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agSslPolicy', function() { + describe('run', function() { + it('should give passing result if no Application Gateway found', function(done) { + const cache = createCache([]); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Application Gateway found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application Gateway is using ssl policy which does not supports minimum TLS version', function(done) { + const cache = createCache([appGateway[1]]); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SSL policy which does not support latest TLS version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Application Gateway', function(done) { + const cache = createErrorCache(); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway is using ssl policy which supports minimum TLS version', function(done) { + const cache = createCache([appGateway[0]]); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SSL policy which supports latest TLS version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway is using custom ssl policy which supports minimum TLS version', function(done) { + const cache = createCache([appGateway[2]]); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SSL policy which supports latest TLS version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application Gateway is using tls version which cannot be parsed', function(done) { + const cache = createCache([appGateway[3]]); + agSslPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application Gateway TLS version cannot be parsed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); + diff --git a/plugins/azure/applicationGateway/agWafEnabled.js b/plugins/azure/applicationGateway/agWafEnabled.js new file mode 100644 index 000000000..e6436db32 --- /dev/null +++ b/plugins/azure/applicationGateway/agWafEnabled.js @@ -0,0 +1,58 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway WAF Enabled', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure that Web Application FireWall (WAF) is enabled for Application Gateways.', + more_info: 'Azure Web Application Firewall (WAF) on Azure Application Gateway provides centralized protection of your web applications from common exploits and vulnerabilities. Web applications are increasingly targeted by malicious attacks that exploit commonly known vulnerabilities.', + recommended_action: 'Modify application gateway and enable WAF.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/ag-overview', + apis: ['applicationGateway:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:write','microsoftnetwork:applicationgatewaywebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.applicationGateway, (location, rcb) => { + var appGateways = helpers.addSource(cache, source, + ['applicationGateway', 'listAll', location]); + + if (!appGateways) return rcb(); + + if (appGateways.err || !appGateways.data) { + helpers.addResult(results, 3, 'Unable to query for Application Gateway: ' + helpers.addError(appGateways), location); + return rcb(); + } + + if (!appGateways.data.length) { + helpers.addResult(results, 0, 'No existing Application Gateway found', location); + return rcb(); + } + + for (let appGateway of appGateways.data) { + if (!appGateway.id) continue; + + if (appGateway.sku.tier != 'WAF_v2'){ + helpers.addResult(results, 2, 'Prevention mode is not supported for WAF Standard v2 tier', location, appGateway.id); + continue; + } + + if (appGateway.webApplicationFirewallConfiguration && appGateway.webApplicationFirewallConfiguration.enabled + && appGateway.webApplicationFirewallConfiguration.enabled === true) { + helpers.addResult(results, 0, 'Web Application Firewall is enabled for Application Gateway', location, appGateway.id); + } else { + helpers.addResult(results, 2, 'Web Application Firewall is not enabled for Application Gateway', location, appGateway.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/agWafEnabled.spec.js b/plugins/azure/applicationGateway/agWafEnabled.spec.js new file mode 100644 index 000000000..8726eabc1 --- /dev/null +++ b/plugins/azure/applicationGateway/agWafEnabled.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var agWafEnabled = require('./agWafEnabled'); + +const appGateway = [ + { "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "webApplicationFirewallConfiguration": { + "enabled": true, + "firewallMode": "Prevention", + }, + }, + { + "sku": { + "tier": "WAF_v2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "webApplicationFirewallConfiguration": { + "enabled": false, + "firewallMode": "Detection", + }, + }, + + { "sku": { + "tier": "STANDARD_V2" + }, + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test",', + "type": "Microsoft.Network/applicationGateways", + "location": "eastus", + "webApplicationFirewallConfiguration": { + "enabled": false, + "firewallMode": "Detection", + }, + } +]; + +const createCache = (gt) => { + return { + applicationGateway: { + listAll: { + 'eastus': { + data: gt + } + } + } + }; +}; + +const createErrorCache = () => { + return { + applicationGateway: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('agWafEnabled', function() { + describe('run', function() { + it('should give passing result if no Application Gateway found', function(done) { + const cache = createCache([]); + agWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Application Gateway found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application Gateway waf prevention mode not enabled', function(done) { + const cache = createCache([appGateway[1]]); + agWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Web Application Firewall is not enabled for Application Gateway'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Application Gateway', function(done) { + const cache = createErrorCache(); + agWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Application Gateway:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway has tags associated', function(done) { + const cache = createCache([appGateway[0]]); + agWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Web Application Firewall is enabled for Application Gateway'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if tier for application gateway is not waf_v2', function(done) { + const cache = createCache([appGateway[2]]); + agWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Prevention mode is not supported for WAF Standard v2 tier'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); + diff --git a/plugins/azure/applicationGateway/applicationGatewayHasTags.js b/plugins/azure/applicationGateway/applicationGatewayHasTags.js new file mode 100644 index 000000000..24471fc3d --- /dev/null +++ b/plugins/azure/applicationGateway/applicationGatewayHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Application Gateway Has Tags', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that Microsoft Azure Application Gateway has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify application gateways and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['applicationGateway:listAll'], + realtime_triggers: ['microsoftnetwork:applicationgateways:write','microsoftnetwork:applicationgateways:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.applicationGateway, (location, rcb) => { + var appGateways = helpers.addSource(cache, source, + ['applicationGateway', 'listAll', location]); + + if (!appGateways) return rcb(); + + if (appGateways.err || !appGateways.data) { + helpers.addResult(results, 3, 'Unable to query for application gateways: ' + helpers.addError(appGateways), location); + return rcb(); + } + + if (!appGateways.data.length) { + helpers.addResult(results, 0, 'No existing application gateways found', location); + return rcb(); + } + + for (let appGateway of appGateways.data) { + if (!appGateway.id) continue; + + if (appGateway.tags && Object.entries(appGateway.tags).length > 0){ + helpers.addResult(results, 0, 'Application Gateway has tags associated', location, appGateway.id); + } else { + helpers.addResult(results, 2, 'Application Gateway does not have tags associated', location, appGateway.id); + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/applicationGateway/applicationGatewayHasTags.spec.js b/plugins/azure/applicationGateway/applicationGatewayHasTags.spec.js new file mode 100644 index 000000000..a247d10c3 --- /dev/null +++ b/plugins/azure/applicationGateway/applicationGatewayHasTags.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var applicationGatewayHasTags = require('./applicationGatewayHasTags'); + +const appGateway = [ + { + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test-gateway",', + "type": "Microsoft.Network/applicationGateways", + "tags": { "key": "value" }, + "location": "eastus", + }, + { + "name": 'test-gateway', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/applicationGateways/test",', + "type": "Microsoft.Network/applicationGateways", + "tags": {}, + "location": "eastus", + } +]; + +const createCache = (gt) => { + return { + applicationGateway: { + listAll: { + 'eastus': { + data: gt + } + } + } + }; +}; + +const createErrorCache = () => { + return { + applicationGateway: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('applicationGatewayHasTags', function() { + describe('run', function() { + it('should give passing result if no Application Gateway found', function(done) { + const cache = createCache([]); + applicationGatewayHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing application gateways found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Application Gateway does not have tags associated', function(done) { + const cache = createCache([appGateway[1]]); + applicationGatewayHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Application Gateway does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Application Gateway', function(done) { + const cache = createErrorCache(); + applicationGatewayHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for application gateways:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Application Gateway has tags associated', function(done) { + const cache = createCache([appGateway[0]]); + applicationGatewayHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application Gateway has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/accessControlAllowCredential.js b/plugins/azure/appservice/accessControlAllowCredential.js new file mode 100644 index 000000000..6034954f2 --- /dev/null +++ b/plugins/azure/appservice/accessControlAllowCredential.js @@ -0,0 +1,64 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Access Control Allow Credential Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Esures that App Service has Access Control Allow Credentials enabled with CORS', + more_info: 'Enabling Access-Control-Allow-Credentials with CORS (Cross-Origin Resource Sharing) ensures secure access to resources across different domains, allowing the secure exchange of sensitive information such as cookies or authorization headers.', + recommended_action: 'Enable Access Control Allow Credentials for all App Services.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/cors', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.webApps, function(location, rcb) { + + const webApps = helpers.addSource( + cache, source, ['webApps', 'list', location] + ); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query App Service: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing App Services found', location); + return rcb(); + } + + for (let webApp of webApps.data) { + + const configs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!configs || configs.err || !configs.data || !configs.data.length) { + helpers.addResult(results, 3, 'Unable to query for Web App Configs: ' + helpers.addError(configs), location, webApp.id); + continue; + } + + if (configs.data[0] && configs.data[0].cors && configs.data[0].cors.supportedCredentials) { + helpers.addResult(results, 0, + 'App Service has Access Control Allow Credentials enabled with CORS', location, webApp.id); + } else { + helpers.addResult(results, 2, + 'App Service does not have Access Control Allow Credentials enabled with CORS', location, webApp.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/accessControlAllowCredential.spec.js b/plugins/azure/appservice/accessControlAllowCredential.spec.js new file mode 100644 index 000000000..c6c485060 --- /dev/null +++ b/plugins/azure/appservice/accessControlAllowCredential.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var accessControlAllowCredential = require('./accessControlAllowCredential'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'kind': 'app' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'kind': 'app,linux' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'cors': { + 'allowedOrigins' :[ + 'https://portal.azure.com' + ], + 'supportedCredentials':true + } + + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'cors': { + 'allowedOrigins' :[ + 'https://portal.azure.com' + ], + 'supportedCredentials':false + } + } +]; +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('accessControlAllowCredential', function() { + describe('run', function() { + it('should give passing result if no app service found', function(done) { + const cache = createCache([], []); + accessControlAllowCredential.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + accessControlAllowCredential.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app has Access Control Allow Credentials enabled', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + accessControlAllowCredential.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has Access Control Allow Credentials enabled with CORS'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have Access Control Allow Credentials enabled', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + accessControlAllowCredential.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have Access Control Allow Credentials enabled with CORS'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/alwaysOnEnabled.js b/plugins/azure/appservice/alwaysOnEnabled.js new file mode 100644 index 000000000..1b58b87de --- /dev/null +++ b/plugins/azure/appservice/alwaysOnEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Always On Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have Always On feature enabled.', + more_info: 'Always On feature keeps the app loaded even when there\'s no traffic. It\'s required for continuous WebJobs or for WebJobs that are triggered using a CRON expression.', + recommended_action: 'Enable Always On feature for Azure Web Apps', + link: 'https://learn.microsoft.com/en-us/azure/app-service/configure-common', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + async.each(webApps.data, function(webApp, scb) { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'Always On feature can not be configured for the function App', location, webApp.id); + return scb(); + } + + const configs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!configs || configs.err || !configs.data || !configs.data.length) { + helpers.addResult(results, 3, 'Unable to query for Web App Configs: ' + helpers.addError(configs), location); + return scb(); + } + + const alwaysOnEnabled = configs.data.some(config => config.alwaysOn); + if (alwaysOnEnabled) { + helpers.addResult(results, 0, 'Always On feature is enabled for the Web App', location, webApp.id); + } else { + helpers.addResult(results, 2, 'Always On feature is disabled for the Web App', location, webApp.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/alwaysOnEnabled.spec.js b/plugins/azure/appservice/alwaysOnEnabled.spec.js new file mode 100644 index 000000000..3ef80ac02 --- /dev/null +++ b/plugins/azure/appservice/alwaysOnEnabled.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var alwaysOnEnabled = require('./alwaysOnEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const configs = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'alwaysOn': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'alwaysOn': false + } +]; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + listConfigurations: { + 'eastus': config + } + } + }; +}; + +describe('alwaysOnEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if always on can not be configured', function(done) { + const cache = createCache([webApps[1]], []); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Always On feature can not be configured for the function App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result if no web app configs', function(done) { + const cache = createCache([webApps[0]], []); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web app configs', function(done) { + const cache = createCache([webApps[0]]); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if always on is enabled', function(done) { + const cache = createCache([webApps[0]], [configs[0]]); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Always On feature is enabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if always on is disabled', function(done) { + const cache = createCache([webApps[0]], [configs[1]]); + alwaysOnEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Always On feature is disabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/appInsightsEnabled.js b/plugins/azure/appservice/appInsightsEnabled.js new file mode 100644 index 000000000..d12ec2067 --- /dev/null +++ b/plugins/azure/appservice/appInsightsEnabled.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Insights Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Application Insights feature is enabled for Azure web apps.', + more_info: 'Application insights provide advanced application monitoring. Application Insights is an extensible Application Performance Management (APM) service for developers and DevOps professionals available as monitoring feature within Azure cloud.', + recommended_action: 'Enable Application insights for Azure Web Apps', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview', + apis: ['webApps:list', 'webApps:listAppSettings'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + async.each(webApps.data, function(webApp, scb) { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'Application insights feature cannot be configured for the function App', location, webApp.id); + return scb(); + } + + const configs = helpers.addSource(cache, source, + ['webApps', 'listAppSettings', location, webApp.id]); + + if (!configs || configs.err || !configs.data) { + helpers.addResult(results, 3, 'Unable to query for Web App Insights: ' + helpers.addError(configs), location); + return scb(); + } + + if (configs.data.ApplicationInsightsAgent_EXTENSION_VERSION && configs.data.ApplicationInsightsAgent_EXTENSION_VERSION.toLowerCase() !== 'default') { + helpers.addResult(results, 0, 'App Insights feature is enabled for the Web App', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Insights feature is disabled for the Web App', location, webApp.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/appInsightsEnabled.spec.js b/plugins/azure/appservice/appInsightsEnabled.spec.js new file mode 100644 index 000000000..255821e61 --- /dev/null +++ b/plugins/azure/appservice/appInsightsEnabled.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var appInsightsEnabled = require('./appInsightsEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const appSettings = [ + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Web/sites/akhtar-test/config/appsettings', + name: 'appsettings', + type: 'Microsoft.Web/sites/config', + location: 'Central US', + APPINSIGHTS_INSTRUMENTATIONKEY: '2f521d9c-2a65-4ab2-8108-8b8a0902790e', + APPLICATIONINSIGHTS_CONNECTION_STRING: 'InstrumentationKey=2f521d9c-2a65-4ab2-8108-8b8a0902790e;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/', + ApplicationInsightsAgent_EXTENSION_VERSION: '~3', + XDT_MicrosoftApplicationInsights_Mode: 'default', + APPINSIGHTS_PROFILERFEATURE_VERSION: 'disabled', + DiagnosticServices_EXTENSION_VERSION: 'disabled', + APPINSIGHTS_SNAPSHOTFEATURE_VERSION: 'disabled', + SnapshotDebugger_EXTENSION_VERSION: 'disabled', + InstrumentationEngine_EXTENSION_VERSION: 'disabled', + XDT_MicrosoftApplicationInsights_BaseExtensions: 'disabled', + XDT_MicrosoftApplicationInsights_PreemptSdk: 'disabled' + }, + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Web/sites/akhtar-test/config/appsettings', + name: 'appsettings', + type: 'Microsoft.Web/sites/config', + location: 'Central US', + APPINSIGHTS_INSTRUMENTATIONKEY: '2f521d9c-2a65-4ab2-8108-8b8a0902790e', + APPLICATIONINSIGHTS_CONNECTION_STRING: 'InstrumentationKey=2f521d9c-2a65-4ab2-8108-8b8a0902790e;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/', + ApplicationInsightsAgent_EXTENSION_VERSION: 'default', + XDT_MicrosoftApplicationInsights_Mode: 'default', + APPINSIGHTS_PROFILERFEATURE_VERSION: 'disabled', + DiagnosticServices_EXTENSION_VERSION: 'disabled', + APPINSIGHTS_SNAPSHOTFEATURE_VERSION: 'disabled', + SnapshotDebugger_EXTENSION_VERSION: 'disabled', + InstrumentationEngine_EXTENSION_VERSION: 'disabled', + XDT_MicrosoftApplicationInsights_BaseExtensions: 'disabled', + XDT_MicrosoftApplicationInsights_PreemptSdk: 'disabled' + } +]; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + listAppSettings: { + 'eastus': config + } + } + }; +}; + +describe('appInsightsEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if application insights can not be configured', function(done) { + const cache = createCache([webApps[1]], []); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Application insights feature cannot be configured for the function App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web app settings', function(done) { + const cache = createCache([webApps[0]]); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Insights:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if application insights is enabled', function(done) { + const cache = createCache([webApps[0]], appSettings[0]); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Insights feature is enabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if always on is disabled', function(done) { + const cache = createCache([webApps[0]], appSettings[1]); + appInsightsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Insights feature is disabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/appServiceAccessRestriction.js b/plugins/azure/appservice/appServiceAccessRestriction.js new file mode 100644 index 000000000..e11e94802 --- /dev/null +++ b/plugins/azure/appservice/appServiceAccessRestriction.js @@ -0,0 +1,142 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service Access Restriction', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Azure App Services have access restriction configured to control network access to your app.', + more_info: 'By setting up access restrictions, you can define a priority-ordered allow/deny list that controls network access to your app. The list can include IP addresses or Azure Virtual Network subnets. When there are one or more entries, an implicit deny all exists at the end of the list. The most secure configuration is to disable public network access entirely. If public access is enabled, this plugin checks for explicit access restrictions with an "Any" IP address and "Deny" action rule.', + recommended_action: 'Add access restriction rules under network settings for the app services', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-ip-restrictions#set-up-azure-functions-access-restrictions', + apis: ['webApps:list', 'webApps:listConfigurations'], + remediation_min_version: '202201131602', + remediation_description: 'Access restriction rule will be added to deny access from any source for affected app services', + apis_remediate: ['webApps:list', 'webApps:listConfigurations'], + actions: {remediate:['webApps:updateconfiguration'], rollback:['webApps:updateconfiguration']}, + permissions: {remediate: ['webApps:updateconfiguration'], rollback: ['webApps:updateconfiguration']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete','microsoftweb:sites:config:write','microsoftweb:sites:config:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for App Services : ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing App Services found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + const webConfigs = helpers.addSource( + cache, source, ['webApps', 'listConfigurations', location, webApp.id] + ); + + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), + location, webApp.id); + } else { + const config = webConfigs.data[0]; + + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'App Service has access restriction enabled', + location, webApp.id); + } else { + let denyAllIp; + if (config.ipSecurityRestrictions && config.ipSecurityRestrictions.length) { + denyAllIp = config.ipSecurityRestrictions.find(ipSecurityRestriction => + ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && + ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' + ); + } + + if (denyAllIp) { + helpers.addResult(results, 0, + 'App Service has access restriction enabled', + location, webApp.id); + } else { + helpers.addResult(results, 2, + 'App Service does not have access restriction enabled', + location, webApp.id); + } + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'appServiceAccessRestriction'; + var baseUrl = 'https://management.azure.com/{resource}/config/web?api-version=2021-02-01'; + var method = 'PATCH'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'ipSecurityRestrictions': [ + { + 'action': 'Deny', + 'name': 'Deny All Access', + 'ipAddress': '0.0.0.0/0', + 'description': 'Khulnasoft CSPM Auto Remediation', + 'priority': 2147483647 + } + ] + } + + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'AccessRestriction': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) { + console.log(err); + return callback(err); + } + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } + } +}; diff --git a/plugins/azure/appservice/appServiceAccessRestriction.spec.js b/plugins/azure/appservice/appServiceAccessRestriction.spec.js new file mode 100644 index 000000000..d6c7520d7 --- /dev/null +++ b/plugins/azure/appservice/appServiceAccessRestriction.spec.js @@ -0,0 +1,188 @@ +var expect = require('chai').expect; +var appServiceAccessRestriction = require('./appServiceAccessRestriction'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', + 'ipSecurityRestrictions': [ + { + 'ipAddress': 'Any', + 'action': 'Allow', + 'priority': 1, + 'name': 'Allow all', + 'description': 'Allow all access' + } + ], + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', + 'ipSecurityRestrictions': [ + { + 'ipAddress': '208.130.0.0/16', + 'action': 'Allow', + 'tag': 'Default', + 'priority': 1, + 'name': 'xyz' + }, + { + 'ipAddress': 'Any', + 'action': 'Deny', + 'priority': 2147483647, + 'name': 'Deny all', + 'description': 'Deny all access' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', + 'ipSecurityRestrictions': [ + { + 'ipAddress': '192.168.1.0/24', + 'action': 'Allow', + 'priority': 100, + 'name': 'Office Network' + } + ], + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('appServiceAccessRestriction', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if app has no configs', function(done) { + const cache = createErrorCache('configs'); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service configuration'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is disabled (most secure)', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app Service has explicit Any/Deny rule', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service has allow all rule', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service has specific IP restrictions but no Any/Deny rule', function(done) { + const cache = createCache([webApps[0]], [configurations[3]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/appServiceDiagnosticLogs.js b/plugins/azure/appservice/appServiceDiagnosticLogs.js new file mode 100644 index 000000000..683685069 --- /dev/null +++ b/plugins/azure/appservice/appServiceDiagnosticLogs.js @@ -0,0 +1,66 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service Diagnostic Logging Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Azure App Service.', + more_info: 'Enabling diagnostic logging provides a quick and easy way to view application logs, allowing users to diagnose and resolve issues including errors, performance bottlenecks, and security concerns.', + recommended_action: 'Enable diagnostic logging for all App Services.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/troubleshoot-diagnostic-logs', + apis: ['webApps:list', 'diagnosticSettings:listByAppServices'], + realtime_triggers: ['microsoftweb:sites:write', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete', 'microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, (location, rcb) => { + + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + if (!webApp.id) return; + + if (webApp && webApp.kind && webApp.kind.startsWith('app')) return; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAppServices', location, webApp.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for App Service diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, webApp.id); + return; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'App Service has diagnostic logs enabled', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have diagnostic logs enabled', location, webApp.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/appServiceDiagnosticLogs.spec.js b/plugins/azure/appservice/appServiceDiagnosticLogs.spec.js new file mode 100644 index 000000000..f3a4a4a01 --- /dev/null +++ b/plugins/azure/appservice/appServiceDiagnosticLogs.spec.js @@ -0,0 +1,178 @@ +var expect = require('chai').expect; +var appServiceDiagnosticLogs = require('./appServiceDiagnosticLogs'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "FunctionAppLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, +] + +const createCache = (webApps, diagnostics) => { + let ds = {}; + if (webApps.length) { + ds[webApps[0].id] = { + data: diagnostics + }; + } + + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': ds + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noWebApp'){ + return { + webApps: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': {} + } + } + }; + } else { + const appId = (webApps && webApps.length) ? webApps[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + webApps: { + list: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('appServiceDiagnosticLogs', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + appServiceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createErrorCache('webApp'); + appServiceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createErrorCache('diagnostic'); + appServiceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Service diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([webApps[0]], [diagnosticSettings[0]]); + appServiceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([webApps[0]], [[]]); + appServiceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/appServicePublicAccess.js b/plugins/azure/appservice/appServicePublicAccess.js new file mode 100644 index 000000000..246ca91c2 --- /dev/null +++ b/plugins/azure/appservice/appServicePublicAccess.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service Public Network Access Disabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Azure App Services have public network access disabled to prevent exposure of the application to the internet.', + more_info: 'By default, App Services may allow public network traffic unless explicitly disabled. Public network access can be disabled using the publicNetworkAccess property or by configuring a private endpoint. Disabling public network access ensures that your applications are only reachable through secure private endpoints and not exposed to the public internet.', + recommended_action: 'Set the Public network access setting to Disabled in the App Service Networking configuration, or configure a private endpoint to restrict access. You can do this via the Azure Portal, CLI, or ARM template.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-access-restrictions#ip-based-access-restriction-rules', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write', 'microsoftweb:sites:delete', 'microsoftweb:sites:config:write', 'microsoftweb:sites:config:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + var webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for App Services: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing App Services found', location); + return rcb(); + } + + webApps.data.forEach(function(webApp) { + if (!webApp.id) return; + + var webConfigs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), + location, webApp.id); + return; + } + + var config = webConfigs.data[0]; + + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'App Service has public network access disabled', + location, webApp.id); + } else { + helpers.addResult(results, 2, + 'App Service does not have public network access disabled', + location, webApp.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/appservice/appServicePublicAccess.spec.js b/plugins/azure/appservice/appServicePublicAccess.spec.js new file mode 100644 index 000000000..cd732733d --- /dev/null +++ b/plugins/azure/appservice/appServicePublicAccess.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var appServicePublicAccess = require('./appServicePublicAccess'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1', + 'name': 'test-app-1', + 'type': 'Microsoft.Web/sites', + 'kind': 'app', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-2', + 'name': 'test-app-2', + 'type': 'Microsoft.Web/sites', + 'kind': 'app', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-3', + 'name': 'test-app-3', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'eastus' + } +]; + +const listConfigurations = [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1/config/web', + 'name': 'web', + 'publicNetworkAccess': 'Disabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-2/config/web', + 'name': 'web', + 'publicNetworkAccess': 'Enabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-3/config/web', + 'name': 'web' + } +]; + +const createCache = (webApps, configurations, webAppsErr, configurationsErr) => { + const appId = (webApps && webApps.length) ? webApps[0].id : null; + return { + webApps: { + list: { + 'eastus': { + err: webAppsErr, + data: webApps + } + }, + listConfigurations: { + 'eastus': { + [appId]: { + err: configurationsErr, + data: configurations + } + } + } + } + }; +}; + +describe('appServicePublicAccess', function () { + describe('run', function () { + it('should give passing result if no web apps found', function (done) { + const cache = createCache([], null); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function (done) { + const cache = createCache(null, null, { message: 'Unable to query Web Apps' }); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query web app configuration', function (done) { + const cache = createCache([webApps[0]], null, null, { message: 'Unable to query configuration' }); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service configuration'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Service has public network access disabled', function (done) { + const cache = createCache([webApps[0]], [listConfigurations[0]]); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service has public network access enabled', function (done) { + const cache = createCache([webApps[1]], [listConfigurations[1]]); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service publicNetworkAccess property is not set', function (done) { + const cache = createCache([webApps[2]], [listConfigurations[2]]); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result for App Service with case-insensitive disabled value', function (done) { + const config = [{ + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1/config/web', + 'name': 'web', + 'publicNetworkAccess': 'disabled' + }]; + const cache = createCache([webApps[0]], config); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/authEnabled.js b/plugins/azure/appservice/authEnabled.js index 646ece2da..8695a4146 100644 --- a/plugins/azure/appservice/authEnabled.js +++ b/plugins/azure/appservice/authEnabled.js @@ -4,22 +4,42 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Authentication Enabled', category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures Authentication is enabled for App Services, redirecting unauthenticated users to the login page.', - more_info: 'Enabling authentication will redirect all unauthenticated requests to the login page. It also handles authentication of users with specific providers (Azure Active Directory, Facebook, Google, Microsoft Account, and Twitter).', + more_info: 'Enabling authentication will redirect all unauthenticated requests to the login page. It also handles authentication of users with specific providers (Azure Entra ID, Facebook, Google, Microsoft Account, and Twitter).', recommended_action: 'Enable App Service Authentication for all App Services.', - link: 'https://docs.microsoft.com/en-us/azure/app-service/overview-authentication-authorization', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization', apis: ['webApps:list', 'webApps:getAuthSettings'], + remediation_min_version: '202104011300', + remediation_description: 'The App Service Authentication option will be enabled for the web app', + apis_remediate: ['webApps:list'], + actions: {remediate:['webApps:updateAuthSettings'], rollback:['webApps:updateAuthSettings']}, + permissions: {remediate: ['webApps:updateAuthSettings'], rollback: ['webApps:updateAuthSettings']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete','microsoftweb:sites:config:write','microsoftweb:sites:config:delete'], compliance: { hipaa: 'HIPAA requires all application access to be restricted to known users ' + 'for auditing and security controls.', pci: 'Access to system components must be restricted to known users.' }, + settings: { + whitelist_functions_for_auth_enabled: { + name: 'Whitelist Functions For Authentication Enabled', + description: 'List of comma separated functions which should be whitelisted to check', + regex: '^.*$', + default: 'khulnasoft-agentless-scanner-continuous-onboarding', + } + }, run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + let config = { + whitelist_functions_for_auth_enabled: settings.whitelist_functions_for_auth_enabled || this.settings.whitelist_functions_for_auth_enabled.default + }; + async.each(locations.webApps, function(location, rcb) { const webApps = helpers.addSource( @@ -41,19 +61,26 @@ module.exports = { } webApps.data.forEach(function(webApp) { - const authSettings = helpers.addSource( - cache, source, ['webApps', 'getAuthSettings', location, webApp.id] - ); - - if (!authSettings || authSettings.err || !authSettings.data) { - helpers.addResult(results, 3, - 'Unable to query App Service: ' + helpers.addError(authSettings), - location, webApp.id); + if (webApp.kind && webApp.kind.includes('workflowapp')) return; + + if (webApp.name.includes(config.whitelist_functions_for_auth_enabled)) { + helpers.addResult(results, 0, 'The App Service is whitelisted', location, webApp.id); } else { - if (authSettings.data.enabled) { - helpers.addResult(results, 0, 'App Service has App Service Authentication enabled', location, webApp.id); + + const authSettings = helpers.addSource( + cache, source, ['webApps', 'getAuthSettings', location, webApp.id] + ); + + if (!authSettings || authSettings.err || !authSettings.data) { + helpers.addResult(results, 3, + 'Unable to query App Service: ' + helpers.addError(authSettings), + location, webApp.id); } else { - helpers.addResult(results, 2, 'App Service does not have App Service Authentication enabled', location, webApp.id); + if (authSettings.data.enabled) { + helpers.addResult(results, 0, 'App Service has App Service Authentication enabled', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have App Service Authentication enabled', location, webApp.id); + } } } }); @@ -63,5 +90,48 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'authEnabled'; + var baseUrl = 'https://management.azure.com/{resource}/config/authsettings?api-version=2019-08-01'; + var method = 'PUT'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'enabled': true + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'AppAuthentication': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/appservice/authEnabled.spec.js b/plugins/azure/appservice/authEnabled.spec.js index 8885835c6..bfd1c0cdc 100644 --- a/plugins/azure/appservice/authEnabled.spec.js +++ b/plugins/azure/appservice/authEnabled.spec.js @@ -1,4 +1,3 @@ -var assert = require('assert'); var expect = require('chai').expect; var auth = require('./authEnabled'); @@ -35,7 +34,7 @@ describe('authEnabled', function() { ); auth.run(cache, {}, callback); - }) + }); it('should give failing result if disable App Service', function(done) { const callback = (err, results) => { @@ -70,7 +69,42 @@ describe('authEnabled', function() { ); auth.run(cache, {}, callback); - }) + }); + + it('should give passing result if App Service is whitelisted', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('App Service is whitelisted') + expect(results[0].region).to.equal('eastus') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/abcdef-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/devresourcegroup/providers/Microsoft.Web/sites/khulnasoft-agentless-scanner-continuous-onboarding-i2hxc3is", + "name": "khulnasoft-agentless-scanner-continuous-onboarding-i2hxc3is", + "type": "Microsoft.Web/sites", + "kind": "app,linux,container", + "location": "East US", + "state": "Running" + } + ], + { + "/subscriptions/abcdef-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/devresourcegroup/providers/Microsoft.Web/sites/test-webapp": { + "data": { + "name": "authsettings", + "type": "Microsoft.Web/sites/config", + "enabled": false + } + } + } + ); + + auth.run(cache, {whitelist_functions_for_auth_enabled: 'khulnasoft-agentless-scanner-continuous-onboarding'}, callback); + }); it('should give passing result if enabled App Service', function(done) { const callback = (err, results) => { @@ -105,6 +139,38 @@ describe('authEnabled', function() { ); auth.run(cache, {}, callback); - }) + }); + + it('should ignore if enabled App Service is a logic app', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(0) + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/abcdef-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/devresourcegroup/providers/Microsoft.Web/sites/test-webapp", + "name": "gio-test-webapp", + "type": "Microsoft.Web/sites", + "kind": "app,workflowapp", + "location": "East US", + "state": "Running" + } + ], + { + "/subscriptions/abcdef-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/devresourcegroup/providers/Microsoft.Web/sites/test-webapp": { + "data": { + "name": "authsettings", + "type": "Microsoft.Web/sites/config", + "enabled": true + } + } + } + ); + + auth.run(cache, {}, callback); + }); }) -}) \ No newline at end of file +}) diff --git a/plugins/azure/appservice/automatedBackupsEnabled.js b/plugins/azure/appservice/automatedBackupsEnabled.js new file mode 100644 index 000000000..4448bb7b1 --- /dev/null +++ b/plugins/azure/appservice/automatedBackupsEnabled.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Custom Backup Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have custom automated backups enabled.', + more_info: 'Protect your Azure App Services web applications against accidental deletion and/or corruption, you can configure application backups to create restorable copies of your app content.', + recommended_action: 'Configure custom automated backup for Azure Web Apps.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/manage-backup', + apis: ['webApps:list', 'webApps:getBackupConfiguration'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete','microsoftweb:sites:config:write','microsoftweb:sites:config:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'WebApps backup can not be configured for the function App', location, webApp.id); + return; + } + + const configs = helpers.addSource(cache, source, + ['webApps', 'getBackupConfiguration', location, webApp.id]); + + if (configs && configs.err && configs.err.includes('NotFound')) { + helpers.addResult(results, 2, 'Custom Backups are not configured for WebApp', location, webApp.id); + } else if (!configs || configs.err || !configs.data) { + helpers.addResult(results, 3, 'Unable to query for Web App backup configs: ' + helpers.addError(configs), location, webApp.id); + } else helpers.addResult(results, 0, 'Custom Backups are configured for WebApp', location, webApp.id); + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/appservice/automatedBackupsEnabled.spec.js b/plugins/azure/appservice/automatedBackupsEnabled.spec.js new file mode 100644 index 000000000..cd3bda1d6 --- /dev/null +++ b/plugins/azure/appservice/automatedBackupsEnabled.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +var automatedBackupsEnabled = require('./automatedBackupsEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const backupConfigs = { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Web/sites/akhtar-test', + name: 'akhtar-test', + type: 'Default', + location: 'Central US', + backupName: 'akhtar-test', + enabled: true, + storageAccountUrl: 'https://akhtarrgdiag.blob.core.windows.net/appbackup?sp=rwdl&st=2022-03-16T07:51:37Z&se=2295-12-29T08:51:37Z&sv=2020-08-04&sr=c&sig=FeC0hGUrqJb6b%2Bh5qbIif84725sMjeqyNUzWa4tL3L4%3D', + backupSchedule: { + frequencyInterval: 7, + frequencyUnit: 'Day', + keepAtLeastOneBackup: true, + retentionPeriodInDays: 7, + startTime: '2022-03-16T07:51:38.699', + lastExecutionTime: '2022-03-16T07:53:38.4131659' + }, + databases: [], + mySqlDumpParams: null +}; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + getBackupConfiguration: { + 'eastus': config + } + } + }; +}; + +const createErrorCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'err': 'NotFound' + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + getBackupConfiguration: { + 'eastus': config + } + } + }; +}; + +describe('automatedBackupsEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + automatedBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + automatedBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if web apps backups can not be configured', function(done) { + const cache = createCache([webApps[1]], []); + automatedBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WebApps backup can not be configured for the function App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no web app backup config found', function(done) { + const cache = createErrorCache([webApps[0]],{err:'Empty'} ); + automatedBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Custom Backups are not configured for WebApp'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if web app backup is configured', function(done) { + const cache = createCache([webApps[0]], backupConfigs); + automatedBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Custom Backups are configured for WebApp'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/backupRetentionPeriod.js b/plugins/azure/appservice/backupRetentionPeriod.js new file mode 100644 index 000000000..ea331a2cc --- /dev/null +++ b/plugins/azure/appservice/backupRetentionPeriod.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Backup Retention Period', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have recommended backup retention period.', + more_info: 'Retaining application backups for a longer period of time will allow you to handle your app data restoration process more efficiently.', + recommended_action: 'Configure backup retention period for Azure Web Apps', + link: 'https://learn.microsoft.com/en-us/azure/app-service/manage-backup', + apis: ['webApps:list', 'webApps:getBackupConfiguration'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete','microsoftweb:sites:config:write','microsoftweb:sites:config:delete'], + settings: { + webapps_backup_retention_period: { + name: 'Backup retention period in days', + description: 'Backup retention period for web apps in days.', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 7 + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var webapps_backup_retention_period = parseInt(settings.webapps_backup_retention_period || this.settings.webapps_backup_retention_period.default); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'WebApps backup can not be configured for the function App', location, webApp.id); + return; + } + const configs = helpers.addSource(cache, source, + ['webApps', 'getBackupConfiguration', location, webApp.id]); + + if (configs && configs.err && configs.err.includes('NotFound')) { + helpers.addResult(results, 0, 'Backups are not configured for WebApp', location, webApp.id); + } else if (!configs || configs.err || !configs.data) { + helpers.addResult(results, 3, 'Unable to query for Web App backup configs: ' + helpers.addError(configs), location, webApp.id); + } else { + const { backupSchedule } = configs.data; + if (backupSchedule && backupSchedule.retentionPeriodInDays) { + if (backupSchedule.retentionPeriodInDays >= webapps_backup_retention_period) { + helpers.addResult(results, 0, + `WebApp has a backup retention period of ${backupSchedule.retentionPeriodInDays} of ${webapps_backup_retention_period} days limit`, + location, webApp.id); + } else { + helpers.addResult(results, 2, + `WebApp has a backup retention period of ${backupSchedule.retentionPeriodInDays} of ${webapps_backup_retention_period} days limit`, + location, webApp.id); + } + } + } + }); + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/backupRetentionPeriod.spec.js b/plugins/azure/appservice/backupRetentionPeriod.spec.js new file mode 100644 index 000000000..648774f4e --- /dev/null +++ b/plugins/azure/appservice/backupRetentionPeriod.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var backupRetentionPeriod = require('./backupRetentionPeriod'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const backupConfigs = { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Web/sites/akhtar-test', + name: 'akhtar-test', + type: 'Default', + location: 'Central US', + backupName: 'akhtar-test', + enabled: true, + storageAccountUrl: 'https://akhtarrgdiag.blob.core.windows.net/appbackup?sp=rwdl&st=2022-03-16T07:51:37Z&se=2295-12-29T08:51:37Z&sv=2020-08-04&sr=c&sig=FeC0hGUrqJb6b%2Bh5qbIif84725sMjeqyNUzWa4tL3L4%3D', + backupSchedule: { + frequencyInterval: 7, + frequencyUnit: 'Day', + keepAtLeastOneBackup: true, + retentionPeriodInDays: 7, + startTime: '2022-03-16T07:51:38.699', + lastExecutionTime: '2022-03-16T07:53:38.4131659' + }, + databases: [], + mySqlDumpParams: null +}; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + getBackupConfiguration: { + 'eastus': config + } + } + }; +}; + +const createErrorCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'err': 'NotFound' + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + getBackupConfiguration: { + 'eastus': config + } + } + }; +}; + +describe('backupRetentionPeriod', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + backupRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + backupRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if web apps backups can not be configured', function(done) { + const cache = createCache([webApps[1]], []); + backupRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WebApps backup can not be configured for the function App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give failing result if no web app backup config found', function(done) { + const cache = createErrorCache([webApps[0]],{err:'Empty'} ); + backupRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Backups are not configured for WebApp'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web app backup configs', function(done) { + const cache = createCache([webApps[0]]); + backupRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App backup configs'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if web app backup retention period is within the recommended threshold', function(done) { + const cache = createCache([webApps[0]], backupConfigs); + backupRetentionPeriod.run(cache, { webapps_backup_retention_period: 7 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WebApp has a backup retention period of 7 of 7 days'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if web app backup retention period is below the recommended threshold', function(done) { + const cache = createCache([webApps[0]], backupConfigs); + backupRetentionPeriod.run(cache, { webapps_backup_retention_period: 10 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WebApp has a backup retention period of 7 of 10 days limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/certificateExpiry.js b/plugins/azure/appservice/certificateExpiry.js new file mode 100644 index 000000000..4aacc9c25 --- /dev/null +++ b/plugins/azure/appservice/certificateExpiry.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service Certificates Expiry', + category: 'App Service', + domain: 'Application Integration', + severity: 'High', + description: 'Detect upcoming expiration of App Service Certificates.', + more_info: 'Azure App Service Certificates help in securing DNS Domain for the web app of function app. Certificates auto-renewal should be configured to avoid any un expected results.', + recommended_action: 'Turn On Certificates auto -renewal for Azure App Service Certificates', + link: 'https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-certificate', + apis: ['appServiceCertificates:list'], + settings: { + days_to_expire: { + name: 'Days Before Expiration of Certificate', + description: 'Threshold to reach certificate expiry date (days)', + regex: '^([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-9][0-9]|3[0-5][0-9]|36[0-5])$', + default: '60', + } + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + daysToExpire: parseInt(settings.days_to_expire || this.settings.days_to_expire.default) + }; + + async.each(locations.appServiceCertificates, function(location, rcb) { + const appServiceCertificates = helpers.addSource(cache, source, + ['appServiceCertificates', 'list', location]); + + if (!appServiceCertificates) return rcb(); + + if (appServiceCertificates.err || !appServiceCertificates.data) { + helpers.addResult(results, 3, 'Unable to query for App Service Certificates : ' + helpers.addError(appServiceCertificates), location); + return rcb(); + } + + if (!appServiceCertificates.data.length) { + helpers.addResult(results, 0, 'No existing App Service Certificates found', location); + return rcb(); + } + + async.each(appServiceCertificates.data, function(certificate, scb) { + if (certificate.expirationDate) { + const daysToExpire = Math.round((new Date(certificate.expirationDate).getTime() - new Date().getTime()) / (24 * 60 * 60 * 1000)); + + if (daysToExpire > config.daysToExpire) { + helpers.addResult(results, 0, `App Service Certificate expires in ${Math.abs(daysToExpire)} days`, location, certificate.id); + } else if (daysToExpire >= 0) { + helpers.addResult(results, 2, `App Service Certificate expires in ${Math.abs(daysToExpire)} days`, location, certificate.id); + } else { + helpers.addResult(results, 2, `App Service Certificate expired ${Math.abs(daysToExpire)} days ago`, location, certificate.id); + } + } else { + helpers.addResult(results, 3, 'App Service Certificate does not have an expiration date configured', location, certificate.id); + } + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/certificateExpiry.spec.js b/plugins/azure/appservice/certificateExpiry.spec.js new file mode 100644 index 000000000..2de0394d0 --- /dev/null +++ b/plugins/azure/appservice/certificateExpiry.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var certificateExpiry = require('./certificateExpiry'); + +let sufficientTime = new Date(new Date().setDate(new Date().getDate() + 70)).toISOString(); +let notSufficientTime = new Date(new Date().setDate(new Date().getDate() + 40)).toISOString(); +let expired = new Date(new Date().setDate(new Date().getDate() - 7)).toISOString(); +console.log(sufficientTime); +console.log(notSufficientTime); +console.log(expired); +const certificates = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/certificates/test-cert', + 'name': 'test-cert', + 'type': 'Microsoft.Web/certificates', + 'location': 'East US', + 'expirationDate': sufficientTime + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/certificates/test-cert', + 'name': 'test-cert', + 'type': 'Microsoft.Web/certificates', + 'location': 'East US', + 'expirationDate': notSufficientTime + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/certificates/test-cert', + 'name': 'test-cert', + 'type': 'Microsoft.Web/certificates', + 'location': 'East US', + 'expirationDate': expired + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/certificates/test-cert', + 'name': 'test-cert', + 'type': 'Microsoft.Web/certificates', + 'location': 'East US' + } +]; + +const createCache = (certificates) => { + let certs = {}; + + if (certificates) { + certs['data'] = certificates; + } + + return { + appServiceCertificates: { + list: { + 'eastus': certs + } + } + }; +}; + +describe('certificateExpiry', function() { + describe('run', function() { + it('should give passing result if no certificates', function(done) { + const cache = createCache([]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Service Certificates found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for certificates', function(done) { + const cache = createCache(); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Service Certificates'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to expiration date is not configured', function(done) { + const cache = createCache([certificates[3]]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('App Service Certificate does not have an expiration date configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if certificate has sufficient time in expiry', function(done) { + const cache = createCache([certificates[0]]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service Certificate expires in 70 days'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if certificate does not have sufficient time in expiry', function(done) { + const cache = createCache([certificates[1]]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service Certificate expires in 40 days'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if certificate is expired already', function(done) { + const cache = createCache([certificates[2]]); + certificateExpiry.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service Certificate expired 7 days ago'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/clientCertEnabled.js b/plugins/azure/appservice/clientCertEnabled.js index 17d7448f8..0ac4a2f1c 100644 --- a/plugins/azure/appservice/clientCertEnabled.js +++ b/plugins/azure/appservice/clientCertEnabled.js @@ -4,17 +4,19 @@ const helpers = require('../../../helpers/azure/'); module.exports = { title: 'Client Certificates Enabled', category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures Client Certificates are enabled for App Services, only allowing clients with valid certificates to reach the app', more_info: 'Enabling Client Certificates will block all clients that do not have a valid certificate from accessing the app.', recommended_action: 'Enable incoming client certificate SSL setting for all App Services.', - link: 'https://docs.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth#enable-client-certificates', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth#enable-client-certificates', apis: ['webApps:list'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); - async.each(locations.webApps, function(location, rcb) { const webApps = helpers.addSource( @@ -35,10 +37,14 @@ module.exports = { } webApps.data.forEach(function(webApp) { - if (webApp.clientCertEnabled) { + if (webApp.siteConfig && webApp.siteConfig.http20Enabled) { helpers.addResult(results, 0, 'The App Service has Client Certificates enabled', location, webApp.id); } else { - helpers.addResult(results, 2, 'The App Service does not have Client Certificates enabled', location, webApp.id); + if (webApp.clientCertEnabled) { + helpers.addResult(results, 0, 'The App Service has Client Certificates enabled', location, webApp.id); + } else { + helpers.addResult(results, 2, 'The App Service does not have Client Certificates enabled', location, webApp.id); + } } }); diff --git a/plugins/azure/appservice/clientCertEnabled.spec.js b/plugins/azure/appservice/clientCertEnabled.spec.js new file mode 100644 index 000000000..7f9651a65 --- /dev/null +++ b/plugins/azure/appservice/clientCertEnabled.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var clientCertEnabled = require('./clientCertEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'clientCertEnabled': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'clientCertEnabled': false, + 'siteConfig': { + 'http20Enabled': false + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'clientCertEnabled': false, + 'siteConfig': { + 'http20Enabled': true + } + } +]; + +const createCache = (webApps) => { + return { + webApps: { + list: { + 'eastus':{ + data: webApps + } + } + } + }; +}; + +const createErrorCache = () => { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('clientCertEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + clientCertEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query app service', function(done) { + const cache = createErrorCache(); + clientCertEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has client certificates enabled', function(done) { + const cache = createCache([webApps[0]]); + clientCertEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The App Service has Client Certificates enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have client certificates enabled', function(done) { + const cache = createCache([webApps[1]]); + clientCertEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The App Service does not have Client Certificates enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service have http20 enabled', function(done) { + const cache = createCache([webApps[2]]); + clientCertEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The App Service has Client Certificates enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/disableFTPDeployments.js b/plugins/azure/appservice/disableFTPDeployments.js new file mode 100644 index 000000000..5b1cf134a --- /dev/null +++ b/plugins/azure/appservice/disableFTPDeployments.js @@ -0,0 +1,71 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Disable FTP Deployments', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that FTP deployments are disabled for App Services.', + more_info: 'Disabling FTP deployments ensures that the encrypted traffic between the web application server and the FTP client cannot be decrypted by malicious actors.', + recommended_action: 'Disable FTP deployments in the general settings for all App Services.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/deploy-ftp?tabs=portal#enforce-ftps', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource( + cache, source, ['webApps', 'list', location] + ); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for App Services: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult( + results, 0, 'No existing App Services found', location); + return rcb(); + } + + webApps.data.forEach(function(webApp) { + const webConfigs = helpers.addSource( + cache, source, ['webApps', 'listConfigurations', location, webApp.id] + ); + + let ftpFound = false; + + if (!webConfigs || webConfigs.err || !webConfigs.data) { + helpers.addResult(results, 3, + 'Unable to query App Service: ' + helpers.addError(webConfigs), + location, webApp.id); + } else { + webConfigs.data.find((config) => { + if (config.ftpsState && config.ftpsState.toLowerCase() === 'allallowed') { + ftpFound = true; + } + }); + + if (ftpFound) { + helpers.addResult(results, 2, 'FTP deployments are not disabled for this web app', location, webApp.id); + } else { + helpers.addResult(results, 0, 'FTP deployments are disabled for this web app', location, webApp.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/disableFTPDeployments.spec.js b/plugins/azure/appservice/disableFTPDeployments.spec.js new file mode 100644 index 000000000..8f4c18974 --- /dev/null +++ b/plugins/azure/appservice/disableFTPDeployments.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var disableFTPDeployments = require('./disableFTPDeployments'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'ftpsState': 'FtpsOnly' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'ftpsState': 'AllAllowed' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('disableFTPDeployments', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + disableFTPDeployments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + disableFTPDeployments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query app service', function(done) { + const cache = createErrorCache('configs'); + disableFTPDeployments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has FTP deployments disabled', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + disableFTPDeployments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('FTP deployments are disabled for this web app'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have FTP deployments disabled', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + disableFTPDeployments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('FTP deployments are not disabled for this web app'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/ftpsOnlyAccessEnabled.js b/plugins/azure/appservice/ftpsOnlyAccessEnabled.js new file mode 100644 index 000000000..99aaf4b0b --- /dev/null +++ b/plugins/azure/appservice/ftpsOnlyAccessEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'FTPS Only Access Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have FTPS only access enabled.', + more_info: 'FTPS-only access for your Azure App Services applications, can guarantee that the encrypted traffic between the web application servers and the FTP clients cannot be decrypted by malicious actors.', + recommended_action: 'Enable FTPS-only access for Azure Web Apps', + link: 'https://learn.microsoft.com/en-us/azure/app-service/deploy-ftp?tabs=portal#enforce-ftps', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + async.each(webApps.data, function(webApp, scb) { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'Always On feature can not be configured for the function App', location, webApp.id); + return scb(); + } + + const configs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!configs || configs.err || !configs.data || !configs.data.length) { + helpers.addResult(results, 3, 'Unable to query for Web App Configs: ' + helpers.addError(configs), location); + return scb(); + } + + const ftpsOnlyAcces = configs.data.every(config => config.ftpsState && config.ftpsState.toLowerCase() == 'ftpsonly'); + + if (ftpsOnlyAcces) { + helpers.addResult(results, 0, 'FTPS-only access is enabled for the Web App', location, webApp.id); + } else { + helpers.addResult(results, 2, 'FTPS-only access is disabled for the Web App', location, webApp.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/ftpsOnlyAccessEnabled.spec.js b/plugins/azure/appservice/ftpsOnlyAccessEnabled.spec.js new file mode 100644 index 000000000..f006c354b --- /dev/null +++ b/plugins/azure/appservice/ftpsOnlyAccessEnabled.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var ftpsOnlyAccessEnabled = require('./ftpsOnlyAccessEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const configs = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'ftpsState': 'FtpsOnly' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'ftpsState': 'AllAlowed' + } +]; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + listConfigurations: { + 'eastus': config + } + } + }; +}; + +describe('ftpsOnlyAccessEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if always on can not be configured', function(done) { + const cache = createCache([webApps[1]], []); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Always On feature can not be configured for the function App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result if no web app configs', function(done) { + const cache = createCache([webApps[0]], []); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web app configs', function(done) { + const cache = createCache([webApps[0]]); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if FTPS-only access is enabled', function(done) { + const cache = createCache([webApps[0]], [configs[0]]); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('FTPS-only access is enabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if FTPS-only access is disabled', function(done) { + const cache = createCache([webApps[0]], [configs[1]]); + ftpsOnlyAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('FTPS-only access is disabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/functionAppNetworkExposure.js b/plugins/azure/appservice/functionAppNetworkExposure.js new file mode 100644 index 000000000..77fec5e94 --- /dev/null +++ b/plugins/azure/appservice/functionAppNetworkExposure.js @@ -0,0 +1,131 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Internet Exposure', + category: 'App Service', + domain: 'Application Integration', + severity: 'Info', + description: 'Ensures that Azure function apps are not exposed to the internet.', + more_info: 'Azure Functions exposed to the internet are at higher risk of unauthorized access and exploitation. Securing access through proper configuration of authorization levels, IP restrictions, private endpoints, or service-specific security settings is critical to minimize vulnerabilities.', + recommended_action: 'Restrict Azure Function exposure by implementing secure access controls, such as authorization levels, IP restrictions, private endpoints, or integrating with VNETs.', + link: 'https://learn.microsoft.com/en-us/azure/azure-functions/functions-networking-options', + apis: ['webApps:list', 'applicationGateways:list', 'loadBalancers:list', 'classicFrontDoors:list', 'afdWafPolicies:listAll'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete', 'microsoftnetwork:applicationgateways:write', 'microsoftnetwork:applicationgateways:delete', 'microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete', + 'microsoftnetwork:frontdoors:write', 'microsoftnetwork:frontdoors:delete', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Function Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (webApps.data && webApps.data.length) { + webApps.data = webApps.data.filter(app => app.id && app.kind && app.kind.toLowerCase().includes('functionapp')); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Function Apps found', location); + return rcb(); + } + + const appGateways = helpers.addSource(cache, source, + ['applicationGateways', 'list', location]); + + const loadBalancers = helpers.addSource(cache, source, + ['loadBalancers', 'list', location]); + + + const frontDoors = helpers.addSource(cache, source, + ['classicFrontDoors', 'list', 'global']); + + + const wafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', 'global']); + + + for (let functionApp of webApps.data) { + let internetExposed = ''; + if (functionApp.publicNetworkAccess && functionApp.publicNetworkAccess === 'Enabled') { + internetExposed = 'public network access'; + } else { + let attachedResources = { + appGateways: [], + lbNames: [], + frontDoors: [] + }; + + // list attached app gateways + if (appGateways && !appGateways.err && appGateways.data && appGateways.data.length) { + attachedResources.appGateways = appGateways.data.filter(ag => + ag.backendAddressPools && ag.backendAddressPools.some(pool => + pool.backendAddresses && pool.backendAddresses.some(addr => + addr.fqdn === functionApp.properties.defaultHostName))); + } + + //list attached load balancers + if (loadBalancers && !loadBalancers.err && loadBalancers.data && loadBalancers.data.length) { + attachedResources.lbNames = loadBalancers.data.filter(lb => + lb.backendAddressPools && lb.backendAddressPools.some(pool => + pool.properties.backendIPConfigurations && + pool.properties.backendIPConfigurations.some(config => + config.id.toLowerCase().includes(functionApp.id.toLowerCase())))); + + attachedResources.lbNames = attachedResources.lbNames.map(lb => lb.name); + } + + // list attached front doors + if (frontDoors && !frontDoors.err && frontDoors.data && frontDoors.data.length) { + frontDoors.data.forEach(fd => { + const isFunctionAppBackend = fd.backendPools && fd.backendPools.some(pool => + pool.backends && pool.backends.some(backend => + backend.address === functionApp.properties.defaultHostName)); + + if (isFunctionAppBackend) { + fd.associatedWafPolicies = []; + + if (fd.frontendEndpoints && wafPolicies && !wafPolicies.err && wafPolicies.data && wafPolicies.data.length) { + fd.frontendEndpoints.forEach(endpoint => { + if (endpoint.webApplicationFirewallPolicyLink) { + const policyId = endpoint.webApplicationFirewallPolicyLink.id.toLowerCase(); + const matchingPolicy = wafPolicies.data.find(policy => + policy.id && policy.id.toLowerCase() === policyId); + if (matchingPolicy) { + fd.associatedWafPolicies.push(matchingPolicy); + } + } + }); + } + + attachedResources.frontDoors.push(fd); + } + }); + } + + internetExposed = helpers.checkNetworkExposure(cache, source, [], [], location, results, attachedResources, functionApp); + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Function App is exposed to the internet through ${internetExposed}`, location, functionApp.id); + } else { + helpers.addResult(results, 0, 'Function App is not exposed to the internet', location, functionApp.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/functionPrivilegeAnalysis.js b/plugins/azure/appservice/functionPrivilegeAnalysis.js new file mode 100644 index 000000000..b796354f4 --- /dev/null +++ b/plugins/azure/appservice/functionPrivilegeAnalysis.js @@ -0,0 +1,23 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'App Service', + domain: 'Web Apps', + severity: 'Info', + description: 'Ensures that no Azure Functions in your environment have excessive permissions.', + more_info: 'Azure Functions that use managed identities or service principals with excessive Azure AD permissions may pose security risks. It is a best practice to assign only the necessary permissions to the identities attached to functions.', + link: 'https://docs.microsoft.com/en-us/azure/app-service/overview-managed-identity', + recommended_action: 'Review and restrict the Azure AD roles associated with managed identities used by Azure Functions to follow the principle of least privilege.', + realtime_triggers: [ + 'Microsoft.Web/sites/write', + 'Microsoft.Web/sites/delete', + 'Microsoft.Web/sites/functions/write', + 'Microsoft.Web/sites/functions/delete', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; diff --git a/plugins/azure/appservice/http20Enabled.js b/plugins/azure/appservice/http20Enabled.js index 8a710f9d1..44d9dd6f5 100644 --- a/plugins/azure/appservice/http20Enabled.js +++ b/plugins/azure/appservice/http20Enabled.js @@ -4,11 +4,19 @@ const helpers = require('../../../helpers/azure/'); module.exports = { title: 'HTTP 2.0 Enabled', category: 'App Service', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures the latest HTTP version is enabled for App Services', more_info: 'Enabling HTTP2.0 ensures that the App Service has the latest technology which improves server performance', recommended_action: 'Enable HTTP 2.0 support in the general settings for all App Services', link: 'https://azure.microsoft.com/en-us/blog/announcing-http-2-support-in-azure-app-service/', apis: ['webApps:list', 'webApps:listConfigurations'], + remediation_min_version: '202103311945', + remediation_description: 'The HTTP 2.0 option will be enabled for the web app', + apis_remediate: ['webApps:list'], + actions: {remediate:['webApps:updateConfiguration'], rollback:['webApps:updateConfiguration']}, + permissions: {remediate: ['webApps:updateConfiguration'], rollback: ['webApps:updateConfiguration']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], run: function(cache, settings, callback) { const results = []; @@ -57,5 +65,51 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'http20Enabled'; + var baseUrl = 'https://management.azure.com/{resource}/config/web?api-version=2019-08-01'; + var method = 'PATCH'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'http20Enabled': true + } + + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Http2.0': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/appservice/http20Enabled.spec.js b/plugins/azure/appservice/http20Enabled.spec.js new file mode 100644 index 000000000..1ee09c42f --- /dev/null +++ b/plugins/azure/appservice/http20Enabled.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var http20Enabled = require('./http20Enabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'http20Enabled': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'http20Enabled': false + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('http20Enabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + http20Enabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + http20Enabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query app service', function(done) { + const cache = createErrorCache('configs'); + http20Enabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has http 2.0 enabled', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + http20Enabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has HTTP 2.0 enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have http 2.0 enabled', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + http20Enabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have HTTP 2.0 enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/httpsOnlyEnabled.js b/plugins/azure/appservice/httpsOnlyEnabled.js index eec7db561..a53f06ebc 100644 --- a/plugins/azure/appservice/httpsOnlyEnabled.js +++ b/plugins/azure/appservice/httpsOnlyEnabled.js @@ -5,11 +5,19 @@ const helpers = require('../../../helpers/azure/'); module.exports = { title: 'HTTPS Only Enabled', category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures HTTPS Only is enabled for App Services, redirecting all HTTP traffic to HTTPS', more_info: 'Enabling HTTPS Only traffic will redirect all non-secure HTTP requests to HTTPS. HTTPS uses the SSL/TLS protocol to provide a secure connection.', recommended_action: 'Enable HTTPS Only support SSL settings for all App Services', - link: 'https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-custom-ssl#enforce-https', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-custom-ssl#enforce-https', apis: ['webApps:list'], + remediation_min_version: '202011201836', + remediation_description: 'The HTTPS-only option will be enabled for the web app', + apis_remediate: ['webApps:list'], + actions: {remediate:['webApps:write'], rollback:['webApps:write']}, + permissions: {remediate: ['webApps:write'], rollback: ['webApps:write']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + 'App Service HTTPS redirection should be used to ensure site visitors ' + @@ -18,12 +26,24 @@ module.exports = { 'App Service HTTPS redirection should be used to ensure site visitors ' + 'are always connecting over a secure channel.' }, + settings: { + whitelist_functions_for_https_only: { + name: 'Whitelist Functions For HTTPS Only', + description: 'List of comma separated functions which should be whitelisted to check', + regex: '^.*$', + default: 'khulnasoft-agentless-scanner-continuous-onboarding', + } + }, run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + let config = { + whitelist_functions_for_https_only: settings.whitelist_functions_for_https_only || this.settings.whitelist_functions_for_https_only.default + }; + async.each(locations.webApps, function(location, rcb) { const webApps = helpers.addSource( @@ -44,10 +64,16 @@ module.exports = { } webApps.data.forEach(function(webApp) { - if (webApp.httpsOnly) { - helpers.addResult(results, 0, 'The App Service has HTTPS Only enabled', location, webApp.id); + + if (webApp.name.includes(config.whitelist_functions_for_https_only)) { + helpers.addResult(results, 0, 'The App Service is whitelisted', location, webApp.id); } else { - helpers.addResult(results, 2, 'The App Service does not have HTTPS Only enabled', location, webApp.id); + + if (webApp.httpsOnly) { + helpers.addResult(results, 0, 'The App Service has HTTPS Only enabled', location, webApp.id); + } else { + helpers.addResult(results, 2, 'The App Service does not have HTTPS Only enabled', location, webApp.id); + } } }); @@ -56,5 +82,50 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'httpsOnlyEnabled'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2019-08-01'; + var method = 'PUT'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'httpsOnly': true + } + + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'HttpsOnly': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/appservice/httpsOnlyEnabled.spec.js b/plugins/azure/appservice/httpsOnlyEnabled.spec.js new file mode 100644 index 000000000..ee8f7389f --- /dev/null +++ b/plugins/azure/appservice/httpsOnlyEnabled.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var httpsOnlyEnabled = require('./httpsOnlyEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'httpsOnly': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'httpsOnly': false + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/khulnasoft-agentless-scanner-continuous-onboarding-i2hxc3is', + 'name': 'khulnasoft-agentless-scanner-continuous-onboarding-i2hxc3is', + 'httpsOnly': false + } +]; + +const createCache = (webApps) => { + return { + webApps: { + list: { + 'eastus':{ + data: webApps + } + } + } + }; +}; + +const createErrorCache = () => { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('httpsOnlyEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + httpsOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query app service', function(done) { + const cache = createErrorCache(); + httpsOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has HTTPS only enabled', function(done) { + const cache = createCache([webApps[0]]); + httpsOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The App Service has HTTPS Only enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have HTTPS only enabled', function(done) { + const cache = createCache([webApps[1]]); + httpsOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The App Service does not have HTTPS Only enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if app service gets whitelisted', function (done) { + const cache = createCache([webApps[2]]); + httpsOnlyEnabled.run(cache, { whitelist_functions_for_https_only:'khulnasoft-agentless-scanner-continuous-onboarding' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The App Service is whitelisted'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/appservice/identityEnabled.js b/plugins/azure/appservice/identityEnabled.js index 4c554de57..00aaa2c89 100644 --- a/plugins/azure/appservice/identityEnabled.js +++ b/plugins/azure/appservice/identityEnabled.js @@ -5,11 +5,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Identity Enabled', category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures a system or user assigned managed identity is enabled to authenticate to App Services without storing credentials in the code.', more_info: 'Maintaining cloud connection credentials in code is a security risk. Credentials should never appear on developer workstations and should not be checked into source control. Managed identities for Azure resources provides Azure services with a managed identity in Azure AD which can be used to authenticate to any service that supports Azure AD authentication, without having to include any credentials in code.', recommended_action: 'Enable system or user-assigned identities for all App Services and avoid storing credentials in code.', - link: 'https://docs.microsoft.com/en-us/azure/app-service/overview-managed-identity', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity', apis: ['webApps:list'], + remediation_min_version: '202101041500', + remediation_description: 'The web app will be assigned a system manager identity', + apis_remediate: ['webApps:list'], + actions: {remediate:['webApps:update'], rollback:['webApps:update']}, + permissions: {remediate: ['webApps:update'], rollback: ['webApps:update']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], run: function(cache, settings, callback) { const results = []; @@ -48,5 +56,50 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'identityEnabled'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2019-08-01'; + var method = 'PATCH'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'identity': { + 'type': 'SystemAssigned' + } + + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'ManagedIdentity': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/appservice/identityEnabled.spec.js b/plugins/azure/appservice/identityEnabled.spec.js new file mode 100644 index 000000000..7e4831f16 --- /dev/null +++ b/plugins/azure/appservice/identityEnabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var identityEnabled = require('./identityEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'identity': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'identity': false + } +]; + +const createCache = (webApps) => { + return { + webApps: { + list: { + 'eastus':{ + data: webApps + } + } + } + }; +}; + +const createErrorCache = () => { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('identityEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + identityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query app service', function(done) { + const cache = createErrorCache(); + identityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has identity assigned', function(done) { + const cache = createCache([webApps[0]]); + identityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The App Service has identities assigned'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service app service does not have any identity assigned', function(done) { + const cache = createCache([webApps[1]]); + identityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The App Service does not have an identity assigned'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/javaVersion.js b/plugins/azure/appservice/javaVersion.js index 604864cdc..cf4aeff2c 100644 --- a/plugins/azure/appservice/javaVersion.js +++ b/plugins/azure/appservice/javaVersion.js @@ -4,27 +4,28 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Java Version', category: 'App Service', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures the latest version of Java is installed for all App Services', more_info: 'Installing the latest version of Java will reduce the security risk of missing security patches.', recommended_action: 'Select the latest version of Java for all Java-based App Services', - link: 'https://docs.microsoft.com/en-us/azure/app-service/app-service-web-get-started-java', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-web-get-started-java', apis: ['webApps:list', 'webApps:listConfigurations'], settings: { latestJavaVersion: { name: 'Latest Java Version', - default: 1.8, + default: 17, description: 'The latest Java version supported by Azure App Service.', regex: '[0-9.]{2,5}' } }, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], run: function(cache, settings, callback) { const config = { latestJavaVersion: settings.latestJavaVersion || this.settings.latestJavaVersion.default }; - var custom = helpers.isCustom(settings, this.settings); - var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); @@ -38,7 +39,7 @@ module.exports = { if (webApps.err || !webApps.data) { helpers.addResult(results, 3, - 'Unable to query for App Services: ' + helpers.addError(webApps), location); + 'Unable to query list web apps: ' + helpers.addError(webApps), location); return rcb(); } @@ -47,28 +48,50 @@ module.exports = { results, 0, 'No existing App Services found', location); return rcb(); } - - var found = false; - - webApps.data.forEach(function(webApp) { + let found = false; + for (let webApp of webApps.data) { + found = false; const webConfigs = helpers.addSource( - cache, source, ['webApps', 'listConfigurations', location, webApp.id] - ); - - if (helpers.checkAppVersions( - webConfigs, - results, - location, - webApp.id, - 'javaVersion', - config.latestJavaVersion, - 'Java', - custom) - ) { - found = true; + cache, source, ['webApps', 'listConfigurations', location, webApp.id]); + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query list web app configurations: ' + helpers.addError(webConfigs), + location, webApp.id); + continue; } - }); + var appConfig = webConfigs.data[0]; + let versionAvailable = false, currentVersion; + if (webApp.kind && webApp.kind === 'app'){ + // windows app + if (appConfig.javaContainer && appConfig.javaContainer.toLowerCase() === 'java'){ + found = true; + currentVersion = appConfig.javaVersion; + if (appConfig.javaVersion && parseFloat(appConfig.javaVersion) >= parseFloat(config.latestJavaVersion)){ + versionAvailable = true; + } + } + } else { + // linux app + if (appConfig.linuxFxVersion && + (appConfig.linuxFxVersion.toLowerCase().indexOf('java') > -1)){ + found = true; + const versionPattern =/java\|(\d+)(?:-([\w\d]+))?/i; + const match = appConfig.linuxFxVersion.match(versionPattern); + currentVersion = match ? match[1] : ''; + if (currentVersion && currentVersion != '' && parseFloat(currentVersion) >= parseFloat(config.latestJavaVersion)){ + versionAvailable = true; + } + } + } + if (found){ + if (versionAvailable) { + helpers.addResult(results, 0, `The Java version (${currentVersion}) is the latest version`, location, webApp.id); + } else { + helpers.addResult(results, 2, `The Java version (${currentVersion}) is not the latest version`, location, webApp.id); + } + } + } if (!found) { helpers.addResult(results, 0, 'No App Services with Java found', location); } diff --git a/plugins/azure/appservice/javaVersion.spec.js b/plugins/azure/appservice/javaVersion.spec.js new file mode 100644 index 000000000..e986b09e3 --- /dev/null +++ b/plugins/azure/appservice/javaVersion.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var javaVersion = require('./javaVersion'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'kind': 'app' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'kind': 'app,linux' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'javaVersion': null + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'javaVersion': '17', + 'javaContainer': 'JAVA' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'javaVersion': '1.0', + 'javaContainer': 'JAVA' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'JAVA|17-java17' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('javaVersion', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no java app found', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Services with Java found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query list web apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app has latest java version', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Java version (17) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have latest java version', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The Java version (1.0) is not the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give Passing result if Linux app service have latest java version', function(done) { + const cache = createCache([webApps[1]], [configurations[3]]); + javaVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Java version (17) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/netFrameworkVersion.js b/plugins/azure/appservice/netFrameworkVersion.js index 659b8f453..c1ec5aab0 100644 --- a/plugins/azure/appservice/netFrameworkVersion.js +++ b/plugins/azure/appservice/netFrameworkVersion.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: '.NET Framework Version', category: 'App Service', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures the latest version of the .NET Framework is installed for all App Services.', more_info: 'Installing the latest version of the .NET framework will reduce the security risk of missing security patches.', recommended_action: 'Select the latest version of the .NET framework for all .NET-based App Services', - link: 'https://docs.microsoft.com/en-us/azure/app-service/web-sites-configure', + link: 'https://learn.microsoft.com/en-us/azure/app-service/web-sites-configure', apis: ['webApps:list', 'webApps:listConfigurations'], settings: { latestNetFrameworkVersion: { @@ -17,6 +19,8 @@ module.exports = { regex: '[0-9.]{2,5}' } }, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + run: function(cache, settings, callback) { const config = { latestNetFrameworkVersion: settings.latestNetFrameworkVersion || this.settings.latestNetFrameworkVersion.default diff --git a/plugins/azure/appservice/netFrameworkVersion.spec.js b/plugins/azure/appservice/netFrameworkVersion.spec.js new file mode 100644 index 000000000..37fe30238 --- /dev/null +++ b/plugins/azure/appservice/netFrameworkVersion.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +var netFrameworkVersion = require('./netFrameworkVersion'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'netFrameworkVersion': null + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'netFrameworkVersion': '4.0' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'netFrameworkVersion': '3.0' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('netFrameworkVersion', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no .NET app found', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Services with .NET found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if app has no configs', function(done) { + const cache = createErrorCache('configs'); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No App Services with .NET found'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app has latest .NET version', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The .NET version (4.0) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have latest .NET version', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + netFrameworkVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The .NET version (3.0) is not the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/nodeJsVersion.js b/plugins/azure/appservice/nodeJsVersion.js new file mode 100644 index 000000000..e7298c00c --- /dev/null +++ b/plugins/azure/appservice/nodeJsVersion.js @@ -0,0 +1,110 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Node.js Version', + category: 'App Service', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures the latest version of Node.js is installed for all App Services', + more_info: 'Installing the latest version of Node.js will reduce the security risk of missing security patches.', + recommended_action: 'Select the latest version of Node for all Node.js based App Services.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/configure-language-nodejs', + apis: ['webApps:list', 'webApps:listConfigurations'], + settings: { + latestNodeJsVersion: { + name: 'Latest Node.js Version', + default: '20', + description: 'The latest Node.js version supported by Azure App Service.', + regex: '[0-9.]{1,2}' + } + }, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + + run: function(cache, settings, callback) { + const config = { + latestNodeJsVersion: settings.latestNodeJsVersion || this.settings.latestNodeJsVersion.default + }; + + var custom = helpers.isCustom(settings, this.settings); + + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource( + cache, source, ['webApps', 'list', location] + ); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for App Services: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult( + results, 0, 'No existing App Services found', location); + return rcb(); + } + + var found = false; + + webApps.data.forEach(function(webApp) { + const webConfigs = helpers.addSource( + cache, source, ['webApps', 'listConfigurations', location, webApp.id] + ); + + if (!webConfigs || webConfigs.err || !webConfigs.data) { + helpers.addResult(results, 3, + 'Unable to query App Service: ' + helpers.addError(webConfigs), + location, webApp.id); + } else { + if (webConfigs.data[0] && + webConfigs.data[0].linuxFxVersion && + webConfigs.data[0].linuxFxVersion.indexOf('NODE') > -1 && + webConfigs.data[0].linuxFxVersion.indexOf('|') > -1) { + found = true; + var linuxFxVersion = webConfigs.data[0].linuxFxVersion; + var nodeVersion = linuxFxVersion.substr(linuxFxVersion.indexOf('|') + 1).replace('-lts', ''); + var isLatestVersion = helpers.compareVersions(nodeVersion, config.latestNodeJsVersion); + + if (isLatestVersion === -1) { + helpers.addResult(results, 2, + `The Node.js version (${nodeVersion}) is not the latest version`, location, webApp.id, custom); + } else { + helpers.addResult(results, 0, + `The Node.js version (${nodeVersion}) is the latest version`, location, webApp.id, custom); + } + } else if (webConfigs.data[0] && webConfigs.data[0].nodeVersion) { + found = true; + + var nodeJsVersion = webConfigs.data[0].nodeVersion.replace(/~/g, ''); + isLatestVersion = helpers.compareVersions(nodeJsVersion, config.latestNodeJsVersion); + + if (isLatestVersion === -1) { + helpers.addResult(results, 2, + `The Node.js version (${nodeJsVersion}) is not the latest version`, location, webApp.id, custom); + } else { + helpers.addResult(results, 0, + `The Node.js version (${nodeJsVersion}) is the latest version`, location, webApp.id, custom); + } + } + } + }); + + if (!found) { + helpers.addResult(results, 0, 'No App Services with Node.js found', location); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/appservice/nodeJsVersion.spec.js b/plugins/azure/appservice/nodeJsVersion.spec.js new file mode 100644 index 000000000..162f95493 --- /dev/null +++ b/plugins/azure/appservice/nodeJsVersion.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var nodeJsVersion = require('./nodeJsVersion'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': '', + 'nodeVersion': '', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': '', + 'nodeVersion': '~20', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'NODE|20-lts' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'NODE|18-lts' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('nodeJsVersion', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Node.js app found', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Services with Node.js found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app has latest Node.js version', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Node.js version (20) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give passing result if Linux app has latest Node.js version', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Node.js version (20) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have latest Node.js version', function(done) { + const cache = createCache([webApps[0]], [configurations[3]]); + nodeJsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The Node.js version (18) is not the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/phpVersion.js b/plugins/azure/appservice/phpVersion.js index 0c221f520..4870c4f58 100644 --- a/plugins/azure/appservice/phpVersion.js +++ b/plugins/azure/appservice/phpVersion.js @@ -4,19 +4,23 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'PHP Version', category: 'App Service', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures the latest version of PHP is installed for all App Services', more_info: 'Installing the latest version of PHP will reduce the security risk of missing security patches.', recommended_action: 'Select the latest version of PHP for all PHP-based App Services', - link: 'https://docs.microsoft.com/en-us/azure/app-service/web-sites-php-configure', + link: 'https://learn.microsoft.com/en-us/azure/app-service/web-sites-php-configure', apis: ['webApps:list', 'webApps:listConfigurations'], settings: { latestPhpVersion: { name: 'Latest PHP Version', - default: '7.3', + default: '8.2', description: 'The latest PHP version supported by Azure App Service.', regex: '[0-9.]{2,5}' } }, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + run: function(cache, settings, callback) { const config = { latestPhpVersion: settings.latestPhpVersion || this.settings.latestPhpVersion.default @@ -29,9 +33,8 @@ module.exports = { var locations = helpers.locations(settings.govcloud); async.each(locations.webApps, function(location, rcb) { - const webApps = helpers.addSource( - cache, source, ['webApps', 'list', location] - ); + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); if (!webApps) return rcb(); @@ -46,32 +49,35 @@ module.exports = { results, 0, 'No existing App Services found', location); return rcb(); } - - var found = false; - - webApps.data.forEach(function(webApp) { + let found = false; + for (let webApp of webApps.data){ const webConfigs = helpers.addSource( cache, source, ['webApps', 'listConfigurations', location, webApp.id] ); - if (helpers.checkAppVersions( - webConfigs, - results, - location, - webApp.id, - 'phpVersion', - config.latestphpVersion, - 'PHP', - custom) - ) { - found = true; + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query for Web App Configs: ' + helpers.addError(webConfigs), + location, webApp.id); + continue; + } + + if (webConfigs.data[0] && webConfigs.data[0].linuxFxVersion && (webConfigs.data[0].linuxFxVersion.toLowerCase().indexOf('php') > -1)){ + found = true; + let currentVersion = webConfigs.data[0].linuxFxVersion.split('|')[1]; + if (parseFloat(currentVersion) >= parseFloat(config.latestPhpVersion)){ + helpers.addResult(results, 0, + `The PHP version (${currentVersion}) is the latest version`, location, webApp.id, custom); + } else { + helpers.addResult(results, 2, + `The PHP version (${currentVersion}) is not the latest version`, location, webApp.id, custom); + } } - }); + } if (!found) { helpers.addResult(results, 0, 'No App Services with PHP found', location); - } - + } rcb(); }, function() { // Global checking goes here diff --git a/plugins/azure/appservice/phpVersion.spec.js b/plugins/azure/appservice/phpVersion.spec.js new file mode 100644 index 000000000..7c55213f6 --- /dev/null +++ b/plugins/azure/appservice/phpVersion.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var phpVersion = require('./phpVersion'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': null + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'PHP|8.2' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'PHP|5.2' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('phpVersion', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if app has no configs', function(done) { + const cache = createErrorCache('configs'); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + done(); + }); + }); + + it('should give passing result if app has latest PHP version', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PHP version (8.2) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have latest PHP version', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PHP version (5.2) is not the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give passing result if no PHP app found', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + phpVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Services with PHP found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/privateEndpointsEnabled.js b/plugins/azure/appservice/privateEndpointsEnabled.js new file mode 100644 index 000000000..17f8f002f --- /dev/null +++ b/plugins/azure/appservice/privateEndpointsEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Private Endpoints Configured', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Web Apps and Function Apps are accessible only through private endpoints.', + more_info: 'Enabling private endpoints for Azure App Service and Function Apps enhances security by allowing access exclusively through a private network, minimizing the risk of public internet exposure and protecting against external attacks.', + recommended_action: 'Ensure that Private Endpoints are configured properly and Public Network Access is disabled for Web Apps and Function Apps.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-private-endpoint', + apis: ['webApps:list', 'webApps:getWebAppDetails'], + realtime_triggers: ['microsoftweb:sites:write', 'microsoftweb:sites:privateendpointconnectionproxies:write', 'microsoftweb:sites:privateendpointconnectionproxies:delete', 'microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(function(webApp) { + if (!webApp || !webApp.id) return; + + const webAppDetails = helpers.addSource(cache, source, + ['webApps', 'getWebAppDetails', location, webApp.id]); + + let hasPrivateEndpoints = false; + + if (webAppDetails && !webAppDetails.err && webAppDetails.data && webAppDetails.data.privateEndpointConnections) { + if (Array.isArray(webAppDetails.data.privateEndpointConnections) && webAppDetails.data.privateEndpointConnections.length > 0) { + hasPrivateEndpoints = true; + } + } + + if (!hasPrivateEndpoints && webApp.privateEndpointConnections && webApp.privateEndpointConnections.length > 0) { + hasPrivateEndpoints = true; + } + + if (hasPrivateEndpoints) { + if (webApp.kind && webApp.kind.toLowerCase().includes('functionapp')) { + helpers.addResult(results, 0, 'Function App has Private Endpoints configured', location, webApp.id); + } else { + helpers.addResult(results, 0, 'App Service has Private Endpoints configured', location, webApp.id); + } + } else { + // No private endpoints configured + if (webApp.kind && webApp.kind.toLowerCase().includes('functionapp')) { + helpers.addResult(results, 2, 'Function App does not have Private Endpoints configured', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have Private Endpoints configured', location, webApp.id); + } + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/privateEndpointsEnabled.spec.js b/plugins/azure/appservice/privateEndpointsEnabled.spec.js new file mode 100644 index 000000000..efce9f52f --- /dev/null +++ b/plugins/azure/appservice/privateEndpointsEnabled.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var privateEndpointsEnabled = require('./privateEndpointsEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'kind': 'app', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/privateEndpointConnections/test-endpoint', + 'name': 'test-endpoint' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app2', + 'name': 'app2', + 'kind': 'app', + 'privateEndpointConnections': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func1', + 'name': 'func1', + 'kind': 'functionapp', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func1/privateEndpointConnections/func-endpoint', + 'name': 'func-endpoint' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func2', + 'name': 'func2', + 'kind': 'functionapp', + 'privateEndpointConnections': [] + } +]; + +const createCache = (webApps, privateEndpointConnections) => { + let cache = { + webApps: { + list: { + 'eastus': { + data: webApps + } + } + } + }; + + if (privateEndpointConnections && webApps) { + cache.webApps.getWebAppDetails = { + 'eastus': {} + }; + webApps.forEach((webApp, index) => { + if (webApp && webApp.id) { + cache.webApps.getWebAppDetails['eastus'][webApp.id] = { + data: { + ...webApp, + privateEndpointConnections: privateEndpointConnections[index] || [] + } + }; + } + }); + } + + return cache; +}; + +const createErrorCache = () => { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('privateEndpointsEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query web app ', function(done) { + const cache = createErrorCache(); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has Private Endpoints configured', function(done) { + const cache = createCache([webApps[0]], [[{id: 'endpoint1', name: 'test-endpoint'}]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have Private Endpoints configured', function(done) { + const cache = createCache([webApps[1]], [[]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if function app has Private Endpoints configured', function(done) { + const cache = createCache([webApps[2]], [[{id: 'func-endpoint', name: 'func-test-endpoint'}]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Function App has Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if function app does not have Private Endpoints configured', function(done) { + const cache = createCache([webApps[3]], [[]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Function App does not have Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/pythonVersion.js b/plugins/azure/appservice/pythonVersion.js index f98cfb2fe..3d01633e0 100644 --- a/plugins/azure/appservice/pythonVersion.js +++ b/plugins/azure/appservice/pythonVersion.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Python Version', category: 'App Service', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures the latest version of Python is installed for all App Services', more_info: 'Installing the latest version of Python will reduce the security risk of missing security patches.', recommended_action: 'Select the latest version of Python for all Python-based App Services', - link: 'https://docs.microsoft.com/en-us/azure/app-service/containers/how-to-configure-python', + link: 'https://learn.microsoft.com/en-us/azure/app-service/containers/how-to-configure-python', apis: ['webApps:list', 'webApps:listConfigurations'], settings: { latestPythonVersion: { @@ -17,6 +19,8 @@ module.exports = { regex: '[0-9.]{2,5}' } }, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + run: function(cache, settings, callback) { const config = { @@ -67,16 +71,14 @@ module.exports = { found = true; var pythonVersion = webConfigs.data[0].linuxFxVersion.substr(webConfigs.data[0].linuxFxVersion.indexOf('|') + 1); + var isLatestVersion = helpers.compareVersions(pythonVersion, config.latestPythonVersion); - var version = parseFloat(pythonVersion); - var allowedVersion = parseFloat(config.latestPythonVersion); - - if (version >= allowedVersion) { - helpers.addResult(results, 0, - `The Python version (${pythonVersion}) is the latest version`, location, webApp.id, custom); - } else { + if (isLatestVersion === -1) { helpers.addResult(results, 2, `The Python version (${pythonVersion}) is not the latest version`, location, webApp.id, custom); + } else { + helpers.addResult(results, 0, + `The Python version (${pythonVersion}) is the latest version`, location, webApp.id, custom); } } } diff --git a/plugins/azure/appservice/pythonVersion.spec.js b/plugins/azure/appservice/pythonVersion.spec.js new file mode 100644 index 000000000..b18e449f7 --- /dev/null +++ b/plugins/azure/appservice/pythonVersion.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var pythonVersion = require('./pythonVersion'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': '' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'PYTHON|3.6' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'linuxFxVersion': 'PYTHON|2.7' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('pythonVersion', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + pythonVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Python app found', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + pythonVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No App Services with Python found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + pythonVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app has latest Python version', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + pythonVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Python version (3.6) is the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not have latest Python version', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + pythonVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The Python version (2.7) is not the latest version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/remoteDebuggingDisabled.js b/plugins/azure/appservice/remoteDebuggingDisabled.js new file mode 100644 index 000000000..c109579ac --- /dev/null +++ b/plugins/azure/appservice/remoteDebuggingDisabled.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Remote Debugging Disabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that Azure Web Apps have remote debugging disabled.', + more_info: 'Remote debugging feature requires specific inbound ports to be opened which can increase chances of unauthorized access.', + recommended_action: 'Remote debugging should be disabled for Azure Web Apps', + link: 'https://learn.microsoft.com/en-us/azure/app-service/troubleshoot-dotnet-visual-studio', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + async.each(webApps.data, function(webApp, scb) { + const configs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!configs || configs.err || !configs.data || !configs.data.length) { + helpers.addResult(results, 3, 'Unable to query for Web App Configs: ' + helpers.addError(configs), location); + return scb(); + } + + const remoteDebugging = configs.data.some(config => config.remoteDebuggingEnabled); + if (!remoteDebugging) { + helpers.addResult(results, 0, 'Remote debugging is disabled for web app', location, webApp.id); + } else { + helpers.addResult(results, 2, 'Remote debugging is enabled for web app', location, webApp.id); + } + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/remoteDebuggingDisabled.spec.js b/plugins/azure/appservice/remoteDebuggingDisabled.spec.js new file mode 100644 index 000000000..98d03a936 --- /dev/null +++ b/plugins/azure/appservice/remoteDebuggingDisabled.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var remoteDebuggingDisabled = require('./remoteDebuggingDisabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + } +]; + +const configs = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'remoteDebuggingEnabled': false + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'location': 'East US', + 'remoteDebuggingEnabled': true + } +]; + +const createCache = (webApps, configs) => { + let app = {}; + let config = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + config[webApps[0].id] = { + 'data': configs + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + }, + listConfigurations: { + 'eastus': config + } + } + }; +}; + +describe('remoteDebuggingDisabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if no web app configs', function(done) { + const cache = createCache([webApps[0]], []); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web app configs', function(done) { + const cache = createCache([webApps[0]]); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web App Configs:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if remote debugging is disabled', function(done) { + const cache = createCache([webApps[0]], [configs[0]]); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Remote debugging is disabled for web app'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if remote debugging is enabled', function(done) { + const cache = createCache([webApps[0]], [configs[1]]); + remoteDebuggingDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Remote debugging is enabled for web app'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/scmSiteAccessRestriction.js b/plugins/azure/appservice/scmSiteAccessRestriction.js new file mode 100644 index 000000000..f897e5408 --- /dev/null +++ b/plugins/azure/appservice/scmSiteAccessRestriction.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service SCM Site Access Restriction', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Azure App Services restrict access to the SCM site that\'s used by your app.', + more_info: 'In addition to being able to control access to your app, you can restrict access to the SCM site that\'s used by your app. ' + + 'The SCM site is both the web deploy endpoint and the Kudu console.', + recommended_action: 'Add access restriction rules under network settings for the scm site used by your app', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-ip-restrictions#set-up-azure-functions-access-restrictions', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for App Services : ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing App Services found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + const webConfigs = helpers.addSource( + cache, source, ['webApps', 'listConfigurations', location, webApp.id] + ); + + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), + location, webApp.id); + } else { + const config = webConfigs.data[0]; + + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'App Service has access restriction enabled for scm site', + location, webApp.id); + } else { + let denyAllIp; + if (config.scmIpSecurityRestrictions && config.scmIpSecurityRestrictions.length) { + denyAllIp = config.scmIpSecurityRestrictions.find(ipSecurityRestriction => + ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && + ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' + ); + } + + if (denyAllIp) { + helpers.addResult(results, 0, + 'App Service has access restriction enabled for scm site', + location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have access restriction enabled for scm site', location, webApp.id); + } + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/azure/appservice/scmSiteAccessRestriction.spec.js b/plugins/azure/appservice/scmSiteAccessRestriction.spec.js new file mode 100644 index 000000000..8d8847f10 --- /dev/null +++ b/plugins/azure/appservice/scmSiteAccessRestriction.spec.js @@ -0,0 +1,188 @@ +var expect = require('chai').expect; +var scmSiteAccessRestriction = require('./scmSiteAccessRestriction'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'scmIpSecurityRestrictions': [ + { + 'ipAddress': 'Any', + 'action': 'Allow', + 'priority': 1, + 'name': 'Allow all', + 'description': 'Allow all access' + } + ], + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'scmIpSecurityRestrictions': [ + { + 'ipAddress': '208.130.0.0/16', + 'action': 'Allow', + 'tag': 'Default', + 'priority': 1, + 'name': 'xyz' + }, + { + 'ipAddress': 'Any', + 'action': 'Deny', + 'priority': 2147483647, + 'name': 'Deny all', + 'description': 'Deny all access' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled', + 'scmIpSecurityRestrictions': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled', + 'scmIpSecurityRestrictions': [ + { + 'ipAddress': 'Any', + 'action': 'Allow', + 'priority': 1, + 'name': 'Allow all', + 'description': 'Allow all access' + } + ] + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('scmSiteAccessRestriction', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if app has no configs', function(done) { + const cache = createErrorCache('configs'); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service configuration'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app Service has access restriction enabled for scm site', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service does not have access restriction enabled for scm site', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Service has public network access disabled with no IP restrictions', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Service has public network access disabled even with allow all IP restrictions', function(done) { + const cache = createCache([webApps[0]], [configurations[3]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/secureHttptriggerFunction.js b/plugins/azure/appservice/secureHttptriggerFunction.js new file mode 100644 index 000000000..fee7575ca --- /dev/null +++ b/plugins/azure/appservice/secureHttptriggerFunction.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Secure Azure Http Triggered Function', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that the authorization level function is set on Azure HTTP trigger functions.', + more_info: 'Authorization levels for HTTP-triggered functions helps establish a secure access control framework during development and provides flexibility to enhance security in production by considering alternative measures beyond basic API access keys.', + recommended_action: 'Set the authorization level for all HTTP-triggered functions.', + link: 'https://learn.microsoft.com/en-us/azure/azure-functions/security-concepts?tabs=v4#authorization-scopes-function-level', + apis: ['webApps:list', 'functions:list'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete', 'microsoftweb:sites:functions:write', 'microsoftweb:sites:functions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const appService = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!appService) return rcb(); + + if (appService.err || !appService.data) { + helpers.addResult(results, 3, 'Unable to query for App Services' + helpers.addError(appService), location); + return rcb(); + } + + if (!appService.data.length) { + helpers.addResult(results, 0, 'No existing App Service found', location); + return rcb(); + } + + async.each(appService.data, function(app, scb) { + if (app && app.kind && app.kind.startsWith('functionapp')){ + const functions = helpers.addSource(cache, source, + ['functions', 'list', location, app.id]); + + if (!functions || functions.err || !functions.data) { + helpers.addResult(results, 3, 'Unable to query Azure Functions for app service: ' + helpers.addError(functions), location); + return scb(); + } + if (!functions.data.length) { + helpers.addResult(results, 0, 'No existing Functions found for App Service', location, app.id); + return scb(); + } + for (let func of functions.data) { + if (func && func.config && func.config.bindings && func.config.bindings.length > 0) { + const httpTriggerBindings = func.config.bindings.filter(binding => binding.type === 'httpTrigger'); + + if (httpTriggerBindings.length) { + for (const httpTriggerBinding of httpTriggerBindings) { + // Check Authorization Level for each httpTrigger binding + if (httpTriggerBinding.authLevel && httpTriggerBinding.authLevel.toLowerCase() === 'function') { + helpers.addResult(results, 0, 'HTTP triggered function has secured authorization Level', location, func.id); + } else { + helpers.addResult(results, 2, 'HTTP triggered function does not have secured authorization Level', location, func.id); + } + } + } else { + // Not an HTTP trigger function + helpers.addResult(results, 0, 'Function is not an HTTP triggered function', location, func.id); + } + } + } + + } else { + helpers.addResult(results, 0, 'Http triggered functions can not be configured for web app', location, appService.id); + } + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/secureHttptriggerFunction.spec.js b/plugins/azure/appservice/secureHttptriggerFunction.spec.js new file mode 100644 index 000000000..07a2b54ff --- /dev/null +++ b/plugins/azure/appservice/secureHttptriggerFunction.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var secureHttptriggerFunction = require('./secureHttptriggerFunction'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'East US' + } +]; + +const functions = [ + { + 'config': { + 'bindings': [ + { + 'authLevel': 'function', + 'type': 'httpTrigger' + } + ] + + } + + }, + { + + 'config': { + 'bindings': [ + { + 'authLevel': 'anonymous', + 'type': 'httpTrigger' + } + ] + + } + } +]; + +const createCache = (webApps, functions) => { + let app = {}; + let func = {}; + + if (webApps) { + app['data'] = webApps; + if (webApps && webApps.length) { + func[webApps[0].id] = { + 'data': functions + }; + } + } + + return { + webApps: { + list: { + 'eastus': app + } + }, + functions:{ + list:{ + 'eastus': func + } + } + }; +}; + +describe('secureHttptriggerFunction', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Service found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if http trigger funtions can not be configured', function(done) { + const cache = createCache([webApps[0]], []); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Http triggered functions can not be configured for web app'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if no http trigger function', function(done) { + const cache = createCache([webApps[1]], []); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Functions found for App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for http trigger function', function(done) { + const cache = createCache([webApps[1]]); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Azure Functions for app service: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if auth level is function for http trigger function', function(done) { + const cache = createCache([webApps[1]], [functions[0]]); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('HTTP triggered function has secured authorization Level'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if auth level is not function for http trigger function', function(done) { + const cache = createCache([webApps[1]], [functions[1]]); + secureHttptriggerFunction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('HTTP triggered function does not have secured authorization Level'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/securityLogsEnabled.js b/plugins/azure/appservice/securityLogsEnabled.js new file mode 100644 index 000000000..6a56a6a38 --- /dev/null +++ b/plugins/azure/appservice/securityLogsEnabled.js @@ -0,0 +1,93 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Security Logging Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that security logging is enabled for Azure Web Apps.', + more_info: 'Enabling Azure Web Apps diagnostics logging provides a quick and easy way to view application logs, allowing users to diagnose and resolve issues, including errors, performance bottlenecks, and security concerns.', + recommended_action: 'Modify Web Apps and enable diagnostic settings for all logs.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-monitoring', + apis: ['webApps:list', 'diagnosticSettings:listByAppServices'], + settings: { + app_service_diagnostic_logs: { + name: 'App Service Diagnostic Logs Enabled', + description: 'Comma separated list of diagnostic logs that should be enabled at minimum i.e. AppServiceAntivirusScanAuditLogs, AppServiceHTTPLogs etc. If you have enabled allLogs, then resource produces pass result. If you only want to check if logging is enabled or not, irrespecitve of log type, then add * in setting.', + regex: '^.*$', + default: 'AppServiceAntivirusScanAuditLogs, AppServiceHTTPLogs, AppServiceConsoleLogs, AppServiceAppLogs, AppServiceFileAuditLogs,AppServiceAuditLogs, AppServiceIPSecAuditLogs, AppServicePlatformLogs' + }, + }, + realtime_triggers: ['microsoftweb:sites:write', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete', 'microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + app_service_diagnostic_logs: settings.app_service_diagnostic_logs || this.settings.app_service_diagnostic_logs.default, + }; + + async.each(locations.webApps, (location, rcb) => { + + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(webApp => { + if (!webApp.id) return; + + if (webApp && webApp.kind && webApp.kind.startsWith('functionapp')) return; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAppServices', location, webApp.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for App Service diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, webApp.id); + return; + } + var found = true; + var missingLogs = []; + + if (config.app_service_diagnostic_logs == '*') { + found = diagnosticSettings.data.some(ds => ds.logs && ds.logs.length); + } else { + config.app_service_diagnostic_logs = config.app_service_diagnostic_logs.replace(/\s/g, ''); + missingLogs = config.app_service_diagnostic_logs.toLowerCase().split(','); + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category && log.category.toLowerCase() === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + } + + if (!missingLogs.length && found) { + helpers.addResult(results, 0, 'Web App has security logging enabled', location, webApp.id); + + } else { + helpers.addResult(results, 2, `Web App does not have security logging enabled ${missingLogs.length ? `for following: ${missingLogs}` : ''}`, location, webApp.id); + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/securityLogsEnabled.spec.js b/plugins/azure/appservice/securityLogsEnabled.spec.js new file mode 100644 index 000000000..c2e7512e9 --- /dev/null +++ b/plugins/azure/appservice/securityLogsEnabled.spec.js @@ -0,0 +1,278 @@ +var expect = require('chai').expect; +var securityLogsEnabled = require('./securityLogsEnabled'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + 'name': 'test-app', + 'type': 'Microsoft.Web/sites', + 'kind': 'app,linux', + 'location': 'East US' + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceHTTPLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceConsoleLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceAppLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceFileAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceIPSecAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServicePlatformLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, + { + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "app-ds", + "logs": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AppServiceHTTPLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, + +] + +const createCache = (webApps, diagnostics) => { + let ds = {}; + if (webApps.length) { + ds[webApps[0].id] = { + data: diagnostics + }; + } + + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': ds + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noWebApp'){ + return { + webApps: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': {} + } + } + }; + } else { + const appId = (webApps && webApps.length) ? webApps[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + webApps: { + list: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByAppServices: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('securityLogsEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + securityLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createErrorCache('webApp'); + securityLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createErrorCache('diagnostic'); + securityLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Service diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if web apps has security logs enabled with settings', function(done) { + const cache = createCache([webApps[0]], [diagnosticSettings[0]]); + securityLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Web App has security logging enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if web apps does not have security logs enabled with settings', function(done) { + const cache = createCache([webApps[0]], [diagnosticSettings[1]]); + securityLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Web App does not have security logging enabled for following: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give passing result with * setting', function(done) { + const cache = createCache([webApps[0]], [diagnosticSettings[1]]); + securityLogsEnabled.run(cache, {app_service_diagnostic_logs: '*'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Web App has security logging enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/tlsVersionCheck.js b/plugins/azure/appservice/tlsVersionCheck.js index c9fda628c..73f671520 100644 --- a/plugins/azure/appservice/tlsVersionCheck.js +++ b/plugins/azure/appservice/tlsVersionCheck.js @@ -4,11 +4,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'TLS Version Check', category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures that all web apps are using the latest version of TLS', more_info: 'App Services currently allows web apps to use TLS versions 1.0, 1.1 and 1.2. It is highly recommended to use the latest TLS 1.2 version for web app TLS connections.', recommended_action: 'Set the minimum TLS version to 1.2 for all App Services.', link: 'https://azure.microsoft.com/en-in/updates/app-service-and-functions-hosted-apps-can-now-update-tls-versions/', apis: ['webApps:list', 'webApps:listConfigurations'], + remediation_min_version: '202201131602', + remediation_description: 'TLS version 1.2 will be set for the affected Web Apps', + apis_remediate: ['webApps:list'], + actions: {remediate:['webApps:write'], rollback:['webApps:write']}, + permissions: {remediate: ['webApps:write'], rollback: ['webApps:write']}, + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], compliance: { pci: 'PCI requires all web applications encrypt data ' + 'in transit. This includes using the latest TLS ' + @@ -64,6 +72,55 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'tlsVersionCheck'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2021-02-01'; + var method = 'PATCH'; + + // for logging purposes + var webAppNameArr = resource.split('/'); + var webAppName = webAppNameArr[webAppNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'siteConfig': { + 'minTlsVersion': '1.2' + } + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'TLS1.2': 'Disabled', + 'WebApp': webAppName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) { + console.log(err); + return callback(err); + } + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/appservice/tlsVersionCheck.spec.js b/plugins/azure/appservice/tlsVersionCheck.spec.js new file mode 100644 index 000000000..e56ff708a --- /dev/null +++ b/plugins/azure/appservice/tlsVersionCheck.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var tlsVersionCheck = require('./tlsVersionCheck'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1' + } +]; + +const configurations = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'minTlsVersion': '1.2' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'minTlsVersion': '1.1' + } +]; + +const createCache = (webApps, configurations) => { + let configs = {}; + if (webApps.length > 0) { + configs[webApps[0].id] = { + data: configurations + }; + } + return { + webApps: { + list: { + 'eastus': { + data: webApps + } + }, + listConfigurations: { + 'eastus': configs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'webApp') { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + webApps: { + list: { + 'eastus': { + data: [webApps[0]] + } + }, + listConfigurations: { + 'eastus': {} + } + } + }; + } +}; + +describe('tlsVersionCheck', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([], []); + tlsVersionCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for app service', function(done) { + const cache = createErrorCache('webApp'); + tlsVersionCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if app has no configs', function(done) { + const cache = createErrorCache('configs'); + tlsVersionCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app satisfies Minimum TLS version criteria', function(done) { + const cache = createCache([webApps[0]], [configurations[0]]); + tlsVersionCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Minimum TLS version criteria is satisfied'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service does not satisfy Minimum TLS version criteria', function(done) { + const cache = createCache([webApps[0]], [configurations[1]]); + tlsVersionCheck.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Minimum TLS version is not 1.2'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/vnetIntegrated.js b/plugins/azure/appservice/vnetIntegrated.js new file mode 100644 index 000000000..38c94804b --- /dev/null +++ b/plugins/azure/appservice/vnetIntegrated.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps VNet Integrated', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have virtual network integrated.', + more_info: 'Enabling virtual network integration for apps allows outbound access to resources within the virtual network, ensuring enhanced security and operational control. This feature is crucial for proactively safeguarding your server against potential security threats and unauthorized access.', + recommended_action: 'Ensure virtual network is integrated for all web apps.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-vnet-integration', + apis: ['webApps:list'], + realtime_triggers: ['microsoftweb:sites:write', 'microsoftweb:sites:networkconfig:delete', 'microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + webApps.data.forEach(function(webApp) { + if (webApp && webApp.kind && webApp.kind === 'functionapp') { + helpers.addResult(results, 0, 'Virtual Networks cannot be integrated with function apps', location, webApp.id); + } else if (webApp && webApp.virtualNetworkSubnetId) { + helpers.addResult(results, 0, 'App Service is integrated with a virtual network', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service is not integrated with a virtual network', location, webApp.id); + } + }); + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/vnetIntegrated.spec.js b/plugins/azure/appservice/vnetIntegrated.spec.js new file mode 100644 index 000000000..37638ba56 --- /dev/null +++ b/plugins/azure/appservice/vnetIntegrated.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var vnetIntegrated = require('./vnetIntegrated'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'virtualNetworkSubnetId': '/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/virtualNetworks/test/subnets/default' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', + 'name': 'app1', + 'virtualNetworkSubnetId': null + } +]; + +const createCache = (webApps) => { + return { + webApps: { + list: { + 'eastus':{ + data: webApps + } + } + } + }; +}; + +const createErrorCache = () => { + return { + webApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('vnetIntegrated', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + vnetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query web app ', function(done) { + const cache = createErrorCache(); + vnetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app service has Vnet Integrated', function(done) { + const cache = createCache([webApps[0]]); + vnetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service is integrated with a virtual network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if app service app service does not have Vnet Integrated', function(done) { + const cache = createCache([webApps[1]]); + vnetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service is not integrated with a virtual network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/webAppsADEnabled.js b/plugins/azure/appservice/webAppsADEnabled.js new file mode 100644 index 000000000..a49de6d3c --- /dev/null +++ b/plugins/azure/appservice/webAppsADEnabled.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Web Apps Entra ID Enabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Web Apps have registration with Azure Entra ID.', + more_info: 'Registration with Azure Entra ID enables App Service web applications to connect to other Azure cloud services securely without the need of access credentials such as user names and passwords.', + recommended_action: 'Enable registration with Azure Entra ID for Azure Web Apps.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#add-a-system-assigned-identity', + apis: ['webApps:list'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, 'Unable to query for Web Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Web Apps found', location); + return rcb(); + } + + for (let app of webApps.data) { + if (app.identity && app.identity.principalId) { + helpers.addResult(results, 0, 'Registration with Azure Entra ID is enabled for the Web App', location, app.id); + } else { + helpers.addResult(results, 2, 'Registration with Azure Entra ID is disabled for the Web App', location, app.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/webAppsADEnabled.spec.js b/plugins/azure/appservice/webAppsADEnabled.spec.js new file mode 100644 index 000000000..26e86be42 --- /dev/null +++ b/plugins/azure/appservice/webAppsADEnabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var webAppsADEnabled = require('./webAppsADEnabled'); + +const webApps = [ + { + id: '/subscriptions/abcdefg-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/test/providers/Microsoft.Web/sites/test', + name: 'test', + type: 'Microsoft.Web/sites', + kind: 'app,linux', + location: 'Central US', + state: 'Running', + hostNames: [ 'test.azurewebsites.net' ], + webSpace: 'test-CentralUSwebspace-Linux', + selfLink: 'https://waws-prod-dm1-213.api.azurewebsites.windows.net:454/subscriptions/abcdefg-ebf6-437f-a3b0-28fc0d22117e/webspaces/test-CentralUSwebspace-Linux/sites/test', + repositorySiteName: 'test', + owner: null, + usageState: 'Normal', + enabled: true, + adminEnabled: true, + enabledHostNames: [ + 'test.azurewebsites.net', + 'test.scm.azurewebsites.net' + ] + }, + { + id: '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/test-app', + name: 'test-app', + type: 'Microsoft.Web/sites', + kind: 'functionapp', + location: 'East US', + identity: { + type: 'SystemAssigned', + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + principalId: '66cddca0-05fa-4ef7-a219-b145f7d9dc6d' + }, + } +]; + +const createCache = (apps) => { + return { + webApps: { + list: { + 'eastus': { + data: apps + } + } + } + }; +}; + +describe('webAppsADEnabled', function() { + describe('run', function() { + it('should give passing result if no web apps', function(done) { + const cache = createCache([]); + webAppsADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Web Apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function(done) { + const cache = createCache(null); + webAppsADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Web Apps'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Registration with Azure Entra ID is enabled', function(done) { + const cache = createCache([webApps[1]]); + webAppsADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Registration with Azure Entra ID is enabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Registration with Azure Entra ID is disabled', function(done) { + const cache = createCache([webApps[0]]); + webAppsADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Registration with Azure Entra ID is disabled for the Web App'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/automationAccounts/automationAcctApprovedCerts.js b/plugins/azure/automationAccounts/automationAcctApprovedCerts.js new file mode 100644 index 000000000..a789be71b --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctApprovedCerts.js @@ -0,0 +1,85 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Approved Certificates Only', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Azure Automation accounts are only using approved certificates.', + more_info: 'Certificates in azure automation accounts should be approved by the organization to meet the organizational security requirements. ', + recommended_action: 'Ensure that Azure Automation accounts are only using approved certificates.', + link: 'https://learn.microsoft.com/en-us/azure/automation/shared-resources/certificates', + apis: ['automationAccounts:list', 'certificates:listByAutomationAccounts'], + settings: { + ca_approved_certificates: { + name: 'Approved CA Certificates', + description: 'List of comma separated approved certificates names', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete'], + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + var config = { + approvedCertificates: settings.ca_approved_certificates || this.settings.ca_approved_certificates.default + }; + + if (!config.approvedCertificates.length) return callback(null, results, source); + + var certificatesList = config.approvedCertificates.toLowerCase().split(','); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + const acctCertificates = helpers.addSource(cache, source, + ['certificates', 'listByAutomationAccounts', location, account.id]); + + if (acctCertificates.err || !acctCertificates.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts certificates: ' + helpers.addError(acctCertificates), location, account.id); + continue; + } + + if (!acctCertificates.data.length) { + helpers.addResult(results, 0, 'No existing certificates found for Automation Account', location, account.id); + } else { + var unapprovedCerts = acctCertificates.data.filter(cert => + cert.name && !certificatesList.includes(cert.name.toLowerCase())).map(function(cert) { + return cert.name; + }); + if (unapprovedCerts && unapprovedCerts.length) { + helpers.addResult(results, 2, `Automation account is using following unapproved certificates: ${unapprovedCerts.join(',')}`, location, account.id); + } else { + helpers.addResult(results, 0, 'Automation account is using approved certificates only', location, account.id); + } + } + + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctApprovedCerts.spec.js b/plugins/azure/automationAccounts/automationAcctApprovedCerts.spec.js new file mode 100644 index 000000000..5d396fc11 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctApprovedCerts.spec.js @@ -0,0 +1,207 @@ +var expect = require('chai').expect; +var automationAcctApprovedCerts = require('./automationAcctApprovedCerts.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", + "location": "centralus", + "name": "Automate-12345-CUS", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const certificates = [ + { + "id": "/subscriptions/1234/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/myAutomationAccount33/certificates/testCert", + "name": "testCert", + "description": "Sample Cert", + "isExportable": false, + "thumbprint": "thumbprint of cert", + "expiryTime": "2018-03-29T17:25:45+00:00", + "creationTime": "2017-03-29T17:26:43.337+00:00", + "lastModifiedTime": "2017-03-29T17:28:55.01+00:00" + }, + { + + "id": "/subscriptions/1234/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/myAutomationAccount33/certificates/testCert", + "name": "testCert2", + "description": "Sample Cert", + "isExportable": false, + "thumbprint": "thumbprint of cert", + "expiryTime": "2018-03-29T17:25:45+00:00", + "creationTime": "2017-03-29T17:26:43.337+00:00", + "lastModifiedTime": "2017-03-29T17:28:55.01+00:00" + + } + +] + +const createCache = (automationAccounts, certs) => { + let certificates = {}; + if (automationAccounts.length) { + certificates[automationAccounts[0].id] = { + data: certs + }; + } + + + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts + } + } + }, + certificates: { + listByAutomationAccounts: { + 'eastus': certificates + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'unknownaccount') { + return { + automationAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noaccounts') { + return { + automationAccounts: { + list: { + 'eastus': { + data: {} + } + } + } + }; + } else if (key === 'certs') { + let certificates = {}; + if (automationAccounts.length) { + certificates[automationAccounts[0].id] = { + 'err': 'unknown' + }; + } + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + certificates: { + listByAutomationAccounts: { + 'eastus': certificates + } + } + }; + } else { + const accountId = (automationAccounts && automationAccounts.length) ? automationAccounts[0].id : null; + const certificate = (certificates && certificates.length) ? certificates[0].id : null; + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + certificates: { + listByAutomationAccounts: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('automationAcctApprovedCerts', function () { + describe('run', function () { + it('should give no result if setting is not enabled', function (done) { + const cache = createErrorCache('noaccounts'); + automationAcctApprovedCerts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createErrorCache('noaccounts'); + automationAcctApprovedCerts.run(cache, {ca_approved_certificates: 'cert'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createErrorCache('unknownaccount'); + automationAcctApprovedCerts.run(cache, {ca_approved_certificates: 'cert'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation certs', function (done) { + const cache = createErrorCache('certs'); + automationAcctApprovedCerts.run(cache, {ca_approved_certificates: 'cert'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts certificates'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has approved certificates', function (done) { + const cache = createCache([automationAccounts[0]], [certificates[0]]); + automationAcctApprovedCerts.run(cache, {ca_approved_certificates: 'testCert, appCert'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account is using approved certificates only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account is using following certificates which are not approved by organization: ', function (done) { + const cache = createCache([automationAccounts[1]], [certificates[1]]); + automationAcctApprovedCerts.run(cache, {ca_approved_certificates: 'testCert, appCert'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account is using following unapproved certificates: testCert2'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.js b/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.js new file mode 100644 index 000000000..a39cc527f --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.js @@ -0,0 +1,90 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Diagnostic Logs', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Azure Automation account.', + more_info: 'Azure Automation can send runbook job status and job streams to get insights, alert emails and correlate jobs across automation accounts. It also allows you to get the audit logs related to Automation accounts, runbooks, and other asset create, modify and delete operations.', + recommended_action: 'Enable diagnostic logging for all Automation accounts.', + link: 'https://learn.microsoft.com/en-us/azure/automation/automation-manage-send-joblogs-log-analytics#azure-automation-diagnostic-settings', + apis: ['automationAccounts:list', 'diagnosticSettings:listByAutomationAccounts'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + settings: { + diagnostic_logs: { + name: 'Diagnostic Logs Enabled', + description: 'Comma separated list of diagnostic logs that should be enabled at minimum i.e. JobLogs, JobStreams etc. If you have enabled allLogs, then resource produces pass result. If you only want to check if logging is enabled or not, irrespecitve of log type, then add * in setting.', + regex: '^.*$', + default: 'JobLogs, JobStreams, DscNodeStatus, AuditEvent' + }, + }, + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + diagnostic_logs: settings.diagnostic_logs || this.settings.diagnostic_logs.default, + }; + async.each(locations.automationAccounts, (location, rcb) => { + + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (let account of automationAccounts.data) { + if (!account.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAutomationAccounts', location, account.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query Automation account diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, account.id); + continue; + } + + var found = true; + var missingLogs = []; + if (config.diagnostic_logs == '*') { + found = diagnosticSettings.data.some(ds => ds.logs && ds.logs.length); + } else { + config.diagnostic_logs = config.diagnostic_logs.replace(/\s/g, ''); + missingLogs = config.diagnostic_logs.toLowerCase().split(','); + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category && log.category.toLowerCase() === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + } + + if (!missingLogs.length && found) { + helpers.addResult(results, 0, 'Automation account has diagnostic logs enabled', location, account.id); + + } else { + helpers.addResult(results, 2, `Automation account does not have diagnostic logs enabled ${missingLogs.length ? `for following: ${missingLogs}` : ''}`, location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.spec.js b/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.spec.js new file mode 100644 index 000000000..d894a0342 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctDiagnosticLogs.spec.js @@ -0,0 +1,305 @@ +var expect = require('chai').expect; +var automationAccountDiagnosticLogs = require('./automationAcctDiagnosticLogs.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", + "location": "centralus", + "name": "Automate-12345-CUS", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/12345/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/automationAccounts/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testaccesslogs', + location: 'global', + logs: [ + { + "category": "JobLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + { + id: '/subscriptions/12345/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/automationAccounts/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testwaflogs', + location: 'global', + logs: [ + { + "category": "JobLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "JobStreams", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "DscNodeStatus", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "AuditEvent", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + + ], + "logAnalyticsDestinationType": null + }, + {}, + { + id: '/subscriptions/12345/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/automationAccounts/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testwaflogs', + location: 'global', + logs: [ + { + "category": "", + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, + { + id: '/subscriptions/12345/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/automationAccounts/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testwaflogs', + location: 'global', + logs: [ + { + "category": "DummyCategory", + "categoryGroup": "", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, +] + +const createCache = (automationAccounts, diagnostics) => { + let diagnostic = {}; + if (automationAccounts.length) { + diagnostic[automationAccounts[0].id] = { + data: diagnostics + }; + } + + + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts + } + } + }, + diagnosticSettings: { + listByAutomationAccounts: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'unknownaccount') { + return { + automationAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noaccounts'){ + return { + automationAccounts: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + automationAccounts: { + list: { + 'global': { + data: [automationAccounts[0]] + } + } + }, + diagnosticSettings: { + listByAutomationAccounts: { + 'global': {} + } + } + }; + } else { + const accountId = (automationAccounts && automationAccounts.length) ? automationAccounts[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + diagnosticSettings: { + listByAutomationAccounts: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('automationAccountDiagnosticLogs', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createErrorCache('noaccounts'); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createErrorCache('unknownaccount'); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function (done) { + const cache = createErrorCache('policy'); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation account diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has diagnostic logging enabled', function (done) { + const cache = createCache([automationAccounts[0]], [diagnosticSettings[1]]); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account does not have diagnostic logging enabled', function (done) { + const cache = createCache([automationAccounts[1]], [diagnosticSettings[0]]); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account does not have diagnostic logs enabled for following'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give pass result if automation account have allLogs Enabled', function(done) { + const cache = createCache([automationAccounts[1]], [diagnosticSettings[3]]); + automationAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result with * setting', function (done) { + const cache = createCache([automationAccounts[1]], [diagnosticSettings[4]]); + automationAccountDiagnosticLogs.run(cache, {diagnostic_logs: '*'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Automation Account does not have diagnostic logs enabled with settings', function (done) { + const cache = createCache([automationAccounts[1]], [diagnosticSettings[1]]); + automationAccountDiagnosticLogs.run(cache, {diagnostic_logs: 'testsetting'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account does not have diagnostic logs enabled for following:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctEncryptedVars.js b/plugins/azure/automationAccounts/automationAcctEncryptedVars.js new file mode 100644 index 000000000..e5efb9cb9 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctEncryptedVars.js @@ -0,0 +1,67 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Encrypted Variables', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that Azure Automation account variables have encryption enabled.', + more_info: 'Azure Automation secures assets such as variables, credentials and certificates using various levels of encryption. Enabling encryption on automation account variables helps enhance the security and privacy of the assets storing sensitive data.', + recommended_action: 'Delete unencrypted variables in automation account and create new encrypted variables.', + link: 'https://learn.microsoft.com/en-us/azure/automation/automation-secure-asset-encryption', + apis: ['automationAccounts:list','accountVariables:listByAutomationAccounts'], + realtime_triggers: ['microsoftautomation:automationaccounts:runbooks:write','microsoftautomation:automationaccounts:runbooks:delete','microsoftautomation:automationaccounts:variables:write','microsoftautomation:automationaccounts:variables:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + var accountVariables = helpers.addSource(cache, source, + ['accountVariables', 'listByAutomationAccounts', location, account.id]); + + if (!accountVariables || accountVariables.err || !accountVariables.data) { + helpers.addResult(results, 3, `Unable to query Automation account variables: ${helpers.addError(accountVariables)}`, + location, account.id); + continue; + } else if (!accountVariables.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts variables found', location); + continue; + } else { + var unencryptedVariableNames = accountVariables.data.filter(variable => !variable.isEncrypted).map(variable => variable.name); + if (unencryptedVariableNames.length) { + helpers.addResult(results, 2, `Automation account has following unencrypted variables: ${unencryptedVariableNames.join(',')}`, location, account.id); + } else { + helpers.addResult(results, 0, 'Automation account has all variables encrypted', location, account.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctEncryptedVars.spec.js b/plugins/azure/automationAccounts/automationAcctEncryptedVars.spec.js new file mode 100644 index 000000000..5a55fa0df --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctEncryptedVars.spec.js @@ -0,0 +1,192 @@ +var expect = require('chai').expect; +var automationAcctEncryptedVariables = require('./automationAcctEncryptedVars.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "EastUS2", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "centralus", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const accountVariables = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/variables/test-variable", + "name": "test-variable", + "type": "Microsoft.Automation/AutomationAccounts/Variables", + "creationTime": "2024-01-22T13:33:52.1066667+00:00", + "lastModifiedTime": "2024-01-22T13:33:52.1066667+00:00", + "isEncrypted": true, + "description": "test" + + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/variables/test-variable", + "name": "test-variable", + "type": "Microsoft.Automation/AutomationAccounts/Variables", + "creationTime": "2024-01-22T13:33:52.1066667+00:00", + "lastModifiedTime": "2024-01-22T13:33:52.1066667+00:00", + "isEncrypted": false, + "description": "test" + + }, + {}, +] + +const createCache = (automationAccounts, variables) => { + let variable = {}; + if (automationAccounts.length) { + variable[automationAccounts[0].id] = { + data: variables + }; + } + + + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts + } + } + }, + accountVariables: { + listByAutomationAccounts: { + 'eastus': variable + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'unknownaccount') { + return { + automationAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noaccounts') { + return { + automationAccounts: { + list: { + 'eastus': { + data: {} + } + } + } + }; + } else if (key === 'variable') { + return { + automationAccounts: { + list: { + 'global': { + data: [automationAccounts[0]] + } + } + }, + accountVariables: { + listByAutomationAccounts: { + 'global': {} + } + } + }; + } else { + const accountId = (automationAccounts && automationAccounts.length) ? automationAccounts[0].id : null; + const variables = (accountVariables && accountVariables.length) ? accountVariables[0].id : null; + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + accountVariables: { + listByAutomationAccounts: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('automationAcctEncryptedVariables', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createErrorCache('noaccounts'); + automationAcctEncryptedVariables.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createErrorCache('unknownaccount'); + automationAcctEncryptedVariables.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation account variables', function (done) { + const cache = createErrorCache('policy'); + automationAcctEncryptedVariables.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation account variables:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has all variables encrypted', function (done) { + const cache = createCache([automationAccounts[0]], [accountVariables[0]]); + automationAcctEncryptedVariables.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has all variables encrypted'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account does not have all variables encrypted', function (done) { + const cache = createCache([automationAccounts[1]], [accountVariables[1]]); + automationAcctEncryptedVariables.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account has following unencrypted variables: test'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.js b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.js new file mode 100644 index 000000000..2c6f899d9 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.js @@ -0,0 +1,71 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Expired Webhooks', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Azure Automation webhooks are deleted after they have expired.', + more_info: 'Expired webhooks increase the risk of unauthorized access, compromising security. Setting a validity period aligns with corporate policies, minimizing the potential for misuse and enhancing overall security.', + recommended_action: 'Delete the expired webhook and re-create it.', + link: 'https://learn.microsoft.com/en-us/azure/automation/automation-webhooks', + apis: ['automationAccounts:list', 'webhooks:listByAutomationAccounts'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:webhooks:write', 'microsoftautomation:automationaccounts:webhooks:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + var webhooks = helpers.addSource(cache, source, + ['webhooks', 'listByAutomationAccounts', location, account.id]); + + if (!webhooks || webhooks.err || !webhooks.data ) { + helpers.addResult(results, 3, 'Unable to query for Automation account webhooks: ' + helpers.addError(webhooks), location, account.id); + continue; + } + + if (!webhooks.data.length) { + helpers.addResult(results, 0, 'No existing webhooks found for Automation account', location, account.id); + continue; + } + var today = new Date(); + webhooks.data.forEach(function(webhook) { + var expiryTime = new Date(Date.parse(webhook.expiryTime)); + + if (expiryTime < today) { + helpers.addResult(results, 2, 'Automation account webhook has expired', location, webhook.id); + } else { + helpers.addResult(results, 0, 'Automation account webhook is valid', location, webhook.id); + } + }); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js new file mode 100644 index 000000000..bd714f340 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +var automationAcctExpiredWebhooks = require('./automationAcctExpiredWebhooks'); +var nextMonthExpiry = new Date(); +nextMonthExpiry.setMonth(nextMonthExpiry.getMonth() + 1); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "EastUS2", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "centralus", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const webhooks = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/webhooks/test1", + "name": "test1", + "creationTime": "2024-01-22T13:33:52.1066667+00:00", + "expiryTime": nextMonthExpiry, + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/webhooks/test2", + "name": "test2", + "creationTime": "2024-01-22T13:33:52.1066667+00:00", + "expiryTime": "2024-02-22T13:33:52.1066667+00:00", + + }, + {}, +] +const createCache = (automationAccounts, webhooks) => { + let webhook = {}; + if (automationAccounts.length) { + webhook[automationAccounts[0].id] = { + data: webhooks + }; + } + + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts + } + } + }, + webhooks: { + listByAutomationAccounts: { + 'eastus': webhook + } + } + }; +}; + + +const createErrorCache = (key) => { + if (key == 'unknownaccount') { + return { + automationAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noaccounts') { + return { + automationAccounts: { + list: { + 'eastus': { + data: [] + } + } + } + }; + } else if (key === 'webhook') { + return { + automationAccounts: { + list: { + 'global': { + data: [automationAccounts[0]] + } + } + }, + webhooks: { + listByAutomationAccounts: { + 'global': {} + } + } + }; + } else { + const accountId = (automationAccounts && automationAccounts.length) ? automationAccounts[0].id : null; + const webhook = (webhooks && webhooks.length) ? webhooks[0].id : null; + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + webhooks: { + listByAutomationAccounts: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + + +describe('automationAcctExpiredWebhooks', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createErrorCache('noaccounts'); + automationAcctExpiredWebhooks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createErrorCache('unknownaccount'); + automationAcctExpiredWebhooks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation account webhooks', function (done) { + const cache = createErrorCache('policy'); + automationAcctExpiredWebhooks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Automation account webhooks: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if webhook is vaild', function (done) { + const cache = createCache([automationAccounts[0]], [webhooks[0]]); + automationAcctExpiredWebhooks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account webhook is valid'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if webhook is expired', function (done) { + const cache = createCache([automationAccounts[1]], [webhooks[1]]); + automationAcctExpiredWebhooks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account webhook has expired'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctHasTags.js b/plugins/azure/automationAccounts/automationAcctHasTags.js new file mode 100644 index 000000000..ca4ac1280 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctHasTags.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Has Tags', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that Azure Automation accounts has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify automation account and tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['automationAccounts:list'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + if (account.tags && Object.entries(account.tags).length > 0){ + helpers.addResult(results, 0, 'Automation account has tags associated', location, account.id); + } else { + helpers.addResult(results, 2, 'Automation account does not have tags associated', location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctHasTags.spec.js b/plugins/azure/automationAccounts/automationAcctHasTags.spec.js new file mode 100644 index 000000000..6cfaef105 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctHasTags.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var automationAcctHasTags = require('./automationAcctHasTags.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": { "key": "value" }, + + }, + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", + "location": "centralus", + "name": "Automate-12345-CUS", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "publicNetworkAccess": true, + } +]; + +const createCache = (automationAccounts,err) => { + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts, + err: err + } + } + } + } +}; + +describe('automationAcctHasTags', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createCache([]); + automationAcctHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createCache(null, 'Error'); + automationAcctHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Automation account has tags associated', function (done) { + const cache = createCache([automationAccounts[0]]); + automationAcctHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Automation account does not have tags associated', function (done) { + const cache = createCache([automationAccounts[1]]); + automationAcctHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctManagedIdentity.js b/plugins/azure/automationAccounts/automationAcctManagedIdentity.js new file mode 100644 index 000000000..8f620522f --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctManagedIdentity.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Managed Identity', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Azure Automation accounts have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify automation account and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/automation/quickstarts/enable-managed-identity', + apis: ['automationAccounts:list'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete','microsoftautomation:automationaccounts:runbooks:write','microsoftautomation:automationaccounts:runbooks:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + if (account.identity && account.identity.type) { + helpers.addResult(results, 0, 'Automation account has managed identity enabled', location, account.id); + } else { + helpers.addResult(results, 2, 'Automation account does not have managed identity enabled', location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctManagedIdentity.spec.js b/plugins/azure/automationAccounts/automationAcctManagedIdentity.spec.js new file mode 100644 index 000000000..34cd94d7f --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctManagedIdentity.spec.js @@ -0,0 +1,100 @@ +var expect = require('chai').expect; +var automationAccountManagedIdentity = require('./automationAcctManagedIdentity.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + }, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "dc03d47d-e6df-491f-aebe-50a93412a890", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "userAssignedIdentities": { + "/subscriptions/12345/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "123455", + "ClientId": "1234554" + } + } + } + }, + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", + "location": "centralus", + "name": "Automate-12345-CUS", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const createCache = (automationAccounts,err) => { + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts, + err: err + } + } + } + } +}; + +describe('automationAccountManagedIdentity', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createCache([]); + automationAccountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createCache(null, 'Error'); + automationAccountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has managed identity enabled', function (done) { + const cache = createCache([automationAccounts[0]]); + automationAccountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account does not have managed identity enabled', function (done) { + const cache = createCache([automationAccounts[1]]); + automationAccountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.js b/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.js new file mode 100644 index 000000000..3abba89f4 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.js @@ -0,0 +1,62 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Private Endpoints Configured', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Azure Automation accounts have private endpoints enabled.', + more_info: 'Enabling private endpoints for Automation Account enhances security by allowing access exclusively through a private network, minimizing the risk of public internet exposure and protecting against external attacks.', + recommended_action: 'Ensure that private endpoints are configured properly for all Automation Accounts.', + link: 'https://learn.microsoft.com/en-us/azure/automation/how-to/private-link-security', + apis: ['automationAccounts:list', 'automationAccounts:get'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete','microsoftautomation:automationcccounts:privateendpointconnectionproxies:write', 'microsoftautomation:automationcccounts:privateendpointconnectionproxies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + var describeAcct = helpers.addSource(cache, source, + ['automationAccounts', 'get', location, account.id]); + + if (!describeAcct || describeAcct.err || !describeAcct.data ) { + helpers.addResult(results, 3, 'Unable to query for Automation account: ' + helpers.addError(describeAcct), location); + continue; + } + + if (describeAcct.data.privateEndpointConnections && describeAcct.data.privateEndpointConnections.length) { + helpers.addResult(results, 0, 'Automation Account has private endpoints configured', location, account.id); + } else { + helpers.addResult(results, 2, 'Automation Account does not have private endpoints configured', location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.spec.js b/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.spec.js new file mode 100644 index 000000000..45c0b21ed --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctPrivateEndpoints.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +var automationAccountPrivateEndponits = require('./automationAcctPrivateEndpoints'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2" + } +]; + +const account = [ +{ + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + }, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "dc03d47d-e6df-491f-aebe-50a93412a890", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "userAssignedIdentities": { + "/subscriptions/12345/resourcegroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "123455", + "ClientId": "1234554" + } + } + }, + "privateEndpointConnections" :[ + { + "id": '/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2/privateEndpointConnections/a112345', + "name": 'a112345', + "type": 'Microsoft.Automation/automationaccounts/privateEndponitConnections' + } + ] + +}, +{ + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + }, + "identity": { + "type": "systemassigned,userassigned", + "principalId": "dc03d47d-e6df-491f-aebe-50a93412a890", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "userAssignedIdentities": { + "/subscriptions/12345/resourcegroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "123455", + "ClientId": "1234554" + } + } + } +} +] + +const createCache = (automationAccounts,acct) => { + let automationacct = {}; + let getacct = {}; + + if (automationAccounts) { + automationacct['data'] = automationAccounts; + if (automationAccounts && automationAccounts.length) { + getacct[automationAccounts[0].id] = { + 'data': acct + }; + } + } + + return { + automationAccounts: { + list: { + 'eastus': automationacct + }, + get: { + 'eastus': getacct + } + } + }; +}; + +describe('automationAccountPrivateEndponits', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createCache([]); + automationAccountPrivateEndponits.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createCache(); + automationAccountPrivateEndponits.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has private endpoints configured', function (done) { + const cache = createCache(automationAccounts, account[0]); + automationAccountPrivateEndponits.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation Account has private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account does not have private endpoints configured', function (done) { + const cache = createCache(automationAccounts,account[1] ); + automationAccountPrivateEndponits.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation Account does not have private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctPublicAccess.js b/plugins/azure/automationAccounts/automationAcctPublicAccess.js new file mode 100644 index 000000000..47e9d730f --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctPublicAccess.js @@ -0,0 +1,66 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Public Access Disabled', + category: 'Automation', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensure that Azure Automation accounts have have public access disabled.', + more_info: 'Disabling public network access ensures that network traffic between the machines on the VNet and the Automation account traverses over the a private link, eliminating exposure from the public internet.', + recommended_action: 'Modify automation account and disable public access.', + link: 'https://learn.microsoft.com/en-us/azure/automation/how-to/private-link-security', + apis: ['automationAccounts:list', 'automationAccounts:get'], + realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + var describeAcct = helpers.addSource(cache, source, + ['automationAccounts', 'get', location, account.id]); + + if (!describeAcct || describeAcct.err || !describeAcct.data ) { + helpers.addResult(results, 3, 'Unable to query for Automation account: ' + helpers.addError(describeAcct), location); + continue; + } + + if (Object.prototype.hasOwnProperty.call(describeAcct.data, 'publicNetworkAccess')) { + if (describeAcct.data.publicNetworkAccess) { + helpers.addResult(results, 2, 'Automation account does not have public network access disabled', location, account.id); + } else { + helpers.addResult(results, 0, 'Automation account has public network access disabled', location, account.id); + } + } else { + helpers.addResult(results, 2, 'Automation account does not have public network access disabled', location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js b/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js new file mode 100644 index 000000000..5d4ce4e11 --- /dev/null +++ b/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +var automationAcctPublicAccess = require('./automationAcctPublicAccess.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2" + } +]; + +const account = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", + "location": "EastUS2", + "name": "Automate-12345-EUS2", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00", + "identity": { + "type": "systemassigned,userassigned", + "principalId": "dc03d47d-e6df-491f-aebe-50a93412a890", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "userAssignedIdentities": { + "/subscriptions/12345/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "PrincipalId": "123455", + "ClientId": "1234554" + } + } + }, + "publicNetworkAccess": false, + }, + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", + "location": "centralus", + "name": "Automate-12345-CUS", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "publicNetworkAccess": true, + } +]; + +const createCache = (automationAccounts, acct) => { + let automationacct = {}; + let getacct = {}; + + if (automationAccounts) { + automationacct['data'] = automationAccounts; + if (automationAccounts && automationAccounts.length) { + getacct[automationAccounts[0].id] = { + 'data': acct + }; + } + } + + return { + automationAccounts: { + list: { + 'eastus': automationacct + }, + get: { + 'eastus': getacct + } + } + }; +}; + +describe('automationAcctPublicAccess', function () { + describe('run', function () { + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createCache([]); + automationAcctPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createCache(); + automationAcctPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account has public network access disabled', function (done) { + const cache = createCache(automationAccounts, account[0]); + automationAcctPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account does not have public network access disabled', function (done) { + const cache = createCache(automationAccounts, account[1]); + automationAcctPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/validSourceControls.js b/plugins/azure/automationAccounts/validSourceControls.js new file mode 100644 index 000000000..b1721f71d --- /dev/null +++ b/plugins/azure/automationAccounts/validSourceControls.js @@ -0,0 +1,92 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automation Account Valid Source Controls', + category: 'Automation', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure that Azure Automation account are not using undesired source controls.', + more_info: 'Automation accounts should only use allowed source controls in order to follow your organizations\'s security and compliance requirements.', + recommended_action: 'Ensure disallowed valid source controls are not being used for Automation accounts.', + link: 'https://learn.microsoft.com/en-us/azure/automation/source-control-integration', + apis: ['automationAccounts:list', 'sourceControls:listByAutomationAccounts'], + settings: { + automation_account_disallowed_source_controls: { + name: 'Automation Account Disallowed Source Controls', + description: 'A comma-separated list of source controls which should not be used', + regex: '^((vsoGit|vsoTfvc|gitHub),? ?){1,3}$', + default: '' + } + }, + realtime_triggers: ['microsoftautomation:automationaccounts:runbooks:write', 'microsoftautomation:automationaccounts:runbooks:delete', 'microsoftautomation:automationaccounts:sourcecontrols:write', 'microsoftautomation:automationaccounts:sourcecontrols:delete'], + + run: function(cache, settings, callback) { + + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + automation_account_disallowed_source_controls: settings.automation_account_disallowed_source_controls || this.settings.automation_account_disallowed_source_controls.default + }; + + if (!config.automation_account_disallowed_source_controls.length) return callback(null, results, source); + + async.each(locations.automationAccounts, (location, rcb) => { + const automationAccounts = helpers.addSource(cache, source, + ['automationAccounts', 'list', location]); + + if (!automationAccounts) return rcb(); + + if (automationAccounts.err || !automationAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query Automation accounts: ' + helpers.addError(automationAccounts), location); + return rcb(); + } + + if (!automationAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts found', location); + return rcb(); + } + + for (var account of automationAccounts.data) { + if (!account.id) continue; + + var sourceControls = helpers.addSource(cache, source, + ['sourceControls', 'listByAutomationAccounts', location, account.id]); + + if (!sourceControls || sourceControls.err || !sourceControls.data) { + helpers.addResult(results, 3, `Unable to query Automation account source controls: ${helpers.addError(sourceControls)}`, + location, account.id); + continue; + } else if (!sourceControls.data.length) { + helpers.addResult(results, 0, 'No existing Automation accounts source controls found', location); + continue; + } else { + var disallowedSourceControls = config.automation_account_disallowed_source_controls.toLowerCase().split(','); + + var foundDisallowedControls = []; + sourceControls.data.forEach(sourceControl => { + if (sourceControl.sourceType && disallowedSourceControls.includes(sourceControl.sourceType.toLowerCase()) && + !foundDisallowedControls.includes(sourceControl.sourceType)) { + foundDisallowedControls.push(sourceControl.sourceType); + } + }); + + if (foundDisallowedControls && foundDisallowedControls.length) { + helpers.addResult(results, 2, `Automation account is using the following source controls: ${foundDisallowedControls.join(',')} which should not be used`, location, account.id); + + } else { + helpers.addResult(results, 0, 'Automation account is using valid source controls', location, account.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/automationAccounts/validSourceControls.spec.js b/plugins/azure/automationAccounts/validSourceControls.spec.js new file mode 100644 index 000000000..7601a8a31 --- /dev/null +++ b/plugins/azure/automationAccounts/validSourceControls.spec.js @@ -0,0 +1,226 @@ +var expect = require('chai').expect; +var automationAcctValidSourceControls = require('./validSourceControls.js'); + +const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "EastUS2", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-10-27T07:27:02.76+00:00", + "lastModifiedTime": "2023-10-27T07:27:02.76+00:00" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct", + "location": "centralus", + "name": "test-automationacct", + "type": "Microsoft.Automation/AutomationAccounts", + "tags": {}, + "properties": { + "creationTime": "2023-07-17T13:09:21.4866667+00:00", + "lastModifiedTime": "2023-07-17T13:09:21.4866667+00:00" + } + } +]; + +const sourceControls = [ + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/sourceControls/test-variable", + "name": "testcontrol", + "type": null, + "creationTime": "2024-02-29T10:59:51.3432035+00:00", + "lastModifiedTime": "2024-02-29T10:59:51.3432035+00:00", + "repoUrl": "https://dummyrepo.git", + "sourceType": "GitHub", + "branch": "main", + "folderPath": "/", + "autoSync": false, + "publishRunbook": false, + "description": null + + }, + { + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/sourceControls/test-variable", + "name": "testcontrol", + "type": null, + "creationTime": "2024-02-29T10:59:51.3432035+00:00", + "lastModifiedTime": "2024-02-29T10:59:51.3432035+00:00", + "repoUrl": "https://dummyrepo.git", + "sourceType": "GitHub", + "branch": "main", + "folderPath": "/", + "autoSync": false, + "publishRunbook": false, + "description": null + + }, + {}, +] + +const createCache = (automationAccounts, sourceControls) => { + let source = {}; + if (automationAccounts.length) { + source[automationAccounts[0].id] = { + data: sourceControls + }; + } + + + return { + automationAccounts: { + list: { + 'eastus': { + data: automationAccounts + } + } + }, + sourceControls: { + listByAutomationAccounts: { + 'eastus': source + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'unknownaccount') { + return { + automationAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noaccounts') { + return { + automationAccounts: { + list: { + 'eastus': { + data: {} + } + } + } + }; + } else if (key === 'source') { + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + sourceControls: { + listByAutomationAccounts: { + 'eastus': {} + } + } + }; + } else { + const accountId = (automationAccounts && automationAccounts.length) ? automationAccounts[0].id : null; + let variables = (sourceControls && sourceControls.length) ? sourceControls[0].id : null; + let source = {}; + source[accountId] = { + data: {} + }; + + return { + automationAccounts: { + list: { + 'eastus': { + data: [automationAccounts[0]] + } + } + }, + sourceControls: { + listByAutomationAccounts: { + 'eastus': source + } + } + }; + } +}; + +describe('automationAcctValidSourceControls', function () { + describe('run', function () { + + it('should give no result if setting value for default is empty', function (done) { + const cache = createCache([automationAccounts[1]], [sourceControls[1]]); + automationAcctValidSourceControls.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + + it('should give pass result if No existing automation accounts found', function (done) { + const cache = createErrorCache('noaccounts'); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'Github'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query automation accounts:', function (done) { + const cache = createErrorCache('unknownaccount'); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'Github'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give unknown result if Unable to query automation accounts source controls:', function (done) { + const cache = createErrorCache('source'); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'Github'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Automation account source controls:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no source controls found', function (done) { + const cache = createErrorCache('check'); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'vsoGit'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Automation accounts source controls found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if automation account is using valid source controls', function (done) { + const cache = createCache([automationAccounts[0]], [sourceControls[0]]); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'vsoGit'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automation account is using valid source controls'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if automation account is using the undesired source controls:', function (done) { + const cache = createCache([automationAccounts[1]], [sourceControls[1]]); + automationAcctValidSourceControls.run(cache, {automation_account_disallowed_source_controls: 'Github'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automation account is using the following source controls: GitHub'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostDiagnosticLogs.js b/plugins/azure/bastion/bastionHostDiagnosticLogs.js new file mode 100644 index 000000000..3c9b087ba --- /dev/null +++ b/plugins/azure/bastion/bastionHostDiagnosticLogs.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Bastion Host Diagnostic Logs Enabled', + category: 'Bastion', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Azure Bastion Host.', + more_info: 'Enabling diagnostics logs for Azure Bastion helps to monitor user connections, tracking access to workloads, and identifying potential security threats, providing essential insights for effective management and security of your environment.', + recommended_action: 'Enable diagnostic logging for all Bastion Hosts.', + link: 'https://learn.microsoft.com/en-us/azure/bastion/diagnostic-logs', + apis: ['bastionHosts:listAll','diagnosticSettings:listByBastionHosts'], + realtime_triggers: ['microsoftnetwork:bastionhosts:write','microsoftnetwork:bastionhosts:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.bastionHosts, function(location, rcb){ + let bastionHost = helpers.addSource(cache, source, + ['bastionHosts', 'listAll', location]); + + if (!bastionHost) return rcb(); + + if (bastionHost.err || !bastionHost.data) { + helpers.addResult(results, 3, 'Unable to query for bastion host: ' + helpers.addError(bastionHost), location); + return rcb(); + } + + if (!bastionHost.data.length) { + helpers.addResult(results, 0, 'No existing Bastion Hosts found', location); + return rcb(); + } + + for (let host of bastionHost.data) { + if (!host.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByBastionHosts', location, host.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Bastion Host diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, host.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Bastion Host has diagnostic logs enabled', location, host.id); + } else { + helpers.addResult(results, 2, 'Bastion Host does not have diagnostic logs enabled', location, host.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostDiagnosticLogs.spec.js b/plugins/azure/bastion/bastionHostDiagnosticLogs.spec.js new file mode 100644 index 000000000..b70612999 --- /dev/null +++ b/plugins/azure/bastion/bastionHostDiagnosticLogs.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +var bastionHostDiagnosticLogs = require('./bastionHostDiagnosticLogs'); + +const bastionHosts = [ + { + "name": "bastionhost'", + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/bastionHosts/bastionhosttenant'", + "type": "Microsoft.Network/bastionHosts", + "location": "eastus", + "sku": { + "name": "Standard" + } + } +]; + +const diagnosticSettings = [ + { + + "id": "/subscriptions/subid/resourcegroups/rg1/providers/microsoft.network/bastionhosts/bastionhosttenant/providers/microsoft.insights/diagnosticSettings/testlogs", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "testlogs", + "location": null, + "kind": null, + "tags": null, + "storageAccountId": null, + "serviceBusRuleId": null, + "eventHubAuthorizationRuleId": null, + "eventHubName": null, + "metrics": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logs": [ + { + "category": "BastionAuditLogs", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null, + + "identity": null + } + +]; + +const createCache = (bastionHosts, ds) => { + const id = bastionHosts && bastionHosts.length ? bastionHosts[0].id : null; + return { + bastionHosts: { + listAll: { + 'eastus': { + data: bastionHosts + } + } + }, + diagnosticSettings: { + listByBastionHosts: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + bastionHosts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('bastionHostDiagnosticLogs', function () { + describe('run', function () { + + it('should give unknown result if unable to query for azure bastion hosts', function (done) { + const cache = createCache(null); + bastionHostDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for bastion host:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no bastion host exist', function (done) { + const cache = createCache([]); + bastionHostDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Bastion Hosts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([bastionHosts[0]], null); + bastionHostDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Bastion Host diagnostic settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Bastion Host has diagnostic logs enabled', function (done) { + const cache = createCache([bastionHosts[0]], [diagnosticSettings[0]]); + bastionHostDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bastion Host has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Bastion Host does not have diagnostic logs enabled', function (done) { + const cache = createCache([bastionHosts[0]],[[]]); + bastionHostDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bastion Host does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostExists.js b/plugins/azure/bastion/bastionHostExists.js new file mode 100644 index 000000000..4fd20208a --- /dev/null +++ b/plugins/azure/bastion/bastionHostExists.js @@ -0,0 +1,43 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Azure Bastion Host Exists', + category: 'Bastion', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that there is at least one Bastion host in Azure subscription.', + more_info: 'Bastion provides secure RDP and SSH connectivity to all of the VMs in the virtual network in which it is provisioned. Using Azure Bastion protects your virtual machines from exposing RDP/SSH ports to the outside world, while still providing secure access using RDP/SSH.', + recommended_action: 'Create an Azure Bastion Host in azure account.', + link: 'https://learn.microsoft.com/en-us/azure/bastion/bastion-overview', + apis: ['bastionHosts:listAll'], + realtime_triggers: ['microsoftnetwork:bastionhosts:write','microsoftnetwork:bastionhosts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.bastionHosts, function(location, rcb){ + let bastionHost = helpers.addSource(cache, source, + ['bastionHosts', 'listAll', location]); + + if (!bastionHost) return rcb(); + + if (bastionHost.err || !bastionHost.data) { + helpers.addResult(results, 3, 'Unable to query for bastion host: ' + helpers.addError(bastionHost), location); + return rcb(); + } + + if (bastionHost.data.length) { + helpers.addResult(results, 0, `There are ${bastionHost.data.length} Bastion hosts`, location); + } else { + helpers.addResult(results, 2, 'No Bastion hosts found', location); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostExists.spec.js b/plugins/azure/bastion/bastionHostExists.spec.js new file mode 100644 index 000000000..c65d11ca8 --- /dev/null +++ b/plugins/azure/bastion/bastionHostExists.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var bastionHostExist = require('./bastionHostExists'); + +const listBastionHost = [ + { + "name": "bastionhost'", + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/bastionHosts/bastionhosttenant'", + "type": "Microsoft.Network/bastionHosts", + "etag": "w/\\00000000-0000-0000-0000-000000000000\\", + "location": "West US", + "sku": { + "name": "Standard" + }, + "properties": { + "provisioningState": "Succeeded", + "dnsName": "bst-9d89d361-100e-4c01-b92d-466548c476dc.bastion.azure.com", + "ipConfigurations": [ + { + "name": "bastionHostIpConfiguration", + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/bastionHosts/bastionhosttenant/bastionHostIpConfigurations/bastionHostIpConfiguration", + "etag": "w/\\00000000-0000-0000-0000-000000000000\\", + "type": "Microsoft.Network/bastionHosts/bastionHostIpConfigurations", + "properties": { + "provisioningState": "Succeeded", + "privateIPAllocationMethod": "Dynamic", + "subnet": { + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet2/subnets/BastionHostSubnet" + }, + "publicIPAddress": { + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/publicIPAddresses/pipName" + } + } + } + ] + } + } +]; + +const createCache = (listBastionHosts) => { + return { + bastionHosts: { + listAll: { + 'eastus': { data: listBastionHosts} + } + } + }; +}; + +describe('bastionHostExist', function() { + describe('run', function() { + + it('should give unknown result if unable to query for azure subscription', function(done) { + const cache = createCache(null); + bastionHostExist.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for bastion host:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if bastion host exist', function(done) { + const cache = createCache([listBastionHost[0]]); + bastionHostExist.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are 1 Bastion hosts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no bastion host exist', function(done) { + const cache = createCache([]); + bastionHostExist.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Bastion hosts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostHasTags.js b/plugins/azure/bastion/bastionHostHasTags.js new file mode 100644 index 000000000..e7d793209 --- /dev/null +++ b/plugins/azure/bastion/bastionHostHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Bastion Host Has Tags', + category: 'Bastion', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Azure Bastion host has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Bastion host and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['bastionHosts:listAll'], + realtime_triggers: ['microsoftnetwork:bastionhosts:write','microsoftnetwork:bastionhosts:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.bastionHosts, function(location, rcb){ + let bastionHost = helpers.addSource(cache, source, + ['bastionHosts', 'listAll', location]); + + if (!bastionHost) return rcb(); + + if (bastionHost.err || !bastionHost.data) { + helpers.addResult(results, 3, 'Unable to query for Bastion Host: ' + helpers.addError(bastionHost), location); + return rcb(); + } + + if (!bastionHost.data.length) { + helpers.addResult(results, 0, 'No existing Bastion hosts found', location); + return rcb(); + } + for (let host of bastionHost.data) { + if (!host.id) continue; + + if (host.tags && Object.entries(host.tags).length > 0) { + helpers.addResult(results, 0, 'Bastion Host has tags', location, host.id); + } else { + helpers.addResult(results, 2, 'Bastion Host does not have tags', location, host.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/bastion/bastionHostHasTags.spec.js b/plugins/azure/bastion/bastionHostHasTags.spec.js new file mode 100644 index 000000000..401ecaffb --- /dev/null +++ b/plugins/azure/bastion/bastionHostHasTags.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var bastionHostHasTags = require('./bastionHostHasTags.js'); + +const listBastionHost = [ + { + name: "bastionhost'", + id: "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/bastionHosts/bastionhosttenant'", + type: "Microsoft.Network/bastionHosts", + location: "West US", + sku: { + name: "Standard" + }, + tags:{} + }, + { + name: "bastionhost12'", + id: "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/bastionHosts/bastionhosttenant'", + type: "Microsoft.Network/bastionHosts", + location: "West US", + sku: { + name: "Standard" + }, + tags:{ + abc: "123" + } + } +]; + + +const createCache = (listBastionHosts) => { + return { + bastionHosts: { + listAll: { + 'eastus': { data: listBastionHosts} + } + } + }; +}; + + +describe('bastionHostHasTags', function () { + describe('run', function () { + + it('should give a passing result if no Bastion Hosts are found', function (done) { + const cache = createCache([], null); + bastionHostHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Bastion hosts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Bastion hosts', function (done) { + const cache = createCache(null, ['error']); + bastionHostHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Bastion Host'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Bastion host has tags', function (done) { + const cache = createCache([listBastionHost[1]], null); + bastionHostHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bastion Host has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Bastion host does not have tags', function (done) { + const cache = createCache([listBastionHost[0]], null); + bastionHostHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bastion Host does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountCmkEncrypted.js b/plugins/azure/batchAccounts/batchAccountCmkEncrypted.js new file mode 100644 index 000000000..02811a5b4 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountCmkEncrypted.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account CMK Encrypted', + category: 'Batch', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure Batch accounts are CMK encrypted.', + more_info: 'Azure Batch allows you to encrypt data in your accounts using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault.', + recommended_action: 'Enable CMK encryption for all Azure Batch accounts.', + link: 'https://learn.microsoft.com/en-us/azure/batch/batch-customer-managed-key', + apis: ['batchAccounts:list'], + realtime_triggers: ['microsoftbatch:batchaccounts:write','microsoftbatch:batchaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb){ + + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + if (batchAccount.encryption && + batchAccount.encryption.keySource && + batchAccount.encryption.keySource.toLowerCase() == 'microsoft.keyvault') { + helpers.addResult(results, 0, 'Batch account is encrypted using CMK', location, batchAccount.id); + } else { + helpers.addResult(results, 2, 'Batch account is not encrypted using CMK', location, batchAccount.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountCmkEncrypted.spec.js b/plugins/azure/batchAccounts/batchAccountCmkEncrypted.spec.js new file mode 100644 index 000000000..b30b689f5 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountCmkEncrypted.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var batchAccountCmkEncrypted = require('./batchAccountCmkEncrypted.js'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "provisioningState": "Succeeded", + "dedicatedCoreQuota": 6, + "encryption": { + "keySource": "Microsoft.Batch" + }, + }, + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "provisioningState": "Succeeded", + "dedicatedCoreQuota": 6, + "encryption": { + "keySource": "Microsoft.KeyVault", + "keyVaultProperties": { + "keyIdentifier": "https://test.vault.azure.net/keys/testkey/1" + } + }, + } +]; + +const createCache = (batchAccounts,err) => { + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts, + err: err + } + } + } + } +}; + +describe('batchAccountCmkEncrypted', function () { + describe('run', function () { + + it('should give pass result if No existing batch accounts found', function (done) { + const cache = createCache([]); + batchAccountCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query batch accounts:', function (done) { + const cache = createCache(null, 'Error'); + batchAccountCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if batch account is encrypted using CMK', function (done) { + const cache = createCache([batchAccounts[1]]); + batchAccountCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if batch account is not encrypted using CMK', function (done) { + const cache = createCache([batchAccounts[0]]); + batchAccountCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.js b/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.js new file mode 100644 index 000000000..c9f896583 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account Diagnostic Logs', + category: 'Batch', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Batch account has diagnostic logs enabled.', + more_info: 'Enabling diagnostics logs for Batch account helps to capture failures, security incidents that occurs when network is compromised. This helps identifying potential security threats and recreate activity trails to use for investigation purposes.', + recommended_action: 'Enable diagnostic logging for all Batch accounts.', + link: 'https://learn.microsoft.com/en-us/azure/batch/monitor-batch#azure-monitor-resource-logs', + apis: ['batchAccounts:list','diagnosticSettings:listByBatchAccounts'], + realtime_triggers: ['microsoftbatch:batchaccounts:write','microsoftbatch:batchaccounts:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb){ + + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByBatchAccounts', location, batchAccount.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Batch account diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, batchAccount.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Batch account has diagnostic logs enabled', location, batchAccount.id); + } else { + helpers.addResult(results, 2, 'Batch account does not have diagnostic logs enabled', location, batchAccount.id); + } + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.spec.js b/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.spec.js new file mode 100644 index 000000000..b884fdea0 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountDiagnosticLogs.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var batchAccountDiagnosticLogs = require('./batchAccountDiagnosticLogs'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "provisioningState": "Succeeded", + "dedicatedCoreQuota": 6, + "encryption": { + "keySource": "Microsoft.Batch" + }, + }, +]; + +const diagnosticSettings = [ + { + + "id": "/subscriptions/subid/resourcegroups/rg1/providers/microsoft.batch/accounts/providers/microsoft.insights/diagnosticSettings/testlogs", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "testlogs", + "location": null, + "kind": null, + "tags": null, + "storageAccountId": null, + "logs": [ + { + "category": "AllLogs", + "categoryGroup": null, + "enabled": true, + } + ], + "logAnalyticsDestinationType": null, + + "identity": null + } + +]; + +const createCache = (batchAccounts, ds) => { + const id = batchAccounts && batchAccounts.length ? batchAccounts[0].id : null; + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts + } + } + }, + diagnosticSettings: { + listByBatchAccounts: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + batchAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('batchAccountDiagnosticLogs', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Batch accounts:', function (done) { + const cache = createCache(null); + batchAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Batch account exist', function (done) { + const cache = createCache([]); + batchAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([batchAccounts[0]], null); + batchAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch account diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Batch account has diagnostic logs enabled', function (done) { + const cache = createCache([batchAccounts[0]], [diagnosticSettings[0]]); + batchAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Batch account does not have diagnostic logs enabled', function (done) { + const cache = createCache([batchAccounts[0]],[[]]); + batchAccountDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsAADEnabled.js b/plugins/azure/batchAccounts/batchAccountsAADEnabled.js new file mode 100644 index 000000000..fc2839ecd --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsAADEnabled.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account Entra ID Auth Enabled', + category: 'Batch', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Batch account has Azure Entra ID authentication mode enabled.', + more_info: 'Enabling Azure Entra ID authentication for Batch account ensures enhanced security by restricting the service API authentication to Microsoft Entra ID that prevents access through less secure shared key methods, thereby safeguarding batch resources from unauthorized access.', + recommended_action: 'Enable Entra ID authentication mode for all Batch accounts.', + link: 'https://learn.microsoft.com/en-us/azure/batch/batch-aad-auth', + apis: ['batchAccounts:list'], + realtime_triggers: ['microsoftbatch:batchaccounts:write', 'microsoftbatch:batchaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb) { + + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + let found = batchAccount.allowedAuthenticationModes && + batchAccount.allowedAuthenticationModes.length? + batchAccount.allowedAuthenticationModes.some(mode => mode.toUpperCase() === 'AAD') : false; + + if (found) { + helpers.addResult(results, 0, 'Batch account has Entra ID authentication enabled', location, batchAccount.id); + } else { + helpers.addResult(results, 2, 'Batch account does not have Entra ID authentication enabled', location, batchAccount.id); + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js b/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js new file mode 100644 index 000000000..508c96d69 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js @@ -0,0 +1,94 @@ + +var expect = require('chai').expect; +var batchAccountsAADEnabled = require('./batchAccountsAADEnabled'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "allowedAuthenticationModes": ["SharedKey", "AAD", "TaskAuthenticationToken"] + }, + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + }, +]; + +const createCache = (batchAccounts) => { + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts + } + } + } + } +}; + +const createErrorCache = () => { + return { + batchAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('batchAccountsAADEnabled', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Batch accounts:', function (done) { + const cache = createCache(null); + batchAccountsAADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Batch account exist', function (done) { + const cache = createCache([]); + batchAccountsAADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Batch account is configured with Entra ID Authentication', function (done) { + const cache = createCache([batchAccounts[0]]); + batchAccountsAADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account has Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Batch account is not configured with Entra ID Authentication', function (done) { + const cache = createCache([batchAccounts[1]]); + batchAccountsAADEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account does not have Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsHasTags.js b/plugins/azure/batchAccounts/batchAccountsHasTags.js new file mode 100644 index 000000000..0ab99cb4d --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account Has Tags', + category: 'Batch', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Azure Batch accounts have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Batch account and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources-portal', + apis: ['batchAccounts:list'], + realtime_triggers: ['microsoftbatch:batchaccounts:write','microsoftbatch:batchaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb){ + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + if (batchAccount.tags && Object.entries(batchAccount.tags).length > 0 ) { + helpers.addResult(results, 0, 'Batch account has tags associated', location, batchAccount.id); + } else { + helpers.addResult(results, 2, 'Batch account does not have tags associated', location, batchAccount.id); + } + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsHasTags.spec.js b/plugins/azure/batchAccounts/batchAccountsHasTags.spec.js new file mode 100644 index 000000000..4dcd9fb6d --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsHasTags.spec.js @@ -0,0 +1,94 @@ + +var expect = require('chai').expect; +var batchAccountsHasTags = require('./batchAccountsHasTags'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "tags": { "key": "value" } + }, + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + }, +]; + +const createCache = (batchAccounts) => { + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts + } + } + } + } +}; + +const createErrorCache = () => { + return { + batchAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('batchAccountsHasTags', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Batch accounts:', function (done) { + const cache = createCache(null); + batchAccountsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Batch account exist', function (done) { + const cache = createCache([]); + batchAccountsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Batch account has tags associated', function (done) { + const cache = createCache([batchAccounts[0]]); + batchAccountsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Batch account does not have tags associated', function (done) { + const cache = createCache([batchAccounts[1]]); + batchAccountsHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js new file mode 100644 index 000000000..4b0821e75 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account Managed Identity', + category: 'Batch', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Batch accounts have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure and using it to obtain Azure Entra Id tokens.', + recommended_action: 'Modify Batch Account and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/troubleshoot/azure/hpc/batch/use-managed-identities-azure-batch-account-pool', + apis: ['batchAccounts:list'], + realtime_triggers: ['microsoftbatch:batchaccounts:write','microsoftbatch:batchaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb){ + + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + if (batchAccount.identity && batchAccount.identity.type) { + helpers.addResult(results, 0, 'Batch account has managed identity enabled', location, batchAccount.id); + } else { + helpers.addResult(results, 2, 'Batch account does not have managed identity enabled', location, batchAccount.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsManagedIdentity.spec.js b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.spec.js new file mode 100644 index 000000000..a3770ee08 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.spec.js @@ -0,0 +1,98 @@ + +var expect = require('chai').expect; +var batchAccountsManagedIdentity = require('./batchAccountsManagedIdentity'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "identity": { + "principalId": "6bb43e0b-f260-4a69-ba3b-853b14451327", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "type": "SystemAssigned", + } + }, + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + }, +]; + +const createCache = (batchAccounts) => { + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts + } + } + } + } +}; + +const createErrorCache = () => { + return { + batchAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('batchAccountsManagedIdentity', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Batch accounts:', function (done) { + const cache = createCache(null); + batchAccountsManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Batch account exist', function (done) { + const cache = createCache([]); + batchAccountsManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Batch account has managed identity enabled', function (done) { + const cache = createCache([batchAccounts[0]]); + batchAccountsManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Batch account does not have managed identity enabled', function (done) { + const cache = createCache([batchAccounts[1]]); + batchAccountsManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsPublicAccess.js b/plugins/azure/batchAccounts/batchAccountsPublicAccess.js new file mode 100644 index 000000000..505ed9204 --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsPublicAccess.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Batch Account Public Access', + category: 'Batch', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Batch accounts are not publicly accessible.', + more_info: 'Disabling public access for your Azure Batch Account enhances security by restricting unauthorized access to resources. This setting ensures that only trusted, internal sources can interact with Batch services, protecting data from potential external threats.', + recommended_action: 'Modify Batch account and disable public access.', + link: 'https://learn.microsoft.com/en-us/azure/batch/public-network-access', + apis: ['batchAccounts:list'], + realtime_triggers: ['microsoftbatch:batchaccounts:write','microsoftbatch:batchaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.batchAccounts, function(location, rcb){ + + var batchAccounts = helpers.addSource(cache, source, + ['batchAccounts', 'list', location]); + + if (!batchAccounts) return rcb(); + + if (batchAccounts.err || !batchAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for Batch accounts: ' + helpers.addError(batchAccounts), location); + return rcb(); + } + if (!batchAccounts.data.length) { + helpers.addResult(results, 0, 'No existing Batch accounts found', location); + return rcb(); + } + + for (let batchAccount of batchAccounts.data) { + if (!batchAccount.id) continue; + + if (batchAccount.publicNetworkAccess && + batchAccount.publicNetworkAccess.toLowerCase() === 'enabled') { + helpers.addResult(results, 2, 'Batch account is publicly accessible', location, batchAccount.id); + } else { + helpers.addResult(results, 0, 'Batch account is not publicly accessible', location, batchAccount.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsPublicAccess.spec.js b/plugins/azure/batchAccounts/batchAccountsPublicAccess.spec.js new file mode 100644 index 000000000..ca54e124f --- /dev/null +++ b/plugins/azure/batchAccounts/batchAccountsPublicAccess.spec.js @@ -0,0 +1,95 @@ + +var expect = require('chai').expect; +var batchAccountsPublicAccess = require('./batchAccountsPublicAccess'); + +const batchAccounts = [ + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "publicNetworkAccess": "Disabled" + }, + { + "id": "/subscriptions/1234566/resourceGroups/dummy/providers/Microsoft.Batch/batchAccounts/test", + "name": "test", + "type": "Microsoft.Batch/batchAccounts", + "location": "eastus", + "accountEndpoint": "test.eastus.batch.azure.com", + "nodeManagementEndpoint": "123456789.eastus.service.batch.azure.com", + "publicNetworkAccess": "Enabled" + }, +]; + +const createCache = (batchAccounts) => { + return { + batchAccounts: { + list: { + 'eastus': { + data: batchAccounts + } + } + } + } +}; + +const createErrorCache = () => { + return { + batchAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('batchAccountsPublicAccess', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Batch accounts:', function (done) { + const cache = createCache(null); + batchAccountsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Batch accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Batch account exist', function (done) { + const cache = createCache([]); + batchAccountsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Batch accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Batch account is not publicly accessible', function (done) { + const cache = createCache([batchAccounts[0]]); + batchAccountsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Batch account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Batch account is publicly accessible', function (done) { + const cache = createCache([batchAccounts[1]]); + batchAccountsPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Batch account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/blobservice/blobContainersCmkEncrypted.js b/plugins/azure/blobservice/blobContainersCmkEncrypted.js new file mode 100644 index 000000000..cb6bb746c --- /dev/null +++ b/plugins/azure/blobservice/blobContainersCmkEncrypted.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Blob Container CMK Encrypted', + category: 'Blob Service', + domain: 'Storage', + description: 'Ensures that blob containers in storage account are CMK encrypted', + severity: 'High', + more_info: 'Azure allows you to encrypt data in your blob containers using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Configuring a customer-managed key for blob services ensures protection and control access to the key that encrypts your data. Customer-managed keys offer greater flexibility to manage access controls.', + recommended_action: 'Ensure that all blob containers in storage account store has CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/customer-managed-keys-overview', + apis: ['storageAccounts:list', 'blobContainers:list', 'encryptionScopes:listByStorageAccounts'], + realtime_triggers: ['microsoftstorage:storageaccounts:blobservices:containers:write', 'microsoftstorage:storageaccounts:blobservices:containers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + async.each(locations.storageAccounts, (location, rcb) => { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location] + ); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult( + results, 0, 'No existing Storage Accounts found', location); + return rcb(); + } + + for (var storageAccount of storageAccounts.data) { + const blobContainers = helpers.addSource( + cache, source, ['blobContainers', 'list', location, storageAccount.id] + ); + + if (!blobContainers || blobContainers.err || !blobContainers.data) { + helpers.addResult(results, 3, + 'Unable to query Blob Containers: ' + helpers.addError(blobContainers), + location, storageAccount.id); + } else if (!blobContainers.data.length) { + helpers.addResult(results, 0, 'Storage Account does not contain blob containers', location, storageAccount.id); + } else { + const encryptionScopes = helpers.addSource( + cache, source, ['encryptionScopes', 'listByStorageAccounts', location, storageAccount.id]); + + if (!encryptionScopes || encryptionScopes.err || !encryptionScopes.data) { + helpers.addResult(results, 3, + 'Unable to query encryption scopes for Storage Accounts: ' + helpers.addError(encryptionScopes), + location, storageAccount.id); + break; + } else { + var cmkEncryptionScopes = encryptionScopes.data.filter(function(scope) { + return scope.keyVaultProperties && scope.keyVaultProperties.keyUri; + }).map(function(scope) { + return scope.name; + }); + blobContainers.data.forEach(function(blob) { + if (!cmkEncryptionScopes.includes(blob.defaultEncryptionScope)) { + helpers.addResult(results, 2, 'Blob container does not have CMK encryption enabled', location, blob.id); + } else { + helpers.addResult(results, 0, 'Blob container has CMK encryption enabled', location, blob.id); + } + }); + } + + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/blobservice/blobContainersCmkEncrypted.spec.js b/plugins/azure/blobservice/blobContainersCmkEncrypted.spec.js new file mode 100644 index 000000000..fc29e5fc1 --- /dev/null +++ b/plugins/azure/blobservice/blobContainersCmkEncrypted.spec.js @@ -0,0 +1,242 @@ +var expect = require('chai').expect; +var blobContainersCmkEncrypted = require('./blobContainersCmkEncrypted'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'allowBlobPublicAccess': false + } +]; + +const blobContainers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'publicAccess' : 'None', + "defaultEncryptionScope": "$account-encryption-key", + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'publicAccess' : 'Container', + "defaultEncryptionScope": "testscope", + } +]; + +const encryptionScopes = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourceGroups/akhtar-function-app_group/providers/Microsoft.Storage/storageAccounts/akhtarfunctionappgrb107/encryptionScopes/testscope", + "type": "Microsoft.Storage/storageAccounts/encryptionScopes", + "name": "testscope", + "source": "Microsoft.Keyvault", + "keyVaultProperties": { + "currentVersionedKeyIdentifier": "https://hcicluster.vault.azure.net/keys/test/108d29beff404c0694eab7cc2834f5b6", + "keyUri": "https://hcicluster.vault.azure.net/keys/test", + "creationTime": "2024-01-29T12:01:27.7050924Z", + "lastModifiedTime": "2024-01-29T12:01:27.7050924Z", + "state": "Enabled" + } + }, + {}, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourceGroups/akhtar-function-app_group/providers/Microsoft.Storage/storageAccounts/akhtarfunctionappgrb107/encryptionScopes/testscope", + "type": "Microsoft.Storage/storageAccounts/encryptionScopes", + "name": "testscope", + "source": "Microsoft.Keyvault", + "source": "Microsoft.Storage", + "creationTime": "2024-01-29T12:04:34.5251960Z", + "lastModifiedTime": "2024-01-29T12:04:34.5251960Z", + "state": "Enabled" + }, +]; +const createCache = (storageAccounts, blobContainers, encryptionScopes) => { + let conatiners = {}; + let scopes = {}; + if (storageAccounts.length) { + conatiners[storageAccounts[0].id] = { + data : blobContainers + }; + scopes[storageAccounts[0].id] = { + data : encryptionScopes + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + blobContainers: { + list: { + 'eastus': conatiners + } + }, + encryptionScopes: { + listByStorageAccounts: { + 'eastus': scopes + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccount') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key == 'unknownEncryptionScopes') { + let conatiners = {}; + let scopes = {}; + if (storageAccounts.length) { + conatiners[storageAccounts[0].id] = { + data : [blobContainers[0]] + }; + scopes[storageAccounts[0].id] = { + 'err' : 'encryptionError' + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobContainers: { + list: { + 'eastus': conatiners + } + }, + encryptionScopes: { + listByStorageAccounts: { + 'eastus': scopes + } + } + }; + } + else { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobContainers: { + list: { + 'eastus': {} + } + } + }; + } +}; + +describe('blobContainersCmkEncrypted', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([], []); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Storage Accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no blob containers', function(done) { + const cache = createCache([storageAccounts[0]], []); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account does not contain blob containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache('storageAccount'); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for blob containers', function(done) { + const cache = createErrorCache('blobContainer'); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Blob Containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknow result if unable to query encryption scopes for Storage Accounts:', function(done) { + const cache = createErrorCache('unknownEncryptionScopes'); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query encryption scopes for Storage Accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Blob container does not have CMK encryption enabled', function(done) { + const cache = createCache([storageAccounts[0]], [blobContainers[0]], [encryptionScopes[0]]); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blob container does not have CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Blob container has CMK encryption enabled', function (done) { + const cache = createCache([storageAccounts[0]], [blobContainers[1]], [encryptionScopes[0]]); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Blob container has CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should also give failing result if Blob container does not have CMK encryption enabled', function(done) { + const cache = createCache([storageAccounts[0]], [blobContainers[0]], [encryptionScopes[1]]); + blobContainersCmkEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blob container does not have CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/blobservice/blobContainersPrivateAccess.js b/plugins/azure/blobservice/blobContainersPrivateAccess.js index 16fe46023..8454c0d31 100644 --- a/plugins/azure/blobservice/blobContainersPrivateAccess.js +++ b/plugins/azure/blobservice/blobContainersPrivateAccess.js @@ -4,10 +4,12 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Blob Container Private Access', category: 'Blob Service', + domain: 'Storage', + severity: 'High', description: 'Ensures that all blob containers do not have anonymous public access set', more_info: 'Blob containers set with public access enables anonymous users to read blobs within a publicly accessible container without authentication. All blob containers should have private access configured.', recommended_action: 'Ensure each blob container is configured to restrict anonymous access', - link: 'https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction', + link: 'https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction', apis: ['storageAccounts:list', 'blobContainers:list'], compliance: { hipaa: 'Strict access controls to all data is a core requirement for HIPAA. ' + @@ -16,6 +18,12 @@ module.exports = { pci: 'PCI requires all access to be restricted and identified. Limiting public blob ' + 'access ensures compliance.' }, + remediation_min_version: '202201032200', + remediation_description: 'Access level for affected blob containers will be set to private', + apis_remediate: ['storageAccounts:list'], + actions: {remediate:['blobContainers:update'], rollback:['blobContainers:update']}, + permissions: {remediate: ['blobContainers:update'], rollback: ['blobContainers:update']}, + realtime_triggers: ['microsoftstorage:storageaccounts:blobservices:containers:write','microsoftstorage:storageaccounts:blobservices:containers:delete'], run: function(cache, settings, callback) { const results = []; @@ -42,25 +50,29 @@ module.exports = { } storageAccounts.data.forEach(function(storageAccount) { - const blobContainers = helpers.addSource( - cache, source, ['blobContainers', 'list', location, storageAccount.id] - ); - - if (!blobContainers || blobContainers.err || !blobContainers.data) { - helpers.addResult(results, 3, - 'Unable to query Blob Containers: ' + helpers.addError(blobContainers), - location, storageAccount.id); - } else if (!blobContainers.data.length) { - helpers.addResult(results, 0, 'Storage Account does not contain blob containers', location, storageAccount.id); + if (storageAccount.allowBlobPublicAccess === false) { + helpers.addResult(results, 0, 'Storage Account does not allow blob containers public access', location, storageAccount.id); } else { - blobContainers.data.forEach(function(blob){ - if (blob.publicAccess && - blob.publicAccess.toLowerCase() == 'none') { - helpers.addResult(results, 0, 'Blob container does not allow public access', location, blob.id); - } else { - helpers.addResult(results, 2, 'Blob container allows public access', location, blob.id); - } - }); + const blobContainers = helpers.addSource( + cache, source, ['blobContainers', 'list', location, storageAccount.id] + ); + + if (!blobContainers || blobContainers.err || !blobContainers.data) { + helpers.addResult(results, 3, + 'Unable to query Blob Containers: ' + helpers.addError(blobContainers), + location, storageAccount.id); + } else if (!blobContainers.data.length) { + helpers.addResult(results, 0, 'Storage Account does not contain blob containers', location, storageAccount.id); + } else { + blobContainers.data.forEach(function(blob){ + if (blob.publicAccess && + blob.publicAccess.toLowerCase() == 'none') { + helpers.addResult(results, 0, 'Blob container does not allow public access', location, blob.id); + } else { + helpers.addResult(results, 2, 'Blob container allows public access', location, blob.id); + } + }); + } } }); @@ -68,5 +80,51 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'blobContainersPrivateAccess'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2021-04-01'; + var method = 'PATCH'; + + // for logging purposes + var containerNameArr = resource.split('/'); + var containerName = containerNameArr[containerNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'properties': { + 'publicAccess': 'None' + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'PrivateAccess': 'Disabled', + 'BlobContainer': containerName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) { + console.log(err); + return callback(err); + } + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/blobservice/blobContainersPrivateAccess.spec.js b/plugins/azure/blobservice/blobContainersPrivateAccess.spec.js new file mode 100644 index 000000000..3de049031 --- /dev/null +++ b/plugins/azure/blobservice/blobContainersPrivateAccess.spec.js @@ -0,0 +1,162 @@ +var expect = require('chai').expect; +var blobPrivateAccess = require('./blobContainersPrivateAccess'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'allowBlobPublicAccess': false + } +]; + +const blobContainers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'publicAccess' : 'None' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'publicAccess' : 'Container' + } +]; + +const createCache = (storageAccounts, blobContainers) => { + let conatiners = {}; + if (storageAccounts.length) { + conatiners[storageAccounts[0].id] = { + data : blobContainers + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + blobContainers: { + list: { + 'eastus': conatiners + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccount') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobContainers: { + list: { + 'eastus': {} + } + } + }; + } +}; + +describe('blobContainerPrivateAccess', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([], []); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Storage Accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no blob containers', function(done) { + const cache = createCache([storageAccounts[0]], []); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account does not contain blob containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache('storageAccount'); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for blob containers', function(done) { + const cache = createErrorCache('blobContainer'); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Blob Containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if blob container does not allow public access', function(done) { + const cache = createCache([storageAccounts[0]], [blobContainers[0]]); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Blob container does not allow public access'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if blob container allows public access', function(done) { + const cache = createCache([storageAccounts[0]], [blobContainers[1]]); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blob container allows public access'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if blob containers public access is disabled by storage account', function (done) { + const cache = createCache([storageAccounts[1]], [blobContainers[1]]); + blobPrivateAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account does not allow blob containers public access'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/blobservice/blobServiceImmutable.js b/plugins/azure/blobservice/blobServiceImmutable.js index 845222875..7b14a7096 100644 --- a/plugins/azure/blobservice/blobServiceImmutable.js +++ b/plugins/azure/blobservice/blobServiceImmutable.js @@ -5,15 +5,18 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Blob Service Immutable', category: 'Blob Service', + domain: 'Storage', + severity: 'High', description: 'Ensures data immutability is properly configured for blob services to protect critical data against deletion', more_info: 'Immutable storage helps store data securely by protecting critical data against deletion.', recommended_action: 'Enable a data immutability policy for all storage containers in the Azure storage account.', - link: 'https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-immutable-storage#Getting-started', - apis: ['storageAccounts:list', 'blobContainers:list'], + link: 'https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-immutable-storage#Getting-started', + apis: ['storageAccounts:list', 'blobContainers:list', 'blobServices:list'], compliance: { hipaa: 'Blob immutability preserves the integrity of stored data and protects against ' + 'accidental or malicious destruction.' }, + realtime_triggers: ['microsoftstorage:storageaccounts:blobservices:containers:write','microsoftstorage:storageaccounts:blobservices:containers:delete'], run: function(cache, settings, callback) { var results = []; @@ -39,29 +42,68 @@ module.exports = { return rcb(); } - storageAccounts.data.forEach(function(storageAccount) { - const blobContainers = helpers.addSource( - cache, source, ['blobContainers', 'list', location, storageAccount.id] - ); + async.each(storageAccounts.data, function(storageAccount, cb) { + let failingBlobs = []; - if (!blobContainers || blobContainers.err || !blobContainers.data) { + const blobServices = helpers.addSource(cache, source, + ['blobServices', 'list', location, storageAccount.id]); + + if (!blobServices || blobServices.err || !blobServices.data) { helpers.addResult(results, 3, - 'Unable to query Blob Containers: ' + helpers.addError(blobContainers), + 'Unable to query Blob Services: ' + helpers.addError(blobServices), location, storageAccount.id); - } else if (!blobContainers.data.length) { - helpers.addResult(results, 0, 'Storage Account does not contain blob containers', location, storageAccount.id); + return cb(); + } else if (!blobServices.data.length) { + helpers.addResult(results, 0, 'Storage Account does not contain blob services', location, storageAccount.id); + return cb(); } else { - blobContainers.data.forEach(function(blob) { - if (blob.hasImmutabilityPolicy) { + async.each(blobServices.data, function(blob, bcb) { + if (blob.deleteRetentionPolicy && blob.deleteRetentionPolicy.enabled) { helpers.addResult(results, 0, 'Immutability has been configured for the blob service', location, blob.id); } else { - helpers.addResult(results, 2, 'Immutability has not been configured for the blob service', location, blob.id); + if (blob.id) { + let failingStorageName = blob.id.split('/'); + failingStorageName.splice(failingStorageName.length - 2, 2); + failingStorageName = failingStorageName.join('/'); + failingBlobs.push(failingStorageName); + + } } + bcb(); + }, function() { + async.each(failingBlobs, function(storageAccountId, scb) { + const blobContainers = helpers.addSource(cache, source, + ['blobContainers', 'list', location, storageAccountId]); + + if (!blobContainers || blobContainers.err || !blobContainers.data) { + helpers.addResult(results, 3, + 'Unable to query Blob Containers: ' + helpers.addError(blobContainers), + location, storageAccount.id); + return scb(); + } else if (!blobContainers.data.length) { + helpers.addResult(results, 0, 'Storage Account does not contain blob containers', location, storageAccount.id); + return scb(); + } else { + async.each(blobContainers.data, function(blobContainer, ccb) { + if (blobContainer.hasImmutabilityPolicy) { + helpers.addResult(results, 0, 'Immutability has been configured for the blob container', location, blobContainer.id); + } else { + helpers.addResult(results, 2, 'Immutability has not been configured for the blob container', location, blobContainer.id); + } + ccb(); + }, function() { + scb(); + }); + } + }, function() { + cb(); + }); }); } + }, function() { + rcb(); }); - rcb(); }, function() { callback(null, results, source); }); diff --git a/plugins/azure/blobservice/blobServiceImmutable.spec.js b/plugins/azure/blobservice/blobServiceImmutable.spec.js new file mode 100644 index 000000000..3eba0db7f --- /dev/null +++ b/plugins/azure/blobservice/blobServiceImmutable.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +var blobServiceImmutable = require('./blobServiceImmutable'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + } +]; + +const blobContainers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'hasImmutabilityPolicy' : true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/container1', + 'location': 'eastus', + 'name': 'container1', + 'hasImmutabilityPolicy' : false + } +]; + +const blobServices = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default', + 'location': 'eastus', + 'name': 'default' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default', + 'location': 'eastus', + 'name': 'default', + 'deleteRetentionPolicy': { + 'enabled': true, + 'days': 30 + } + } +]; + +const createCache = (storageAccounts, blobServices, blobContainers) => { + let conatiners = {}; + let services = {}; + if (storageAccounts.length) { + conatiners[storageAccounts[0].id] = { + data : blobContainers + }; + services[storageAccounts[0].id] = { + data : blobServices + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + blobContainers: { + list: { + 'eastus': conatiners + } + }, + blobServices: { + list:{ + 'eastus': services + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccount') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key == 'blobService') { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobServices: { + list:{ + 'eastus': {} + } + } + }; + } else { + let services = {}; + services[storageAccounts[0].id] = { + data : [blobServices[0]] + }; + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobServices: { + list:{ + 'eastus': services + } + }, + blobContainers: { + list: { + 'eastus': {} + } + } + }; + } +}; + +describe('blobServiceImmutable', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([], [], []); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Storage Accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no blob services', function(done) { + const cache = createCache([storageAccounts[0]], [], []); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account does not contain blob services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no blob containers', function(done) { + const cache = createCache([storageAccounts[0]], [blobServices[0]], []); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account does not contain blob containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache('storageAccount'); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query blob services', function(done) { + const cache = createErrorCache('blobService'); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Blob Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query blob containers', function(done) { + const cache = createErrorCache('blobContainer'); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Blob Containers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if immutability is configured for blob service', function(done) { + const cache = createCache([storageAccounts[0]], [blobServices[1]], []); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Immutability has been configured for the blob service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if immutability is configured for blob container', function(done) { + const cache = createCache([storageAccounts[0]], [blobServices[0]], [blobContainers[0]]); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Immutability has been configured for the blob container'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if immutability is not configuredfor blob container', function(done) { + const cache = createCache([storageAccounts[0]], [blobServices[0]], [blobContainers[1]]); + blobServiceImmutable.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Immutability has not been configured for the blob container'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js b/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js index b70f68ac2..4a915aaa8 100644 --- a/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js +++ b/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Detect Insecure Custom Origin', category: 'CDN Profiles', + domain: 'Content Delivery', + severity: '', description: 'Ensures that HTTPS is enabled for CDN endpoints with a custom origin', more_info: 'All Azure CDN endpoints should enable HTTPS to secure traffic to the backend custom origin.', recommended_action: 'Enable HTTPS and disable HTTP for each custom origin endpoint for each CDN profile.', - link: 'https://docs.microsoft.com/en-us/azure/cdn/cdn-create-endpoint-how-to', + link: 'https://learn.microsoft.com/en-us/azure/cdn/cdn-create-endpoint-how-to', apis: ['profiles:list', 'endpoints:listByProfile'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + @@ -17,6 +19,7 @@ module.exports = { 'Secure CDN origins should be used to ensure traffic between ' + 'the Azure CDN and backend service is encrypted.' }, + realtime_triggers: ['microsoftcdn:profiles:write','microsoftcdn:profiles:delete','microsoftcdn:profiles:endpoints:write','microsoftcdn:profiles:endpoints:delete'], run: function(cache, settings, callback) { const results = []; @@ -46,7 +49,7 @@ module.exports = { if (!endpoints || endpoints.err || !endpoints.data) { helpers.addResult(results, 3, - 'Unable to query for CDN Profile endpoints: ' + helpers.addError(endpoints), profile.id); + 'Unable to query for CDN Profile endpoints: ' + helpers.addError(endpoints), location, profile.id); } else { if (!endpoints.data.length) { helpers.addResult(results, 0, diff --git a/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.spec.js b/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.spec.js new file mode 100644 index 000000000..04f450fde --- /dev/null +++ b/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.spec.js @@ -0,0 +1,174 @@ +var expect = require('chai').expect; +var detectInsecureCustomOrigin = require('./detectInsecureCustomOrigin'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "cdn", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + +const endpoints = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile/endpoints/test-end", + "type": "Microsoft.Cdn/profiles/endpoints", + "name": "test-end", + "location": "Global", + "tags": {}, + "hostName": "test-end.azureedge.net", + "originHostHeader": "akhtar-test.azurewebsites.net", + "originPath": null, + "isCompressionEnabled": true, + "isHttpAllowed": true, + "isHttpsAllowed": true, + "queryStringCachingBehavior": "IgnoreQueryString" + + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile/endpoints/test-end", + "type": "Microsoft.Cdn/profiles/endpoints", + "name": "test-end", + "location": "Global", + "tags": {}, + "hostName": "test-end.azureedge.net", + "originHostHeader": "akhtar-test.azurewebsites.net", + "originPath": null, + "isCompressionEnabled": true, + "isHttpAllowed": false, + "isHttpsAllowed": true, + "queryStringCachingBehavior": "IgnoreQueryString" + + }, + +]; + +const createCache = (profiles, endpoints) => { + let containers = {}; + if (profiles.length) { + containers[profiles[0].id] = { + data : endpoints + }; + } + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + endpoints: { + listByProfile: { + 'global': containers + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + endpoints: { + listByProfile: { + 'global': {} + } + } + }; + } +}; + +describe('detectInsecureCustomOrigin', function() { + describe('run', function() { + it('should give passing result if No existing CDN Profiles found', function(done) { + const cache = createCache([], []); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing CDN Profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if CDN profile does not contain any endpoints', function(done) { + const cache = createCache([profiles[0]], []); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CDN profile does not contain any endpoints'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for CDN Profiles', function(done) { + const cache = createErrorCache('profile'); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CDN Profiles'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for CDN Profile endpoints', function(done) { + const cache = createErrorCache('blobContainer'); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for CDN Profile endpoints'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if CDN profile endpoint does not allow insecure HTTP origin', function(done) { + const cache = createCache([profiles[0]], [endpoints[1]]); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CDN profile endpoint does not allow insecure HTTP origin'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if CDN profile endpoint allows insecure HTTP origin', function(done) { + const cache = createCache([profiles[0]], [endpoints[0]]); + detectInsecureCustomOrigin.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CDN profile endpoint allows insecure HTTP origin'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/cdnprofiles/endpointLoggingEnabled.js b/plugins/azure/cdnprofiles/endpointLoggingEnabled.js index 5a89d59c5..d4d25671d 100644 --- a/plugins/azure/cdnprofiles/endpointLoggingEnabled.js +++ b/plugins/azure/cdnprofiles/endpointLoggingEnabled.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Endpoint Logging Enabled', category: 'CDN Profiles', + domain: 'Content Delivery', + severity: '', description: 'Ensures that endpoint requests are being logged for CDN endpoints', more_info: 'Endpoint Logging ensures that all requests to a CDN endpoint are logged.', recommended_action: 'Ensure that diagnostic logging is enabled for each CDN endpoint for each CDN profile', - link: 'https://docs.microsoft.com/en-us/azure/cdn/cdn-azure-diagnostic-logs', + link: 'https://learn.microsoft.com/en-us/azure/cdn/cdn-azure-diagnostic-logs', apis: ['profiles:list', 'endpoints:listByProfile', 'diagnosticSettings:listByEndpoint'], + realtime_triggers: ['microsoftcdn:profiles:write','microsoftcdn:profiles:delete','microsoftcdn:profiles:endpoints:write','microsoftcdn:profiles:endpoints:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/cdnprofiles/endpointLoggingEnabled.spec.js b/plugins/azure/cdnprofiles/endpointLoggingEnabled.spec.js new file mode 100644 index 000000000..078d660cb --- /dev/null +++ b/plugins/azure/cdnprofiles/endpointLoggingEnabled.spec.js @@ -0,0 +1,257 @@ +var expect = require('chai').expect; +var endpointLoggingEnabled = require('./endpointLoggingEnabled'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "cdn", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + +const endpoints = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile/endpoints/test-end", + "type": "Microsoft.Cdn/profiles/endpoints", + "name": "test-end", + "location": "Global", + "tags": {}, + "hostName": "test-end.azureedge.net", + "originHostHeader": "akhtar-test.azurewebsites.net", + "originPath": null, + "isCompressionEnabled": true, + "isHttpAllowed": true, + "isHttpsAllowed": true, + "queryStringCachingBehavior": "IgnoreQueryString" + + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/microsoft.cdn/profiles/test-profile/endpoints/test-end/providers/microsoft.insights/diagnosticSettings/test-diagnostic", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "test-diagnostic", + "location": null, + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/234/resourceGroups/akhtar-rg/providers/Microsoft.Storage/storageAccounts/akhtarrgdiag", + "serviceBusRuleId": null, + "workspaceId": null, + "eventHubAuthorizationRuleId": null, + "eventHubName": null, + "metrics": [], + "logs": [ + { + "category": "CoreAnalytics", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": true, + "days": 2 + } + } + ], + "logAnalyticsDestinationType": null + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/microsoft.cdn/profiles/test-profile/endpoints/test-end/providers/microsoft.insights/diagnosticSettings/test-diagnostic", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "test-diagnostic2", + "location": null, + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/234/resourceGroups/akhtar-rg/providers/Microsoft.Storage/storageAccounts/akhtarrgdiag", + "serviceBusRuleId": null, + "workspaceId": null, + "eventHubAuthorizationRuleId": null, + "eventHubName": null, + "metrics": [], + "logs": [], + "logAnalyticsDestinationType": null + } +] + +const createCache = (profiles, endpoints, diagnostics) => { + let containers = {}; + if (profiles.length) { + containers[profiles[0].id] = { + data : endpoints + }; + } + + let diagnostic = {}; + if (endpoints.length) { + diagnostic[endpoints[0].id] = { + data: diagnostics + }; + } + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + endpoints: { + listByProfile: { + 'global': containers + } + }, + diagnosticSettings:{ + listByEndpoint: { + 'global': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'container') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + endpoints: { + listByProfile: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[0].id : null; + const endpointId = (endpoints && endpoints.length) ? endpoints[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + endpoints: { + listByProfile: { + 'global': { + [profileId]: { + data: [endpoints[0]] + } + } + }, + }, + diagnosticSettings: { + listByEndpoint: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('endpointLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if No existing CDN profiles found', function(done) { + const cache = createCache([], [], []); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing CDN profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if No existing CDN endpoints found', function(done) { + const cache = createCache([profiles[0]], [], []); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing CDN endpoints found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query CDN profiles', function(done) { + const cache = createErrorCache('profile'); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CDN profiles:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query CDN endpoints', function(done) { + const cache = createErrorCache('container'); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query CDN endpoints'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function(done) { + const cache = createErrorCache('policy'); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query diagnostics settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if Request logging is enabled for endpoint', function(done) { + const cache = createCache([profiles[0]], [endpoints[0]], [diagnosticSettings[0]]); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Request logging is enabled for endpoint'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Request logging is not enabled for endpoint', function(done) { + const cache = createCache([profiles[0]], [endpoints[0]], [diagnosticSettings[1]]); + endpointLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Request logging is not enabled for endpoint'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppAuthEnabled.js b/plugins/azure/containerapps/containerAppAuthEnabled.js new file mode 100644 index 000000000..d44384158 --- /dev/null +++ b/plugins/azure/containerapps/containerAppAuthEnabled.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps Authentication Enabled', + category: 'Container Apps', + domain: 'Containers', + severity: 'Low', + description: 'Ensures that built-in authentication is enabled for Container Apps.', + more_info: 'Enabling built-in authentication for Container Apps enhances security by preventing unauthorized access, ensuring that only authenticated users can interact with the app by providing authentication with federated identity providers.', + recommended_action: 'Modify Container Apps and enable built-in authentication feature.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/authentication', + apis: ['containerApps:list', 'containerApps:getAuthSettings'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:authconfigs:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + + var authConfig = helpers.addSource(cache, source, + ['containerApps', 'getAuthSettings', location, container.id]); + + if (!authConfig || authConfig.err || !authConfig.data) { + helpers.addResult(results, 3, `Unable to query for Container app authentication settings: ${helpers.addError(authConfig)}`, + location, container.id); + continue; + } + + if (authConfig.data[0] && authConfig.data[0].platform && authConfig.data[0].platform.enabled) { + helpers.addResult(results, 0, + 'Container app has built-in authentication enabled', location, container.id); + } else { + helpers.addResult(results, 2, + 'Container app does not have built-in authentication enabled', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppAuthEnabled.spec.js b/plugins/azure/containerapps/containerAppAuthEnabled.spec.js new file mode 100644 index 000000000..6b152c354 --- /dev/null +++ b/plugins/azure/containerapps/containerAppAuthEnabled.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var containerAppAuthenticationEnabled = require('./containerAppAuthEnabled'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "None" + } + }, +]; + +const authConfig = [ + [ { + "id": "/subscriptions/1234567/resourceGroups/test/providers/Microsoft.App/containerApps/test/authConfigs/current", + "name": "current", + "type": "Microsoft.App/containerapps/authconfigs", + "platform": { + "enabled": true + }, + "globalValidation": { + "unauthenticatedClientAction": "AllowAnonymous" + }, + "identityProviders": { + "customOpenIdConnectProviders": {} + }, + "login": { + "routes": {}, + "preserveUrlFragmentsForLogins": false, + "allowedExternalRedirectUrls": [], + "cookieExpiration": {}, + "nonce": {} + } + + }, +], + + [ + { + + "id": "/subscriptions/1234567/resourceGroups/test/providers/Microsoft.App/containerApps/test/authConfigs/current", + "name": "current", + "type": "Microsoft.App/containerapps/authconfigs", + "platform": { + "enabled": false + }, + "globalValidation": { + "unauthenticatedClientAction": "AllowAnonymous" + }, + "identityProviders": { + "customOpenIdConnectProviders": {} + }, + "login": { + "routes": {}, + "preserveUrlFragmentsForLogins": false, + "allowedExternalRedirectUrls": [], + "cookieExpiration": {}, + "nonce": {} + } + } + + ] + +] + + + + +const createCache = (container, authConfig) => { + const id = (container && container.length) ? container[0].id : null; + return { + containerApps: { + list: { + 'eastus': { + data: container + } + }, + getAuthSettings: { + 'eastus': { + [id]: { + data: authConfig + } + } + } + } + }; +}; + + +describe('containerAppAuthenticationEnabled', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([], null); + containerAppAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createCache(null, null); + containerAppAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app has authentication enabled', function(done) { + const cache = createCache([containerApps[0]], authConfig[0]); + containerAppAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has built-in authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app does not have authentication enabled', function(done) { + const cache = createCache([containerApps[1]], authConfig[1]); + containerAppAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have built-in authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppHasTags.js b/plugins/azure/containerapps/containerAppHasTags.js new file mode 100644 index 000000000..6900fb7d5 --- /dev/null +++ b/plugins/azure/containerapps/containerAppHasTags.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps Has Tags', + category: 'Container Apps', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that Container Apps have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Container App and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + + if (container.tags && Object.entries(container.tags).length > 0){ + helpers.addResult(results, 0, 'Container app has tags associated', location, container.id); + } else { + helpers.addResult(results, 2, 'Container app does not have tags associated', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppHasTags.spec.js b/plugins/azure/containerapps/containerAppHasTags.spec.js new file mode 100644 index 000000000..14061bd4f --- /dev/null +++ b/plugins/azure/containerapps/containerAppHasTags.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var containerAppHasTags = require('./containerAppHasTags'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "SystemAssigned" + }, + "tags": { 'key': 'value' }, + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "None" + }, + "tags": { }, + }, +]; + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('containerAppHasTags', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + containerAppHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + containerAppHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app has tags', function(done) { + const cache = createCache([containerApps[0]]); + containerAppHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app does not have tags', function(done) { + const cache = createCache([containerApps[1]]); + containerAppHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppHttpsOnly.js b/plugins/azure/containerapps/containerAppHttpsOnly.js new file mode 100644 index 000000000..f06fc83a6 --- /dev/null +++ b/plugins/azure/containerapps/containerAppHttpsOnly.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps HTTPS only', + category: 'Container Apps', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures that Container Apps are only accessible over HTTPS.', + more_info: 'Enabling ingress feature for container app redirects the non-secure HTTP requests to HTTPS ensuring that container apps are securely accessed. This allows server authentication and protects data in transit from potential security threats.', + recommended_action: 'Enable HTTPS only by disabling allowInsecure feature for all Container apps.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/ingress-overview', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + if (container.configuration && container.configuration.ingress && container.configuration.ingress.allowInsecure) { + helpers.addResult(results, 2, + 'Container app is not configured with HTTPS only traffic', location, container.id); + + } else { + helpers.addResult(results, 0, + 'Container app is configured with HTTPS only traffic', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/containerapps/containerAppHttpsOnly.spec.js b/plugins/azure/containerapps/containerAppHttpsOnly.spec.js new file mode 100644 index 000000000..9a4ca01a6 --- /dev/null +++ b/plugins/azure/containerapps/containerAppHttpsOnly.spec.js @@ -0,0 +1,142 @@ +var expect = require('chai').expect; +var containerAppHttpsOnly = require('./containerAppHttpsOnly'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": true, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": false, + "ipSecurityRestrictions": null, + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": true, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": true, + "ipSecurityRestrictions": null, + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "None" + } + }, +]; + + + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('containerAppMcontainerAppHttpsOnlyanagedIdentity', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + containerAppHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + containerAppHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app is only accessible over HTTPS', function(done) { + const cache = createCache([containerApps[0]]); + containerAppHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app is configured with HTTPS only traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app is not only accessible over HTTPS', function(done) { + const cache = createCache([containerApps[1]]); + containerAppHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app is not configured with HTTPS only traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); diff --git a/plugins/azure/containerapps/containerAppIPRestriction.js b/plugins/azure/containerapps/containerAppIPRestriction.js new file mode 100644 index 000000000..0a1f822e7 --- /dev/null +++ b/plugins/azure/containerapps/containerAppIPRestriction.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps IP Restriction Configured', + category: 'Container Apps', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures that Container Apps are configured to allow only specific IP addresses.', + more_info: 'Azure Container Apps provides IP ingress restrictions for controlling inbound traffic, enhancing application security. Allow or deny rules can be defined for specific IP ranges, enabling precise access management to container apps. This feature is crucial for reducing potential security vulnerabilities, as unrestricted configurations permit all inbound traffic by default.', + recommended_action: 'Modify Container Apps and configure IP restriction.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/ip-restrictions', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + + if (container.configuration && + container.configuration.ingress && + container.configuration.ingress.ipSecurityRestrictions && + container.configuration.ingress.ipSecurityRestrictions.length) { + helpers.addResult(results, 0, + 'Container app has IP restrictions configured', location, container.id); + + } else { + helpers.addResult(results, 2, + 'Container app does not have IP restrictions configured', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/containerapps/containerAppIPRestriction.spec.js b/plugins/azure/containerapps/containerAppIPRestriction.spec.js new file mode 100644 index 000000000..3dbccd08f --- /dev/null +++ b/plugins/azure/containerapps/containerAppIPRestriction.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var containerAppIPRestriction = require('./containerAppIPRestriction'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": true, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": false, + "ipSecurityRestrictions": [ + { + "action":'Allow', + "description":'dummy', + "ipAddressRange": '00000', + "name": 'test' + } + ], + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": true, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": true, + "ipSecurityRestrictions": null, + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "None" + } + }, +]; + + + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('containerAppIPRestriction', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + containerAppIPRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + containerAppIPRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app has IP restrictions configured', function(done) { + const cache = createCache([containerApps[0]]); + containerAppIPRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has IP restrictions configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app does not have IP restrictions configured', function(done) { + const cache = createCache([containerApps[1]]); + containerAppIPRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have IP restrictions configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); diff --git a/plugins/azure/containerapps/containerAppManagedIdentity.js b/plugins/azure/containerapps/containerAppManagedIdentity.js new file mode 100644 index 000000000..978ecbd01 --- /dev/null +++ b/plugins/azure/containerapps/containerAppManagedIdentity.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps Managed Identity', + category: 'Container Apps', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Azure Container Apps has managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Container apps and add managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/managed-identity', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + + if (container.identity && container.identity.type && + (container.identity.type.toLowerCase() === 'systemassigned' || container.identity.type.toLowerCase() === 'userassigned')) { + helpers.addResult(results, 0, + 'Container app has managed identity enabled', location, container.id); + } else { + helpers.addResult(results, 2, + 'Container app does not have managed identity enabled', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppManagedIdentity.spec.js b/plugins/azure/containerapps/containerAppManagedIdentity.spec.js new file mode 100644 index 000000000..6c37b514f --- /dev/null +++ b/plugins/azure/containerapps/containerAppManagedIdentity.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var containerAppManagedIdentity = require('./containerAppManagedIdentity'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "None" + } + }, +]; + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('containerAppManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + containerAppManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + containerAppManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app has managed identity enabled', function(done) { + const cache = createCache([containerApps[0]]); + containerAppManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app does not have managed identity enabled', function(done) { + const cache = createCache([containerApps[1]]); + containerAppManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppVolumeMount.js b/plugins/azure/containerapps/containerAppVolumeMount.js new file mode 100644 index 000000000..967cdc231 --- /dev/null +++ b/plugins/azure/containerapps/containerAppVolumeMount.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps Volume Mount Configured', + category: 'Container Apps', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Container Apps are configured to use volume mounts.', + more_info: 'Adding volume mounts in Azure Container Apps ensures persistent storage, enabling data integrity and seamless sharing among containers. By configuring volume mounts, data remains available even after container restarts or in case of failures, facilitating backup, scalability, and simplified management of applications.', + recommended_action: 'Modify Container apps and configure volume mount.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/storage-mounts', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + + if (container.template && container.template.volumes && + container.template.volumes.length ) { + helpers.addResult(results, 0, + 'Container app has volume mount configured', location, container.id); + } else { + helpers.addResult(results, 2, + 'Container app does not have volume mount configured', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/containerapps/containerAppVolumeMount.spec.js b/plugins/azure/containerapps/containerAppVolumeMount.spec.js new file mode 100644 index 000000000..e91636133 --- /dev/null +++ b/plugins/azure/containerapps/containerAppVolumeMount.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var containerAppVolumeMount = require('./containerAppVolumeMount'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "SystemAssigned" + }, + "template": { + "volumes": [{ + "name": "test", + "storageType": "Secret" + }] + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "identity": { + "type": "None" + }, + "template": { + "volumes": [] + } + }, +]; + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('containerAppVolumeMount', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + containerAppVolumeMount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + containerAppVolumeMount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Container app has volume mount configured', function(done) { + const cache = createCache([containerApps[0]]); + containerAppVolumeMount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has volume mount configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Container app does not have volume mount configured', function(done) { + const cache = createCache([containerApps[1]]); + containerAppVolumeMount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have volume mount configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerapps/externalNetworkAccess.js b/plugins/azure/containerapps/externalNetworkAccess.js new file mode 100644 index 000000000..ae131284d --- /dev/null +++ b/plugins/azure/containerapps/externalNetworkAccess.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Container Apps External Network Access', + category: 'Container Apps', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Container Apps have external network access disabled.', + more_info: 'Disabling external network access for Container Apps ensures that inbound communication is restricted to callers within the apps environment, enhancing security by minimizing exposure to external threats. This helps safeguard sensitive data and prevents unauthorized access to the app resources.', + recommended_action: 'Modify all Container Apps and disable external network access.', + link: 'https://learn.microsoft.com/en-us/azure/container-apps/ingress-overview', + apis: ['containerApps:list'], + realtime_triggers: ['microsoftapp:containerapps:write', 'microsoftapp:containerapps:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.containerApps, function(location, rcb) { + + var containerApps = helpers.addSource(cache, source, + ['containerApps', 'list', location]); + + if (!containerApps) return rcb(); + + if (containerApps.err || !containerApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Container apps: ' + helpers.addError(containerApps), location); + return rcb(); + } + + if (!containerApps.data.length) { + helpers.addResult(results, 0, 'No existing Container apps found', location); + return rcb(); + } + + for (let container of containerApps.data) { + if (!container.id) continue; + + if (container.configuration && container.configuration.ingress && container.configuration.ingress.external) { + helpers.addResult(results, 2, + 'Container app does not have external network access disabled', location, container.id); + + } else { + helpers.addResult(results, 0, + 'Container app has external network access disabled', location, container.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerapps/externalNetworkAccess.spec.js b/plugins/azure/containerapps/externalNetworkAccess.spec.js new file mode 100644 index 000000000..9bc740400 --- /dev/null +++ b/plugins/azure/containerapps/externalNetworkAccess.spec.js @@ -0,0 +1,140 @@ +var expect = require('chai').expect; +var externalNetworkAccess = require('./externalNetworkAccess'); + +const containerApps = [ + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test1", + "name": "test1", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": false, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": false, + "ipSecurityRestrictions": null, + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/123456/resourceGroups/tesr/providers/Microsoft.App/containerapps/test2", + "name": "test2", + "type": "Microsoft.App/containerApps", + "configuration": { + "ingress": { + "fqdn": "testfatima.wittysea-8163cba4.australiaeast.azurecontainerapps.io", + "external": true, + "targetPort": 300, + "exposedPort": 0, + "transport": "Auto", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ], + "customDomains": null, + "allowInsecure": false, + "ipSecurityRestrictions": null, + "corsPolicy": null, + "clientCertificateMode": "Ignore", + "stickySessions": { + "affinity": "none" + } + }, + }, + "identity": { + "type": "None" + } + }, +]; + +const createCache = (container) => { + return { + containerApps: { + list: { + 'eastus': { + data: container + } + } + } + }; +}; + +const createErrorCache = () => { + return { + containerApps: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('externalNetworkAccess', function() { + describe('run', function() { + it('should give passing result if no container apps', function(done) { + const cache = createCache([]); + externalNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container apps found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for container apps', function(done) { + const cache = createErrorCache(); + externalNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container apps: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if container app has external network access disabled', function(done) { + const cache = createCache([containerApps[0]]); + externalNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container app has external network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container app does not have external network access disabled', function(done) { + const cache = createCache([containerApps[1]]); + externalNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container app does not have external network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrAdminUser.js b/plugins/azure/containerregistry/acrAdminUser.js index dcf1cb30c..f8fa503b5 100644 --- a/plugins/azure/containerregistry/acrAdminUser.js +++ b/plugins/azure/containerregistry/acrAdminUser.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'ACR Admin User', category: 'Container Registry', + domain: 'Containers', + severity: 'Medium', description: 'Ensures that the admin user is not enabled on container registries', more_info: 'Azure Container Registries have an admin user that is designed for testing. This should be disabled by default to avoid sharing confidential admin credentials.', recommended_action: 'Ensure that the admin user is disabled for each container registry.', - link: 'https://docs.microsoft.com/en-us/azure/container-registry/container-registry-authentication', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication', apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.js b/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.js new file mode 100644 index 000000000..e3eade7fd --- /dev/null +++ b/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR Anonymous Pull Access Enabled', + category: 'Container Registry', + domain: 'Containers', + severity: 'High', + description: 'Ensure that anonymous pull access is not enabled for Azure container registries.', + more_info: 'Anonymous pull access makes all registry content publicly available for read actions which can cause security risks and lead to unauthorized access to registry.', + recommended_action: 'Modify container registry and disable anonymous pull access.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/anonymous-pull-access', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, function(location, rcb) { + var registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing container registries found', location); + return rcb(); + } + + for (let registry of registries.data){ + if (!registry.id) continue; + + if (registry.anonymousPullEnabled) { + helpers.addResult(results, 2, 'Anonymous pull access is enabled for the container registry', location, registry.id); + } else { + helpers.addResult(results, 0, 'Anonymous pull access is not enabled for the container registry', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.spec.js b/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.spec.js new file mode 100644 index 000000000..141229b4d --- /dev/null +++ b/plugins/azure/containerregistry/acrAnonymousPullAccessEnabled.spec.js @@ -0,0 +1,106 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var acrAnonymousPullAccessEnabled = require('./acrAnonymousPullAccessEnabled'); + +registries = [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "anonymousPullEnabled": true, + "publicNetworkAccess": "Enabled" + }, + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "anonymousPullEnabled": false, + "publicNetworkAccess": "Disabled" + } + +]; +const createCache = (err, data) => { + return { + registries: { + list: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('acrAnonymousPullAccessEnabled', function() { + describe('run', function() { + it('should give passing result if no container registries', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing container registries found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null, []); + acrAnonymousPullAccessEnabled.run(cache, {}, callback); + }); + + it('should give failing result if anonymous pull access is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Anonymous pull access is enabled for the container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[registries[0]]); + acrAnonymousPullAccessEnabled.run(cache, {}, callback); + }); + + it('should give passing result if anonymous pull access is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Anonymous pull access is not enabled for the container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null, [registries[1]]); + acrAnonymousPullAccessEnabled.run(cache, {}, callback); + }) + it('should give passing result unable to query container registry', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for container registries:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null, null); + acrAnonymousPullAccessEnabled.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrCMKEncryption.js b/plugins/azure/containerregistry/acrCMKEncryption.js new file mode 100644 index 000000000..934c288ee --- /dev/null +++ b/plugins/azure/containerregistry/acrCMKEncryption.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR CMK Encryption', + category: 'Container Registry', + domain: 'Containers', + severity: 'High', + description: 'Ensure that Microsoft Azure Container registries have CMK encryption enabled.', + more_info: 'A customer-managed key gives you the ownership to bring your own key in Azure Key Vault. When you enable a customer-managed key, you can manage its rotations, control the access and permissions to use it, and audit its use.', + recommended_action: 'Create new container registry with Premium SKU and enable CMK encryption.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/tutorial-customer-managed-keys', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, (location, rcb) => { + var containerRegistries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!containerRegistries) return rcb(); + + if (containerRegistries.err || !containerRegistries.data) { + helpers.addResult(results, 3, 'Unable to query for Container registries: ' + helpers.addError(containerRegistries), location); + return rcb(); + } + + if (!containerRegistries.data.length) { + helpers.addResult(results, 0, 'No existing Container registries found', location); + return rcb(); + } + + for (let registry of containerRegistries.data) { + if (!registry.id) continue; + + if (registry.encryption && registry.encryption.status && registry.encryption.status.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, 'Container Registry has CMK encryption enabled', location, registry.id); + } else { + helpers.addResult(results, 2, 'Container Registry does not have CMK encryption enabled', location, registry.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrCMKEncryption.spec.js b/plugins/azure/containerregistry/acrCMKEncryption.spec.js new file mode 100644 index 000000000..4f9be06a0 --- /dev/null +++ b/plugins/azure/containerregistry/acrCMKEncryption.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var acrCMKEncryption = require('./acrCMKEncryption'); + +const acr = [ + { + "name": 'test-gateway', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providersMicrosoft.ContainerRegistry/registries/giotestacr", + "type": "Microsoft.ContainerRegistry/registries", + "tags": { "key": "value" }, + "location": "eastus", + "encryption": { "status": "enabled" }, + }, + { + "name": 'test-gateway', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providersMicrosoft.ContainerRegistry/registries/giotestacr", + "type": "Microsoft.ContainerRegistry/registries", + "tags": {}, + "location": "eastus", + "encryption": { "status": "disabled" }, + } +]; + +const createCache = (cr) => { + return { + registries: { + list: { + 'eastus': { + data: cr + } + } + } + }; +}; + +const createErrorCache = () => { + return { + registries: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('acrCMKEncryption', function() { + describe('run', function() { + it('should give passing result if no container registery found', function(done) { + const cache = createCache([]); + acrCMKEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container registries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container registry does not have cmk encryption enabled', function(done) { + const cache = createCache([acr[1]]); + acrCMKEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container Registry does not have CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for container registery', function(done) { + const cache = createErrorCache(); + acrCMKEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container registries:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if container registry have cmk encryption enabled', function(done) { + const cache = createCache([acr[0]]); + acrCMKEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container Registry has CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrContentTrustEnabled.js b/plugins/azure/containerregistry/acrContentTrustEnabled.js new file mode 100644 index 000000000..266edc83a --- /dev/null +++ b/plugins/azure/containerregistry/acrContentTrustEnabled.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'ACR Content Trust Enabled', + category: 'Container Registry', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that content trust is enabled for Azure premium container registries.', + more_info: 'Content trust allows you to sign the images you push to your registry. Consumers of your images (people or systems pulling images from your registry) can configure their clients to pull only signed images which enhances container image security by ensuring the integrity and authenticity of images and safeguards against unauthorized or tampered content.', + recommended_action: 'Modify your container registry and enable content trust.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/container-registry-content-trust#enable-registry-content-trust', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, function(location, rcb) { + + var registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing container registries found', location); + return rcb(); + } + + for (let registry of registries.data) { + if (!registry.id) continue; + + if (registry.sku && registry.sku.tier && registry.sku.tier.toLowerCase() !='premium') { + helpers.addResult(results, 0, 'Content trust is feature of Premium tier container registry', location, registry.id); + } else { + + var trustPolicy = registry.policies && registry.policies.trustPolicy? registry.policies.trustPolicy : null; + + if (trustPolicy && trustPolicy.status && trustPolicy.status.toLowerCase() == 'enabled'){ + helpers.addResult(results, 0, 'Content trust is enabled for container registry', location, registry.id); + } else { + helpers.addResult(results, 2, 'Content trust is not enabled for container registry', location, registry.id); + } + } + } + + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrContentTrustEnabled.spec.js b/plugins/azure/containerregistry/acrContentTrustEnabled.spec.js new file mode 100644 index 000000000..4e89590ad --- /dev/null +++ b/plugins/azure/containerregistry/acrContentTrustEnabled.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var acrContentTrustEnabled = require('./acrContentTrustEnabled'); + +registries = [ + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": true, + "sku": { + "name": "Premium", + "tier": "Premium" + }, + "policies": { + "quarantinePolicy": { + "status": "disabled" + }, + "trustPolicy": { + "type": "Notary", + "status": "disabled" + }, + } + }, + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": false, + "sku": { + "name": "Premium", + "tier": "Premium" + }, + "policies": { + "quarantinePolicy": { + "status": "disabled" + }, + "trustPolicy": { + "type": "Notary", + "status": "enabled" + }, + } + }, + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": false, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "policies": { + "quarantinePolicy": { + "status": "disabled" + }, + "trustPolicy": { + "type": "Notary", + "status": "enabled" + }, + } + } + +]; +const createCache = (err, data) => { + return { + registries: { + list: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('acrContentTrustEnabled', function() { + describe('run', function() { + it('should give passing result if no container registries', function(done) { + const cache = createCache(null, []); + acrContentTrustEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing container registries found'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give failing result if content trsut is not enabled for container registry', function(done) { + const cache = createCache(null,[registries[0]]); + acrContentTrustEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Content trust is not enabled for container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }); + + }); + + it('should give passing result if content trsut is enabled for container registry', function(done) { + const cache = createCache(null, [registries[1]]); + acrContentTrustEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Content trust is enabled for container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give passing result unable to query container registry', function(done) { + const cache = createCache(null, null); + acrContentTrustEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for container registries:'); + expect(results[0].region).to.equal('eastus'); + done() + }); + + }); + + it('should give passing result if registry is non premium', function(done) { + const cache = createCache(null, [registries[2]]); + acrContentTrustEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Content trust is feature of Premium tier container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }); + + }); + + }) +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrHasTags.js b/plugins/azure/containerregistry/acrHasTags.js new file mode 100644 index 000000000..4b66b542f --- /dev/null +++ b/plugins/azure/containerregistry/acrHasTags.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR Has Tags', + category: 'Container Registry', + domain: 'Containers', + severity: 'Low', + description: 'Ensure that Microsoft Azure Container registries have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Container registries and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, (location, rcb) => { + var conatinerRegisteries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!conatinerRegisteries) return rcb(); + + if (conatinerRegisteries.err || !conatinerRegisteries.data) { + helpers.addResult(results, 3, 'Unable to query for Container registries: ' + helpers.addError(conatinerRegisteries), location); + return rcb(); + } + + if (!conatinerRegisteries.data.length) { + helpers.addResult(results, 0, 'No existing Container registries found', location); + return rcb(); + } + + for (let registry of conatinerRegisteries.data) { + if (!registry.id) continue; + + if (registry.tags && Object.entries(registry.tags).length > 0){ + helpers.addResult(results, 0, 'Conatiner Registry has tags associated', location, registry.id); + } else { + helpers.addResult(results, 2, 'Conatiner Registry does not have tags associated', location, registry.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrHasTags.spec.js b/plugins/azure/containerregistry/acrHasTags.spec.js new file mode 100644 index 000000000..fd30fe06e --- /dev/null +++ b/plugins/azure/containerregistry/acrHasTags.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var acrHasTags = require('./acrHasTags'); + +const acr = [ + { + "name": 'test-gateway', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providersMicrosoft.ContainerRegistry/registries/giotestacr", + "type": "Microsoft.ContainerRegistry/registries", + "tags": { "key": "value" }, + "location": "eastus", + }, + { + "name": 'test-gateway', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providersMicrosoft.ContainerRegistry/registries/giotestacr", + "type": "Microsoft.ContainerRegistry/registries", + "tags": {}, + "location": "eastus", + } +]; + +const createCache = (cr) => { + return { + registries: { + list: { + 'eastus': { + data: cr + } + } + } + }; +}; + +const createErrorCache = () => { + return { + registries: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('acrHasTags', function() { + describe('run', function() { + it('should give passing result if no container registery found', function(done) { + const cache = createCache([]); + acrHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container registries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if container registery does not have tags associated', function(done) { + const cache = createCache([acr[1]]); + acrHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Conatiner Registry does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for container registery', function(done) { + const cache = createErrorCache(); + acrHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Container registries:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if container registery has tags associated', function(done) { + const cache = createCache([acr[0]]); + acrHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Conatiner Registry has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrLogAnalyticsEnabled.js b/plugins/azure/containerregistry/acrLogAnalyticsEnabled.js new file mode 100644 index 000000000..a795e887d --- /dev/null +++ b/plugins/azure/containerregistry/acrLogAnalyticsEnabled.js @@ -0,0 +1,63 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR Log Analytics Enabled', + category: 'Container Registry', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Azure container registry logs are sent to the Log Analytics workspace.', + more_info: 'Enabling Log Analytics for Azure container registry ensures that logs are shipped to a central repository that can be queried and audited.', + recommended_action: 'Modify container registry and enable Send to Log Analytics from diagnostic settings.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/monitor-service', + apis: ['registries:list', 'diagnosticSettings:listByContainerRegistries'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, (location, rcb) => { + const registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing container registries found', location); + return rcb(); + } + + for (let registry of registries.data) { + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByContainerRegistries', location, registry.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, + 'Unable to query diagnostics settings: ' + helpers.addError(diagnosticSettings), location, registry.id); + } else if (!diagnosticSettings.data.length) { + helpers.addResult(results, 2, 'No existing diagnostics settings', location, registry.id); + } else { + let found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Logging is enabled for container registry', location, registry.id); + } else { + helpers.addResult(results, 2, 'Logging is not enabled for container registry', location, registry.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrLogAnalyticsEnabled.spec.js b/plugins/azure/containerregistry/acrLogAnalyticsEnabled.spec.js new file mode 100644 index 000000000..a517d1bf5 --- /dev/null +++ b/plugins/azure/containerregistry/acrLogAnalyticsEnabled.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var acrLogAnalyticsEnabled = require('./acrLogAnalyticsEnabled'); + +const containerRegistries = [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "adminUserEnabled": true, + "publicNetworkAccess": "Enabled" + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [], + logs: [ + { + category: 'NetworkSecurityGroupEvent', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + }, + { + category: 'NetworkSecurityGroupRuleCounter', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + } + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [], + logs: [], + logAnalyticsDestinationType: null + } +]; + + +const createCache = (listRegistries, diagnosticSetting) => { + let logs = {}; + if (listRegistries && listRegistries.length > 0) { + logs[listRegistries[0].id] = { + data: diagnosticSetting + }; + } + + return { + registries: { + list: { + 'eastus': { + data: listRegistries + } + } + }, + diagnosticSettings: { + listByContainerRegistries: { + 'eastus': logs + } + } + }; +}; + +describe('acrLogAnalyticsEnabled', function() { + describe('run', function() { + it('should give passing result if No existing container registry found', function(done) { + const cache = createCache([], []); + acrLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing container registries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give Failing result if No existing diagnostics settings', function(done) { + const cache = createCache([containerRegistries[0]],[]); + acrLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing diagnostics settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for container registry Groups', function(done) { + const cache = createCache(null); + acrLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for container registries:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Log Analytics is enabled for ACR', function(done) { + const cache = createCache([containerRegistries[0]], [diagnosticSettings[0]]); + acrLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Logging is enabled for container registry'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Log Analytics is not enabled for ACR', function(done) { + const cache = createCache([containerRegistries[0]], [diagnosticSettings[1]]); + acrLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Logging is not enabled for container registry'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrManagedIdentityEnabled.js b/plugins/azure/containerregistry/acrManagedIdentityEnabled.js new file mode 100644 index 000000000..32c72d930 --- /dev/null +++ b/plugins/azure/containerregistry/acrManagedIdentityEnabled.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR Managed Identity Enabled', + category: 'Container Registry', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Azure container registries have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify container registry and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication-managed-identity?tabs=azure-cli', + apis: ['registries:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, function(location, rcb) { + var registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing container registries found', location); + return rcb(); + } + + for (let registry of registries.data){ + if (!registry.id) continue; + + if (registry.identity) { + helpers.addResult(results, 0, 'Container registry has managed identity enabled', location, registry.id); + } else { + helpers.addResult(results, 2, 'Container registry does not have managed identity enabled', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrManagedIdentityEnabled.spec.js b/plugins/azure/containerregistry/acrManagedIdentityEnabled.spec.js new file mode 100644 index 000000000..20f937093 --- /dev/null +++ b/plugins/azure/containerregistry/acrManagedIdentityEnabled.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var acrManagedIdentityEnabled = require('./acrManagedIdentityEnabled'); + +registries = [ + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": true, + "sku": { + "name": "Premium", + "tier": "Premium" + }, + "policies": { + "trustPolicy": { + "type": "Notary", + "status": "disabled" + }, + }, + "identity": { + "principalId": "1234", + "tenantId": "1234009", + "type": "systemAssigned", + "userAssignedIdentities": { + "/subscriptions/12343345/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "principalId": "1234333345", + "clientId": "1234333345" + } + } + }, + }, + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": false, + "sku": { + "name": "Premium", + "tier": "Premium" + }, + "policies": { + "trustPolicy": { + "type": "Notary", + "status": "enabled" + }, + }, + }, + { + "id": "/subscriptions/123445/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "anonymousPullEnabled": true, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "policies": { + "trustPolicy": { + "type": "Notary", + "status": "enabled" + }, + }, + "identity": { + "principalId": "1234", + "tenantId": "1234009", + "type": "systemAssigned, userAssigned", + "userAssignedIdentities": { + "/subscriptions/12343345/resourcegroups/meerab-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testmeerab": { + "principalId": "1234333345", + "clientId": "1234333345" + } + } + }, + }, + +]; +const createCache = (err, data) => { + return { + registries: { + list: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('acrManagedIdentityEnabled', function() { + describe('run', function() { + it('should give passing result if no container registries', function(done) { + const cache = createCache(null, []); + acrManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing container registries found'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give failing result if container registry does not have managed identity enabled', function(done) { + const cache = createCache(null,[registries[1]]); + acrManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container registry does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }); + + }); + + it('should give passing result if container registry has managed identity enabled', function(done) { + const cache = createCache(null, [registries[0]]); + acrManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container registry has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give passing result if container registry has both system and user assigned managed identity enabled', function(done) { + const cache = createCache(null, [registries[2]]); + acrManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container registry has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give passing result unable to query container registry', function(done) { + const cache = createCache(null, null); + acrManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for container registries:'); + expect(results[0].region).to.equal('eastus'); + done() + }); + + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrPublicAccess.js b/plugins/azure/containerregistry/acrPublicAccess.js new file mode 100644 index 000000000..61da38f39 --- /dev/null +++ b/plugins/azure/containerregistry/acrPublicAccess.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'ACR Public Access', + category: 'Container Registry', + domain: 'Containers', + severity: 'High', + description: 'Ensures that Azure Container registries are not publicly accessible.', + more_info: 'Azure Container registries should be not be publicly accessible to prevent unauthorized actions.', + recommended_action: 'Ensure that the public network access is disabled for each container registry.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/container-registry-access-selected-networks', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, function(location, rcb) { + var registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing Container registries found', location); + return rcb(); + } + + for (let registry of registries.data){ + if (!registry.id) continue; + + if (registry.publicNetworkAccess && registry.publicNetworkAccess.toLowerCase() === 'enabled'){ + helpers.addResult(results, 2, 'Container registry is publicly accessible', location, registry.id); + } else { + helpers.addResult(results, 0, 'Container registry is not publicly accessible', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrPublicAccess.spec.js b/plugins/azure/containerregistry/acrPublicAccess.spec.js new file mode 100644 index 000000000..37e42f695 --- /dev/null +++ b/plugins/azure/containerregistry/acrPublicAccess.spec.js @@ -0,0 +1,105 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var acrPublicAccess = require('./acrPublicAccess'); + +const createCache = (err, data) => { + return { + registries: { + list: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('acrPublicAccess', function() { + describe('run', function() { + it('should give passing result if no container registries', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container registries found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + acrPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if it is publicly accessible', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Container registry is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "adminUserEnabled": true, + "publicNetworkAccess": "Enabled" + } + ] + ); + + acrPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if not publicly accessible', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Container registry is not publicly accessibl'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "adminUserEnabled": false, + "publicNetworkAccess": "Disabled" + } + ] + ); + + acrPublicAccess.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/containerregistry/acrTrustedServiceEnabled.js b/plugins/azure/containerregistry/acrTrustedServiceEnabled.js new file mode 100644 index 000000000..e4a035a8f --- /dev/null +++ b/plugins/azure/containerregistry/acrTrustedServiceEnabled.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'ACR Trusted Services Enabled', + category: 'Container Registry', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that "Allow trusted Microsoft services to access this container registry" feature is enabled for Azure Container registries.', + more_info: 'Enabling network firewall rules for container registry will block access to incoming requests for data, including from other Azure services. To allow certain Azure cloud services access your vault resources, add an exception so that the trusted cloud services can bypass the firewall rules.', + recommended_action: 'Ensure that Azure Container registry network firewall configuration allows trusted Microsoft services to bypass the firewall.', + link: 'https://learn.microsoft.com/en-us/azure/container-registry/allow-access-trusted-services', + apis: ['registries:list'], + realtime_triggers: ['microsoftcontainerregistry:registries:write','microsoftcontainerregistry:registries:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.registries, function(location, rcb) { + var registries = helpers.addSource(cache, source, + ['registries', 'list', location]); + + if (!registries) return rcb(); + + if (registries.err || !registries.data) { + helpers.addResult(results, 3, + 'Unable to query for container registries: ' + helpers.addError(registries), location); + return rcb(); + } + + if (!registries.data.length) { + helpers.addResult(results, 0, 'No existing Container registries found', location); + return rcb(); + } + + for (let registry of registries.data){ + if (!registry.id) continue; + + if (registry.networkRuleBypassOptions && registry.networkRuleBypassOptions.toLowerCase() === 'none'){ + helpers.addResult(results, 2, 'Trusted Microsoft services are not allowed to access the Container registry', location, registry.id); + } else { + helpers.addResult(results, 0, 'Trusted Microsoft services are allowed to access the Container registry', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/containerregistry/acrTrustedServiceEnabled.spec.js b/plugins/azure/containerregistry/acrTrustedServiceEnabled.spec.js new file mode 100644 index 000000000..a71cd1d74 --- /dev/null +++ b/plugins/azure/containerregistry/acrTrustedServiceEnabled.spec.js @@ -0,0 +1,107 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var acrTrustedServiceEnabled = require('./acrTrustedServiceEnabled'); + +const createCache = (err, data) => { + return { + registries: { + list: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('acrTrustedServiceEnabled', function() { + describe('run', function() { + it('should give passing result if no container registries', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Container registries found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + acrTrustedServiceEnabled.run(cache, {}, callback); + }); + + it('should give failing result if trusted Microsoft Azure cloud services are not allowed to access the container registery', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Trusted Microsoft services are not allowed to access the Container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "adminUserEnabled": true, + "networkRuleBypassOptions": "None", + "publicNetworkAccess": "Enabled" + } + ] + ); + + acrTrustedServiceEnabled.run(cache, {}, callback); + }); + + it('should give passing result if trusted Microsoft Azure cloud services are allowed to access the container registery', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Trusted Microsoft services are allowed to access the Container registry'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.ContainerRegistry/registries/testregistry12543", + "name": "testregistry12543", + "type": "Microsoft.ContainerRegistry/registries", + "location": "eastus", + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "loginServer": "testregistry12543.azurecr.io", + "creationDate": "2019-10-18T21:16:01.347Z", + "provisioningState": "Succeeded", + "adminUserEnabled": false, + "publicNetworkAccess": "Disabled", + "networkRuleBypassOptions": "AzureServices", + } + ] + ); + + acrTrustedServiceEnabled.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/cosmosdb/advancedThreatProtection.js b/plugins/azure/cosmosdb/advancedThreatProtection.js new file mode 100644 index 000000000..002e22dc1 --- /dev/null +++ b/plugins/azure/cosmosdb/advancedThreatProtection.js @@ -0,0 +1,73 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Advanced Threat Protection Enabled', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'High', + description: 'Ensures that Advanced Threat Protection feature is enabled for Microsoft Azure Cosmos DB accounts.', + more_info: 'Advanced Threat Protection for Azure Cosmos DB provides an additional layer of security intelligence that detects unusual and potentially harmful attempts to access or exploit Azure Cosmos DB accounts.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/cosmos-db-advanced-threat-protection', + recommended_action: 'Modify Microsoft Azure Cosmos DB accounts to enable advanced threat protection feature.', + apis: ['databaseAccounts:list', 'advancedThreatProtection:get'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + async.each(databaseAccounts.data, (account, cb) => { + if (!account.id) return cb(); + + if (account.EnabledApiTypes && + account.EnabledApiTypes === 'Sql') { + var advancedThreatProtection = helpers.addSource(cache, source, + ['advancedThreatProtection', 'get', location, account.id]); + + if (!advancedThreatProtection || advancedThreatProtection.err || !advancedThreatProtection.data) { + helpers.addResult(results, 3, + 'Unable to query advanced threat protection for Cosmos DB account: ' + helpers.addError(advancedThreatProtection), + location, account.id); + return cb(); + } + + if (advancedThreatProtection.data.isEnabled) { + helpers.addResult(results, 0, + 'Advanced threat protection is enabled for Cosmos DB account', location, account.id); + } else { + helpers.addResult(results, 2, + 'Advanced threat protection is not enabled for CosmosDB account', location, account.id); + } + } else { + helpers.addResult(results, 0, + 'Advanced threat protection feature is not supported for current resource', location, account.id); + } + + cb(); + }, function() { + rcb(); + }); + + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/advancedThreatProtection.spec.js b/plugins/azure/cosmosdb/advancedThreatProtection.spec.js new file mode 100644 index 000000000..f68d7af7e --- /dev/null +++ b/plugins/azure/cosmosdb/advancedThreatProtection.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var advancedThreatProtection = require('./advancedThreatProtection'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; + +const atpGet = [ + { + id: 'subscriptions/123/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos/providers/Microsoft.Security/advancedThreatProtectionSettings/current', + type: 'Microsoft.Security/advancedThreatProtectionSettings', + name: 'current', + isEnabled: true + }, + { + id: 'subscriptions/123/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos/providers/Microsoft.Security/advancedThreatProtectionSettings/current', + type: 'Microsoft.Security/advancedThreatProtectionSettings', + name: 'current', + isEnabled: false + } +]; + +const createCache = (accounts, accountsErr, atpGet, atpErr) => { + const id = (accounts && accounts.length) ? accounts[0].id : null; + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + }, + advancedThreatProtection: { + get: { + 'eastus': { + [id]: { + err: atpErr, + data: atpGet + } + } + } + } + } +}; + +describe('advancedThreatProtection', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + advancedThreatProtection.run(cache, {}, callback); + }); + + it('should give passing result if Advanced threat protection feature is not supported for current resource', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Advanced threat protection feature is not supported'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]] + ); + + advancedThreatProtection.run(cache, {}, callback); + }); + + it('should give failing result if Advanced threat protection is not enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Advanced threat protection is not enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]], + null, + atpGet[1] + ); + + advancedThreatProtection.run(cache, {}, callback); + }); + + it('should give passing result if Advanced threat protection is enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Advanced threat protection is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]], + null, + atpGet[0] + ); + + advancedThreatProtection.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + advancedThreatProtection.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/cosmosdb/automaticFailoverEnabled.js b/plugins/azure/cosmosdb/automaticFailoverEnabled.js new file mode 100644 index 000000000..a1ba9ac66 --- /dev/null +++ b/plugins/azure/cosmosdb/automaticFailoverEnabled.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automatic Failover Enabled', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that the Automatic Failover feature is enabled for Microsoft Azure Cosmos DB accounts.', + more_info: 'It is strongly recommended to configure the Azure Cosmos DB accounts used for production workloads to enable automatic failover. ' + + 'Automatic failover allows Azure Cosmos DB to automatically failover to the Azure cloud region with the highest failover priority when the source region become unavailable.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/high-availability', + recommended_action: 'Modify Cosmos DB account to enable automatic failover.', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + + databaseAccounts.data.forEach(account => { + if (!account.id) return; + + if (account.enableAutomaticFailover) { + helpers.addResult(results, 0, + 'Automatic failover is enabled for Cosmos DB account', location, account.id); + } else { + helpers.addResult(results, 2, + 'Automatic failover is not enabled for Cosmos DB account', location, account.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/automaticFailoverEnabled.spec.js b/plugins/azure/cosmosdb/automaticFailoverEnabled.spec.js new file mode 100644 index 000000000..5bd336c97 --- /dev/null +++ b/plugins/azure/cosmosdb/automaticFailoverEnabled.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var automaticFailoverEnabled = require('./automaticFailoverEnabled'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": true, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; + +const createCache = (accounts, accountsErr) => { + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('automaticFailoverEnabled', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + automaticFailoverEnabled.run(cache, {}, callback); + }); + + it('should give passing result if automatic failover is enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automatic failover is enabled for Cosmos DB account'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]] + ); + + automaticFailoverEnabled.run(cache, {}, callback); + }); + + it('should give failing result if automatic failover is not enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automatic failover is not enabled for Cosmos DB account'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]], + ); + + automaticFailoverEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + automaticFailoverEnabled.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.js b/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.js new file mode 100644 index 000000000..aa60e9f05 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.js @@ -0,0 +1,55 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Public Access Disabled', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that Microsoft Azure Cosmos DB accounts are configured to deny public access.', + more_info: 'Microsoft Azure Cosmos DB accounts should not be accessible from internet and only be accessed from within a VNET.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-firewall', + recommended_action: 'Modify firewall and the virtual network configuration for your Cosmos DB accounts to provide access to selected networks.', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + databaseAccounts.data.forEach(account => { + if (!account.id) return; + + if (account.isVirtualNetworkFilterEnabled && account.ipRules && account.ipRules.length) { + helpers.addResult(results, 0, + 'Cosmos DB account denies public access', location, account.id); + } else { + helpers.addResult(results, 2, + 'Cosmos DB account allows public access', location, account.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.spec.js b/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.spec.js new file mode 100644 index 000000000..e77f96617 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosPublicAccessDisabled.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var cosmosPublicAccessDisabled = require('./cosmosPublicAccessDisabled'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": true, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": true, + "virtualNetworkRules": [ + { + "id": '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/deleteasap/providers/Microsoft.Network/virtualNetworks/deleteasap-vnet/subnets/default', + "ignoreMissingVNetServiceEndpoint": false + } + ], + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard", + "ipRules": [ + { "ipAddressOrRange": '104.42.195.92' }, + { "ipAddressOrRange": '40.76.54.131' } + ] + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; + +const createCache = (accounts, accountsErr) => { + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('cosmosPublicAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + cosmosPublicAccessDisabled.run(cache, {}, callback); + }); + + it('should give passing result if Cosmos DB account denies public access', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cosmos DB account denies public access'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]] + ); + + cosmosPublicAccessDisabled.run(cache, {}, callback); + }); + + it('should give failing result if Cosmos DB account allows public access', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cosmos DB account allows public access'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]], + ); + + cosmosPublicAccessDisabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + cosmosPublicAccessDisabled.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.js b/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.js new file mode 100644 index 000000000..f75c3bad5 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Diagnostic Logs', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Azure Cosmos DB accounts.', + more_info: 'Enabling diagnostic logs for Cosmos DB accounts is crucial to collect resource logs, which provide detailed data about resource operations. It helps to gain valuable insights into resource activity, assisting in monitoring, diagnosing issues, and optimizing the performance of Azure resources.', + recommended_action: 'Enable diagnostic logging for all Azure Cosmos DB accounts.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/monitor-resource-logs', + apis: ['databaseAccounts:list', 'diagnosticSettings:listByDatabaseAccounts'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, (location, rcb) => { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + + for (let account of databaseAccounts.data) { + if (!account.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByDatabaseAccounts', location, account.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query Cosmos DB account diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, account.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + if (found) { + helpers.addResult(results, 0, 'Cosmos DB account has diagnostic logs enabled', location, account.id); + + } else { + helpers.addResult(results, 2, 'Cosmos DB account does not have diagnostic logs enabled' , location, account.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.spec.js b/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.spec.js new file mode 100644 index 000000000..a858edd4d --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +var accountDiagnosticLogging = require('./cosmosdbDiagnosticLogs'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {"key": "value"}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": true, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": true, + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "createMode": "Default", + "databaseAccountOfferType": "Standard", + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; +const diagnosticSettings = [ + { + id: 'subscriptions/12424/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'openai-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (accounts, ds) => { + const id = accounts && accounts.length ? accounts[0].id : null; + return { + databaseAccounts: { + list: { + 'eastus': { + data: accounts + } + } + }, + diagnosticSettings: { + listByDatabaseAccounts: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('accountDiagnosticLogging', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [],null + ); + + accountDiagnosticLogging.run(cache, {}, callback); + }); + + it('should give passing result if diagnostic logs is enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cosmos DB account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]], [diagnosticSettings[0]] + ); + + accountDiagnosticLogging.run(cache, {}, callback); + }); + + it('should give failing result if diagnostic logs is not enabled for Cosmos DB account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cosmos DB account does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]], [] + ); + + accountDiagnosticLogging.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null, ['error']); + + accountDiagnosticLogging.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Cosmos DB account diagnostic settings: '); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache([databaseAccounts[0]], null); + accountDiagnosticLogging.run(cache, {}, callback); + + }); + }); +}); diff --git a/plugins/azure/cosmosdb/cosmosdbHasTags.js b/plugins/azure/cosmosdb/cosmosdbHasTags.js new file mode 100644 index 000000000..7e174561d --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Has Tags', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Azure Cosmos DB database accounts have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify affected database accounts and add tags.', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:write','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + for (let db of databaseAccounts.data) { + if (!db.id) continue; + + if (db.tags && Object.entries(db.tags).length > 0){ + helpers.addResult(results, 0, 'Cosmos DB account has tags associated', location, db.id); + } else { + helpers.addResult(results, 2, 'Cosmos DB account does not have tags associated', location, db.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/cosmosdbHasTags.spec.js b/plugins/azure/cosmosdb/cosmosdbHasTags.spec.js new file mode 100644 index 000000000..e6bfeeedd --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbHasTags.spec.js @@ -0,0 +1,130 @@ +var expect = require('chai').expect; +var cosmosdbHasTags = require('./cosmosdbHasTags'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {"key": "value"}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": true, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": true, + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard", + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; + +const createCache = (accounts, accountsErr) => { + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('cosmosdbHasTags', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + cosmosdbHasTags.run(cache, {}, callback); + }); + + it('should give passing result if Cosmos db has tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cosmos DB account has tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]] + ); + + cosmosdbHasTags.run(cache, {}, callback); + }); + + it('should give failing result if Azure Cosmos db does not have tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cosmos DB account does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]], + ); + + cosmosdbHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + cosmosdbHasTags.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/cosmosdb/cosmosdbLocalAuth.js b/plugins/azure/cosmosdb/cosmosdbLocalAuth.js new file mode 100644 index 000000000..7090696b5 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbLocalAuth.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Local Authentication Disabled', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that local authentication is disabled for Cosmos DB accounts.', + more_info: 'For enhanced security, centralized identity management and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication for Azure Cosmos DB accounts.', + recommended_action: 'Ensure that Cosmos DB accounts have local authentication disabled.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#disable-local-auth', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, (location, rcb) => { + const databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + for (let dbAccount of databaseAccounts.data) { + if (!dbAccount.id) continue; + + if (dbAccount.disableLocalAuth) { + helpers.addResult(results, 0, 'Cosmos DB account has local authentication disabled', location, dbAccount.id); + } else { + helpers.addResult(results, 2, 'Cosmos DB account has local authentication enabled', location, dbAccount.id); + } + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/cosmosdb/cosmosdbLocalAuth.spec.js b/plugins/azure/cosmosdb/cosmosdbLocalAuth.spec.js new file mode 100644 index 000000000..5d8c2c636 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbLocalAuth.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var cosmosdbLocalAuth = require('./cosmosdbLocalAuth'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {"key": "value"}, + "disableLocalAuth": true, + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "disableLocalAuth": false + } +]; + +const createCache = (accounts, accountsErr) => { + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('cosmosdbLocalAuth', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + cosmosdbLocalAuth.run(cache, {}, callback); + }); + + it('should give passing result if Cosmos DB has local auth disbaled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cosmos DB account has local authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]] + ); + + cosmosdbLocalAuth.run(cache, {}, callback); + }); + + it('should give failing result if Cosmos DB has local auth enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cosmos DB account has local authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]], + ); + + cosmosdbLocalAuth.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + cosmosdbLocalAuth.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js new file mode 100644 index 000000000..a23d70cb9 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js @@ -0,0 +1,56 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Managed Identity', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Azure Cosmos DB accounts have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/managed-identity-based-authentication', + recommended_action: 'Enable system or user-assigned identities for all Azure Cosmos DB accounts.', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); + } + + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } + + databaseAccounts.data.forEach(account => { + if (!account.id) return; + + if (account.identity && account.identity.type && + (account.identity.type.toLowerCase() === 'systemassigned' || account.identity.type.toLowerCase() === 'userassigned')) { + helpers.addResult(results, 0, + 'Cosmos DB account has managed identity enabled', location, account.id); + } else { + helpers.addResult(results, 2, + 'Cosmos DB account does not have managed identity enabled', location, account.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js new file mode 100644 index 000000000..b1f8054a2 --- /dev/null +++ b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +var cosmosdbManagedIdentity = require('./cosmosdbManagedIdentity'); + +const databaseAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {"key": "value"}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "identity": { + "principalId":"e8c02afc-8fb0-43eb-985f-5bb60a87e7aa", + "type":"systemassigned" + }, + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "enableAutomaticFailover": true, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": true, + "EnabledApiTypes": "Sql", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard", + }, + { + "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", + "name": "khulnasoft-cosmos", + "location": "East US", + "tags": {}, + "type": "Microsoft.DocumentDB/databaseAccounts", + "kind": "GlobalDocumentDB", + "provisioningState": "Succeeded", + "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", + "publicNetworkAccess": "Enabled", + "identity": { + "type":"None" + }, + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "enablePartitionKeyMonitor": false, + "isVirtualNetworkFilterEnabled": false, + "virtualNetworkRules": [], + "EnabledApiTypes": "Cassandra", + "disableKeyBasedMetadataWriteAccess": false, + "enableAnalyticalStorage": false, + "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", + "createMode": "Default", + "databaseAccountOfferType": "Standard" + } +]; + +const createCache = (accounts, accountsErr) => { + return { + databaseAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('cosmosdbManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no Cosmos DB accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cosmos DB accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + cosmosdbManagedIdentity.run(cache, {}, callback); + }); + + it('should give passing result if Cosmos db has managed identity', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cosmos DB account has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[0]] + ); + + cosmosdbManagedIdentity.run(cache, {}, callback); + }); + + it('should give failing result if Azure Cosmos db does not have managed identity', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cosmos DB account does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [databaseAccounts[1]], + ); + + cosmosdbManagedIdentity.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Cosmos DB accounts'} + ); + + cosmosdbManagedIdentity.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/databricks/workspaceDbfsInfraEncryption.js b/plugins/azure/databricks/workspaceDbfsInfraEncryption.js new file mode 100644 index 000000000..28aa1934f --- /dev/null +++ b/plugins/azure/databricks/workspaceDbfsInfraEncryption.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace DBFS Infrastructure Encryption', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that DBFS root storage for Databricks premium workspace has infrastructure encryption enabled.', + more_info: 'Enabling infrastructure level encryption for Azure Databricks workspace DBFS root storage allows data in storage account to be encrypted twice, once at the service level and once at the infrastructure level, using two different encryption algorithms and two different keys and provides an extra layer of protection and security in case one of the keys is compromised.', + recommended_action: 'Enable infrastructure level encryption for all Databricks premium workspace DBFS root storage.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/keys/#--enable-double-encryption-for-dbfs', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + + if (workspace.sku && workspace.sku.name && workspace.sku.name.toLowerCase()!='premium') { + helpers.addResult(results, 0, 'Databricks workspace is not a premium workspace', location, workspace.id); + } else if (workspace.parameters && workspace.parameters.requireInfrastructureEncryption && workspace.parameters.requireInfrastructureEncryption.value) { + helpers.addResult(results, 0, 'DBFS root storage for databricks workspace has infrastructure level encryption enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'DBFS root storage for databricks workspace does not have infrastructure level encryption enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceDbfsInfraEncryption.spec.js b/plugins/azure/databricks/workspaceDbfsInfraEncryption.spec.js new file mode 100644 index 000000000..bba897179 --- /dev/null +++ b/plugins/azure/databricks/workspaceDbfsInfraEncryption.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var databricksWorkspaceInfraEncryption = require('./workspaceDbfsInfraEncryption.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": true + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + } +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('databricksWorkspaceInfraEncryption', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + databricksWorkspaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + databricksWorkspaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if workspace is not using premium tier', function (done) { + const cache = createCache([workspaces[0]], null); + databricksWorkspaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace is not a premium workspace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace dbfs root has infrastructure level encryption enabled', function (done) { + const cache = createCache([workspaces[1]], null); + databricksWorkspaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DBFS root storage for databricks workspace has infrastructure level encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace DBFS root does not have infrastructure level encryption enabled', function (done) { + const cache = createCache([workspaces[2]], null); + databricksWorkspaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DBFS root storage for databricks workspace does not have infrastructure level encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceDiagnosticLogs.js b/plugins/azure/databricks/workspaceDiagnosticLogs.js new file mode 100644 index 000000000..f2cf7cd2b --- /dev/null +++ b/plugins/azure/databricks/workspaceDiagnosticLogs.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Diagnostic Logs', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Databricks workspace has diagnostic logs enabled.', + more_info: 'Enabling diagnostics logs for Azure Databricks workspace helps to monitor detailed usage patterns in your account, access and query your account\'s audit logs, and identifying potential security threats, providing essential insights for effective management and security of your environment.', + recommended_action: 'Ensure that Azure Databricks workspace has diagnostic logs enabled.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/administration-guide/account-settings/audit-logs', + apis: ['databricks:listWorkspaces','diagnosticSettings:listByDatabricksWorkspace'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByDatabricksWorkspace', location, workspace.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Databricks workspace diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, workspace.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Databricks workspace has diagnostic logs enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace does not have diagnostic logs enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceDiagnosticLogs.spec.js b/plugins/azure/databricks/workspaceDiagnosticLogs.spec.js new file mode 100644 index 000000000..42e97d997 --- /dev/null +++ b/plugins/azure/databricks/workspaceDiagnosticLogs.spec.js @@ -0,0 +1,202 @@ +var expect = require('chai').expect; +var workspaceDiagnosticLogs = require('./workspaceDiagnosticLogs.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/1234/resourcegroups/cloudexploit-dev/providers/Microsoft.Databricks/workspace/test/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [ [Object] ], + logs: [ + { + category: null, + categoryGroup: 'allLogs', + enabled: true, + retentionPolicy: { enabled: false, days: 0 } + }, + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/1234/resourcegroups/cloudexploit-dev/providers/Microsoft.Databricks/workspace/omerredistest/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [ [Object] ], + logs: [ + ], + logAnalyticsDestinationType: null + } +] +const createCache = (workspace, diagnostics) => { + let diagnostic = {}; + if (workspace.length) { + diagnostic[workspace[0].id] = { + data: diagnostics + }; + } + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspace + } + } + }, + diagnosticSettings: { + listByDatabricksWorkspace: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'workspace') { + return { + databricks: { + listWorkspaces: { + 'eastus': {} + } + } + }; + } else if (key === 'nospace'){ + return { + databricks: { + listWorkspaces: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: [workspaces[0]] + } + } + }, + diagnosticSettings: { + listByDatabricksWorkspace: { + 'eastus': {} + } + } + }; + } else { + const workspaceId = (workspace && workspace.length) ? workspace[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: [workspace[0]] + } + } + }, + diagnosticSettings: { + listByDatabricksWorkspace: { + 'eastus': { + data: {} + } + } + } + }; + } +}; +describe('workspaceDiagnosticLogs', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createErrorCache('nospace'); + workspaceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createErrorCache('workspace'); + workspaceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Databricks workspace diagnostic settings:', function (done) { + const cache = createErrorCache('diagnostic'); + workspaceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks workspace diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace has diagnostic logs enabled', function (done) { + const cache = createCache([workspaces[0]],[diagnosticSettings[0]] ); + workspaceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace does not have diagnostic logs enabled', function (done) { + const cache = createCache([workspaces[0]],[diagnosticSettings[1]] ); + workspaceDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceHasTags.js b/plugins/azure/databricks/workspaceHasTags.js new file mode 100644 index 000000000..892193db5 --- /dev/null +++ b/plugins/azure/databricks/workspaceHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Azure Databricks Workspace has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify databricks workspace and add tags.', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + if (workspace.tags && Object.entries(workspace.tags).length > 0) { + helpers.addResult(results, 0, 'Databricks workspace has tags associated', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace does not have tags associated', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceHasTags.spec.js b/plugins/azure/databricks/workspaceHasTags.spec.js new file mode 100644 index 000000000..75429264f --- /dev/null +++ b/plugins/azure/databricks/workspaceHasTags.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var workspaceHasTags = require('./workspaceHasTags.js'); + +const workspaces = [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {"key": "value"}, + }, +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceHasTags', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace has tags associated', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace does not have tags associated', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceManagedDiskCmk.js b/plugins/azure/databricks/workspaceManagedDiskCmk.js new file mode 100644 index 000000000..ef2c39665 --- /dev/null +++ b/plugins/azure/databricks/workspaceManagedDiskCmk.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Managed Disk CMK Encrypted', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Databricks premium workspace managed disk is encrypted with CMK.', + more_info: 'Azure Databricks allows you to encrypt data in your workspace using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault.', + recommended_action: 'Ensure that Databricks workspace managed disk has CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/keys/cmk-managed-disks-azure', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + + if (workspace.sku && workspace.sku.name && workspace.sku.name.toLowerCase()!='premium') { + helpers.addResult(results, 0, 'Databricks workspace is not a premium workspace', location, workspace.id); + } else if (workspace.encryption && workspace.encryption.entities && workspace.encryption.entities.managedDisk) { + helpers.addResult(results, 0, 'Databricks workspace managed disk has CMK encryption enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace managed disk does not have CMK encryption enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceManagedDiskCmk.spec.js b/plugins/azure/databricks/workspaceManagedDiskCmk.spec.js new file mode 100644 index 000000000..2ab01dfcc --- /dev/null +++ b/plugins/azure/databricks/workspaceManagedDiskCmk.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var workspaceManagedDiskCmk = require('./workspaceManagedDiskCmk.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": true + }, + }, + "encryption": { + "entities": { + "managedServices": { + "keySource": "Microsoft.Keyvault", + "keyVaultProperties": { + "keyVaultUri": "https://test.vault.azure.net", + "keyName": "test", + "keyVersion": "1" + } + }, + "managedDisk": { + "keySource": "Microsoft.Keyvault", + "keyVaultProperties": { + "keyVaultUri": "https://testkvmeerab.vault.azure.net", + "keyName": "testkey", + "keyVersion": "1" + }, + } + } + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + } +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceManagedDiskCmk', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspaceManagedDiskCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceManagedDiskCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if workspace is not using premium tier', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceManagedDiskCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace is not a premium workspace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace managed disks has CMK encryption enabled', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceManagedDiskCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace managed disk has CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace managed disk does not have CMK encryption enabled', function (done) { + const cache = createCache([workspaces[2]], null); + workspaceManagedDiskCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace managed disk does not have CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceManagedServicesCmk.js b/plugins/azure/databricks/workspaceManagedServicesCmk.js new file mode 100644 index 000000000..041ad423a --- /dev/null +++ b/plugins/azure/databricks/workspaceManagedServicesCmk.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Managed Services CMK Encrypted', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Databricks premium workspace managed services are encrypted with CMK.', + more_info: 'Azure Databricks allows you to encrypt data in your workspace using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault', + recommended_action: 'Ensure that Databricks workspace managed services has CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/keys/cmk-managed-disks-azure', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + + if (workspace.sku && workspace.sku.name && workspace.sku.name.toLowerCase()!='premium') { + helpers.addResult(results, 0, 'Databricks workspace is not a premium workspace', location, workspace.id); + } else if (workspace.encryption && workspace.encryption.entities && workspace.encryption.entities.managedServices) { + helpers.addResult(results, 0, 'Databricks workspace managed services has CMK encryption enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace managed services does not have CMK encryption enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceManagedServicesCmk.spec.js b/plugins/azure/databricks/workspaceManagedServicesCmk.spec.js new file mode 100644 index 000000000..8e61a3110 --- /dev/null +++ b/plugins/azure/databricks/workspaceManagedServicesCmk.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var workspaceManagedServicesCmk = require('./workspaceManagedServicesCmk.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": true + }, + }, + "encryption": { + "entities": { + "managedServices": { + "keySource": "Microsoft.Keyvault", + "keyVaultProperties": { + "keyVaultUri": "https://test.vault.azure.net", + "keyName": "test", + "keyVersion": "1" + } + }, + } + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + } +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceManagedServicesCmk', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspaceManagedServicesCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceManagedServicesCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if workspace is not using premium tier', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceManagedServicesCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace is not a premium workspace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace managed services has CMK encryption enabled', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceManagedServicesCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace managed services has CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace managed services does not have CMK encryption enabled', function (done) { + const cache = createCache([workspaces[2]], null); + workspaceManagedServicesCmk.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace managed services does not have CMK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceSecureCluster.js b/plugins/azure/databricks/workspaceSecureCluster.js new file mode 100644 index 000000000..83ec1be8c --- /dev/null +++ b/plugins/azure/databricks/workspaceSecureCluster.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Secure Cluster', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Databricks Workspace has secure cluster connectivity enabled.', + more_info: 'Enabling the No Public IP feature on Azure Databricks workspace secures cluster connectivity by ensuring that virtual networks have no open ports and compute resources are without public IP addresses. This approach enhances security by reducing the attack surface and simplifying network configuration.', + recommended_action: 'Ensure that Databricks workspace has secure cluster connectivity enabled.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + if (workspace.parameters && workspace.parameters.enableNoPublicIp && workspace.parameters.enableNoPublicIp.value) { + helpers.addResult(results, 0, 'Databricks workspace has secure cluster connectivity enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace does not have secure cluster connectivity enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/databricks/workspaceSecureCluster.spec.js b/plugins/azure/databricks/workspaceSecureCluster.spec.js new file mode 100644 index 000000000..d2020f13a --- /dev/null +++ b/plugins/azure/databricks/workspaceSecureCluster.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var workspaceSecureCluster = require('./workspaceSecureCluster.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": false + }, + "enableNoPublicIp": { + "type": "Bool", + "value": false + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "requireInfrastructureEncryption": { + "type": "Bool", + "value": true + }, + "enableNoPublicIp": { + "type": "Bool", + "value": true + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceSecureCluster', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspaceSecureCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceSecureCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace has secure cluster connectivity enabled', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceSecureCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace has secure cluster connectivity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace does not have secure cluster connectivity enabled', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceSecureCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace does not have secure cluster connectivity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/appWhitelistingEnabled.js b/plugins/azure/defender/appWhitelistingEnabled.js similarity index 69% rename from plugins/azure/securitycenter/appWhitelistingEnabled.js rename to plugins/azure/defender/appWhitelistingEnabled.js index b4a51a8eb..9dccca7b5 100644 --- a/plugins/azure/securitycenter/appWhitelistingEnabled.js +++ b/plugins/azure/defender/appWhitelistingEnabled.js @@ -3,13 +3,16 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Application Whitelisting Enabled', - category: 'Security Center', - description: 'Ensures that Security Center Monitor Adaptive Application Whitelisting is enabled', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Microsoft Defender Monitor Adaptive Application Whitelisting is enabled.', more_info: 'Adaptive application controls work in conjunction with machine learning to analyze processes running in a VM and help control which applications can run, hardening the VM against malware.', - recommended_action: 'Enable Adaptive Application Controls for Virtual Machines from the Azure Security Center by ensuring AuditIfNotExists setting is used.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-adaptiveapplication', + recommended_action: 'Enable Adaptive Application Controls for Virtual Machines from the Microsoft Defender for Cloud by ensuring AuditIfNotExists setting is used.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/adaptive-application-controls', apis: ['policyAssignments:list'], - + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], + run: function(cache, settings, callback) { const results = []; const source = {}; diff --git a/plugins/azure/defender/appWhitelistingEnabled.spec.js b/plugins/azure/defender/appWhitelistingEnabled.spec.js new file mode 100644 index 000000000..b07f3d084 --- /dev/null +++ b/plugins/azure/defender/appWhitelistingEnabled.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var appWhitelistingEnabled = require('./appWhitelistingEnabled'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('appWhitelistingEnabled', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + appWhitelistingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + appWhitelistingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + appWhitelistingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Adaptive Application Whitelisting enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + appWhitelistingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Adaptive Application Whitelisting disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + appWhitelistingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/autoProvisioningEnabled.js b/plugins/azure/defender/autoProvisioningEnabled.js similarity index 86% rename from plugins/azure/securitycenter/autoProvisioningEnabled.js rename to plugins/azure/defender/autoProvisioningEnabled.js index 0c3798904..fb95ccfe0 100644 --- a/plugins/azure/securitycenter/autoProvisioningEnabled.js +++ b/plugins/azure/defender/autoProvisioningEnabled.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Auto Provisioning Enabled', - category: 'Security Center', - description: 'Ensures that automatic provisioning of the monitoring agent is enabled', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that automatic provisioning of the monitoring agent is enabled.', more_info: 'The Microsoft Monitoring Agent scans for various security-related configurations and events such as system updates, OS vulnerabilities, and endpoint protection and provides alerts.', recommended_action: 'Ensure that the data collection settings of the subscription have Auto Provisioning set to enabled.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-enable-data-collection', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components', apis: ['autoProvisioningSettings:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/autoProvisioningEnabled.spec.js b/plugins/azure/defender/autoProvisioningEnabled.spec.js new file mode 100644 index 000000000..890d82bb8 --- /dev/null +++ b/plugins/azure/defender/autoProvisioningEnabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var autoProvisioningEnabled = require('./autoProvisioningEnabled'); + +const autoProvisioningSettings = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/autoProvisioningSettings/default', + 'name': 'default', + 'autoProvision': 'On' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/autoProvisioningSettings/default', + 'name': 'default', + 'autoProvision': 'Off' + } +]; + +const createCache = (autoProvisioningSettings) => { + return { + autoProvisioningSettings: { + list: { + global:{ + data: autoProvisioningSettings + } + } + } + }; +}; + +const createErrorCache = () => { + return { + autoProvisioningSettings: { + list: { + global: {} + } + } + }; +}; + +describe('autoProvisioningEnabled', function() { + describe('run', function() { + it('should give failing result if no auto provisioning settings', function(done) { + const cache = createCache([]); + autoProvisioningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing auto provisioning settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query auto provisioning settings', function(done) { + const cache = createErrorCache(); + autoProvisioningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query auto provisioning settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if auto provisioning is enabled', function(done) { + const cache = createCache([autoProvisioningSettings[0]]); + autoProvisioningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Monitoring Agent Auto Provisioning is enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if auto provisioning is disabled', function(done) { + const cache = createCache([autoProvisioningSettings[1]]); + autoProvisioningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Monitoring Agent Auto Provisioning is disabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForAPIs.js b/plugins/azure/defender/enableDefenderForAPIs.js new file mode 100644 index 000000000..48c023a64 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForAPIs.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For APIs', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for all APIs.', + more_info: 'Turning on Microsoft Defender for APIs enables threat detection for APIs, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for APIs in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-apis-introduction', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'api', 'APIs', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForAPIs.spec.js b/plugins/azure/defender/enableDefenderForAPIs.spec.js new file mode 100644 index 000000000..aac73e6ec --- /dev/null +++ b/plugins/azure/defender/enableDefenderForAPIs.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var enableDefenderForAPIs = require('./enableDefenderForAPIs'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + }; +}; + +describe('enableDefenderForAPIs', function() { + describe('run', function() { + it('should give unknown result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(['error'], null); + + enableDefenderForAPIs.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, []); + + enableDefenderForAPIs.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for APIs is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for APIs'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ]); + + enableDefenderForAPIs.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for APIs is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for APIs'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "API", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ]); + + enableDefenderForAPIs.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/defender/enableDefenderForARM.js b/plugins/azure/defender/enableDefenderForARM.js new file mode 100644 index 000000000..3bad41e5e --- /dev/null +++ b/plugins/azure/defender/enableDefenderForARM.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Resource Manager', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for Resource Manager.', + more_info: 'Turning on Microsoft Defender for Resource Manager enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Resource Manager in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-resource-manager-introduction', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'arm', 'Resource Manager', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForARM.spec.js b/plugins/azure/defender/enableDefenderForARM.spec.js new file mode 100644 index 000000000..21c3e2706 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForARM.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var enableDefenderForResourceManager = require('./enableDefenderForARM'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + }; +}; + +describe('enableDefenderForResourceManager', function() { + describe('run', function() { + it('should give unknown result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(['error'], null); + + enableDefenderForResourceManager.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, []); + + enableDefenderForResourceManager.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Resource Manager is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Resource Manager'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ]); + + enableDefenderForResourceManager.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Resource Manager is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Resource Manager'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "arm", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ]); + + enableDefenderForResourceManager.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/defender/enableDefenderForAppService.js b/plugins/azure/defender/enableDefenderForAppService.js new file mode 100644 index 000000000..f261bff27 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForAppService.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For App Services', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for App Services.', + more_info: 'Turning on Microsoft Defender for App Services enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for App Services in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-app-service-introduction', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'appservices', 'App Services', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForAppService.spec.js b/plugins/azure/defender/enableDefenderForAppService.spec.js new file mode 100644 index 000000000..e1cc5cdb3 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForAppService.spec.js @@ -0,0 +1,103 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./enableDefenderForAppService'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForAppService', function() { + describe('run', function() { + it('should give passing unknown if unable to query for pricing informatiin', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null + ); + + auth.run(cache, {}, callback); + }); + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for App Services is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for App Services'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for App Services is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for App Services'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "AppServices", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForCSPM.js b/plugins/azure/defender/enableDefenderForCSPM.js new file mode 100644 index 000000000..de5a62eb8 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForCSPM.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For CSPM', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for CSPM.', + more_info: 'Turning on Microsoft Defender for CSPM enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for CSPM in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'cloudposture', 'CSPM', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForCSPM.spec.js b/plugins/azure/defender/enableDefenderForCSPM.spec.js new file mode 100644 index 000000000..6f80dffd9 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForCSPM.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var enableDefenderForCSPM = require('./enableDefenderForCSPM'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + }; +}; + +describe('enableDefenderForCSPM', function() { + describe('run', function() { + it('should give unknown result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(['error'], null); + + enableDefenderForCSPM.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, []); + + enableDefenderForCSPM.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for CSPM is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for CSPM'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ]); + + enableDefenderForCSPM.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for CSPM is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for CSPM'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "CloudPosture", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ]); + + enableDefenderForCSPM.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/defender/enableDefenderForContainers.js b/plugins/azure/defender/enableDefenderForContainers.js new file mode 100644 index 000000000..c44709f54 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForContainers.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Containers', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for all containers.', + more_info: 'Turning on Microsoft Defender for Containers enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Containers in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'containers', 'Containers', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/securitycenter/highSeverityAlertsEnabled.spec.js b/plugins/azure/defender/enableDefenderForContainers.spec.js similarity index 54% rename from plugins/azure/securitycenter/highSeverityAlertsEnabled.spec.js rename to plugins/azure/defender/enableDefenderForContainers.spec.js index 525c4a21c..e90e3e672 100644 --- a/plugins/azure/securitycenter/highSeverityAlertsEnabled.spec.js +++ b/plugins/azure/defender/enableDefenderForContainers.spec.js @@ -1,10 +1,10 @@ var assert = require('assert'); var expect = require('chai').expect; -var auth = require('./highSeverityAlertsEnabled'); +var auth = require('./enableDefenderForContainers'); const createCache = (err, data) => { return { - securityContacts: { + pricings: { list: { 'global': { err: err, @@ -15,13 +15,13 @@ const createCache = (err, data) => { } }; -describe('highSeverityAlertsEnabled', function() { +describe('enableDefenderForKubernetes', function() { describe('run', function() { - it('should give failing result if no security contacts', function(done) { + it('should give passing result if no pricings found', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('No existing security contacts'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); expect(results[0].region).to.equal('global'); done() }; @@ -34,11 +34,11 @@ describe('highSeverityAlertsEnabled', function() { auth.run(cache, {}, callback); }); - it('should give failing result if disable App Service', function(done) { + it('should give failing result if Azure Defender for Containers is not enabled', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('High severity alerts for the subscription are not configured'); + expect(results[0].message).to.include('Azure Defender is not enabled for Containers'); expect(results[0].region).to.equal('global'); done() }; @@ -47,13 +47,10 @@ describe('highSeverityAlertsEnabled', function() { null, [ { - "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/securityContacts/default1", - "name": "default1", - "type": "Microsoft.Security/securityContacts", - "email": "rod_giovanni@yahoo.com", - "phone": "3053232490", - "alertNotifications": "Off", - "alertsToAdmins": "Off", + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", "location": "global" } ] @@ -62,11 +59,11 @@ describe('highSeverityAlertsEnabled', function() { auth.run(cache, {}, callback); }); - it('should give passing result if enabled App Service', function(done) { + it('should give passing result if Azure Defender for Containers is enabled', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('High severity alerts for the subscription are configured'); + expect(results[0].message).to.include('Azure Defender is enabled for Containers'); expect(results[0].region).to.equal('global'); done() }; @@ -75,13 +72,10 @@ describe('highSeverityAlertsEnabled', function() { null, [ { - "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/securityContacts/default1", - "name": "default1", - "type": "Microsoft.Security/securityContacts", - "email": "rod_giovanni@yahoo.com", - "phone": "3053232490", - "alertNotifications": "On", - "alertsToAdmins": "Off", + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "Containers", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", "location": "global" } ] @@ -90,4 +84,4 @@ describe('highSeverityAlertsEnabled', function() { auth.run(cache, {}, callback); }) }) -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForCosmosDB.js b/plugins/azure/defender/enableDefenderForCosmosDB.js new file mode 100644 index 000000000..bbd64a30c --- /dev/null +++ b/plugins/azure/defender/enableDefenderForCosmosDB.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Cosmos DBs', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for all Cosmos databases.', + more_info: 'Turning on Microsoft Defender for Cosmos databases enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Azure Cosmos DB in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-defender-for-cosmos', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + helpers.checkMicrosoftDefender(pricings, 'cosmosdbs', 'Cosmos Databases', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/enableDefenderForCosmosDB.spec.js b/plugins/azure/defender/enableDefenderForCosmosDB.spec.js new file mode 100644 index 000000000..3758bf24c --- /dev/null +++ b/plugins/azure/defender/enableDefenderForCosmosDB.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var enableDefenderForCosmosDB = require('./enableDefenderForCosmosDB'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + }; +}; + +describe('enableDefenderForCosmosDB', function() { + describe('run', function() { + it('should give unknown result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(['error'], null); + + enableDefenderForCosmosDB.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, []); + + enableDefenderForCosmosDB.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Cosmos DB is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Cosmos Databases'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ]); + + enableDefenderForCosmosDB.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Cosmos DB is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Cosmos Databases'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "CosmosDBs", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ]); + + enableDefenderForCosmosDB.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/defender/enableDefenderForDNS.js b/plugins/azure/defender/enableDefenderForDNS.js new file mode 100644 index 000000000..5e0fc409f --- /dev/null +++ b/plugins/azure/defender/enableDefenderForDNS.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For DNS', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender for DNS is enabled.', + more_info: 'Turning on Microsoft Defender for DNS enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for DNS in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'dns', 'DNS', results, location); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForDNS.spec.js b/plugins/azure/defender/enableDefenderForDNS.spec.js new file mode 100644 index 000000000..633542911 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForDNS.spec.js @@ -0,0 +1,87 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var enableDefenderForDNS = require('./enableDefenderForDNS'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForDNS', function() { + describe('run', function() { + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + enableDefenderForDNS.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for DNS is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for DNS'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "Dns", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + enableDefenderForDNS.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for DNS is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for DNS'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "Dns", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + enableDefenderForDNS.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForKeyVaults.js b/plugins/azure/defender/enableDefenderForKeyVaults.js new file mode 100644 index 000000000..3354c6fa1 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForKeyVaults.js @@ -0,0 +1,44 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Key Vaults', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender for Key Vaults is enabled.', + more_info: 'Turning on Microsoft Defender for Key Vaults enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Key Vaults in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'keyvaults', 'Key Vaults', results, location); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForKeyVaults.spec.js b/plugins/azure/defender/enableDefenderForKeyVaults.spec.js new file mode 100644 index 000000000..ad111e0c0 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForKeyVaults.spec.js @@ -0,0 +1,87 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var enableDefenderForKeyVaults = require('./enableDefenderForKeyVaults'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForKeyVaults', function() { + describe('run', function() { + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + enableDefenderForKeyVaults.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Key Vault is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Key Vaults'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "KeyVaults", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + enableDefenderForKeyVaults.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Key Vault is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Key Vaults'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "KeyVaults", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + enableDefenderForKeyVaults.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForOSRD.js b/plugins/azure/defender/enableDefenderForOSRD.js new file mode 100644 index 000000000..330859dfd --- /dev/null +++ b/plugins/azure/defender/enableDefenderForOSRD.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Open Source Relational Databases', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for Open Source Relational Databases.', + more_info: 'Enabling Defender for Cloud on Open Source Relational Databases allows detection of unusual database access, query patterns, and suspicious activities, enhancing overall security. This plan brings threat protections for PostgreSQL, MySQL and MariaDB Azure Databases.', + recommended_action: 'Enable Microsoft Defender for Open Source Relational Databases in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-introduction#what-are-the-benefits-of-microsoft-defender-for-open-source-relational-databases', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'opensourcerelationaldatabases', 'Open Source Relational Databases', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/enableDefenderForOSRD.spec.js b/plugins/azure/defender/enableDefenderForOSRD.spec.js new file mode 100644 index 000000000..5208d1bdb --- /dev/null +++ b/plugins/azure/defender/enableDefenderForOSRD.spec.js @@ -0,0 +1,87 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./enableDefenderForOSRD'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForPostgresql', function() { + describe('run', function() { + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Open Source Relational Databases is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Open Source Relational Databases'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12340/providers/Microsoft.Security/pricings/default", + "name": "openSourceRelationalDatabases", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Open Source Relational Databases is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Open Source Relational Databases'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12340/providers/Microsoft.Security/pricings/default", + "name": "openSourceRelationalDatabases", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForSqlServers.js b/plugins/azure/defender/enableDefenderForSqlServers.js new file mode 100644 index 000000000..b529d9665 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForSqlServers.js @@ -0,0 +1,100 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For SQL Servers', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for Azure SQL Server Databases at subscription level or individual resource level.', + more_info: 'Turning on Microsoft Defender for Azure SQL Server Databases enables threat detection for Azure SQL database servers, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Turning on Microsoft Defender for Azure SQL Databases incurs an additional cost per resource.', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities', + apis: ['pricings:list', 'servers:listSql', 'serverSecurityAlertPolicies:listByServer'], + settings: { + check_level: { + name: 'Defender Check Level', + description: 'Check for Defender at subscription level or resource level', + regex: '^(subscription|resource)$', + default: 'subscription' + } + }, + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete','microsoftsql:servers:securityalertpolicies:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var config = { + check_level: settings.check_level || this.settings.check_level.default + }; + + var serviceName = 'sqlservers'; + var serviceDisplayName = 'SQL Servers'; + + + if (config.check_level === 'subscription') { + var pricings = helpers.addSource(cache, source, ['pricings', 'list', 'global']); + + if (!pricings) return callback(null, results, source); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query Pricing information: ' + helpers.addError(pricings), 'global'); + return callback(null, results, source); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', 'global'); + return callback(null, results, source); + } + + helpers.checkMicrosoftDefender(pricings, serviceName, serviceDisplayName, results, 'global'); + return callback(null, results, source); + } + + async.each(locations.servers, function(location, rcb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server => { + const securitySettings = helpers.addSource(cache, source, + ['serverSecurityAlertPolicies', 'listByServer', location, server.id]); + + if (!securitySettings || securitySettings.err || !securitySettings.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server security alert policies: ' + helpers.addError(securitySettings), + location, server.id); + } else { + securitySettings.data.forEach(setting => { + if (setting.state && setting.state.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, + 'Azure Defender is enabled for SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Azure Defender is not enabled for SQL server', location, server.id); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForSqlServers.spec.js b/plugins/azure/defender/enableDefenderForSqlServers.spec.js new file mode 100644 index 000000000..fd4808c7b --- /dev/null +++ b/plugins/azure/defender/enableDefenderForSqlServers.spec.js @@ -0,0 +1,127 @@ +const assert = require('assert'); +const expect = require('chai').expect; +const plugin = require('./enableDefenderForSqlServers'); + +describe('enableDefenderForSqlServers', function() { + describe('run', function() { + it('should give passing result if Defender is enabled at subscription level', function(done) { + const cache = createCache([{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Standard' + }]); + const settings = { + check_level: 'subscription' + }; + + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for SQL Servers'); + done(); + }); + }); + + it('should give failing result if Defender is not enabled at subscription level', function(done) { + const cache = createCache([{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }]); + const settings = { + check_level: 'subscription' + }; + + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for SQL Servers'); + done(); + }); + }); + + it('should give passing result if Defender is enabled at resource level', function(done) { + const cache = createCache( + [{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }], + [{ + id: '/subscriptions/123/servers/test-server' + }], + [{ + id: '/subscriptions/123/servers/test-server/security', + state: 'Enabled' + }] + ); + const settings = { + check_level: 'resource' + }; + + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for SQL server'); + done(); + }); + }); + + it('should give failing result if Defender is not enabled at resource level', function(done) { + const cache = createCache( + [{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }], + [{ + id: '/subscriptions/123/servers/test-server' + }], + [{ + id: '/subscriptions/123/servers/test-server/security', + state: 'Disabled' + }] + ); + const settings = { + check_level: 'resource' + }; + + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for SQL server'); + done(); + }); + }); + + // Add other necessary test cases for error conditions + }); +}); + +function createCache(pricingData, serversData, securityData) { + return { + pricings: { + list: { + global: { + data: pricingData + } + } + }, + servers: { + listSql: { + 'eastus': { + data: serversData + } + } + }, + serverSecurityAlertPolicies: { + listByServer: { + 'eastus': { + '/subscriptions/123/servers/test-server': { + data: securityData + } + } + } + } + }; +} \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForSqlServersVMs.js b/plugins/azure/defender/enableDefenderForSqlServersVMs.js new file mode 100644 index 000000000..f3ba2e830 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForSqlServersVMs.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For SQL Servers On Machines', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for Azure SQL Server on machines.', + more_info: 'Turning on Microsoft Defender for SQL Server on machines enables threat detection for SQL servers on machines, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for SQL servers on machines in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-sql-usage', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'sqlservervirtualmachines', 'SQL Servers on machines', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForSqlServersVMs.spec.js b/plugins/azure/defender/enableDefenderForSqlServersVMs.spec.js new file mode 100644 index 000000000..d55d1e77c --- /dev/null +++ b/plugins/azure/defender/enableDefenderForSqlServersVMs.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var enableDefenderForSQLServers = require('./enableDefenderForSqlServersVMs'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + }; +}; + +describe('enableDefenderForSQLServers', function() { + describe('run', function() { + it('should give unknown result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(['error'], null); + + enableDefenderForSQLServers.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, []); + + enableDefenderForSQLServers.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for SQL Servers on machines is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for SQL Servers on machines'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ]); + + enableDefenderForSQLServers.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for SQL Servers on machines is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for SQL Servers on machines'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache(null, [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "SQLServerVirtualMachines", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ]); + + enableDefenderForSQLServers.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/defender/enableDefenderForStorage.js b/plugins/azure/defender/enableDefenderForStorage.js new file mode 100644 index 000000000..104d7dcb9 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForStorage.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Storage', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for Storage.', + more_info: 'Turning on Microsoft Defender for Storage enables threat detection for Storage, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Storage in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-storage-introduction', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'storageaccounts', 'Storage Accounts', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForStorage.spec.js b/plugins/azure/defender/enableDefenderForStorage.spec.js new file mode 100644 index 000000000..93869a310 --- /dev/null +++ b/plugins/azure/defender/enableDefenderForStorage.spec.js @@ -0,0 +1,87 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./enableDefenderForStorage'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForStorage', function() { + describe('run', function() { + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Storage Account is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Storage Accounts'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "StorageAccounts", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Storage Account is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Storage Accounts'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", + "name": "StorageAccounts", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForVMs.js b/plugins/azure/defender/enableDefenderForVMs.js new file mode 100644 index 000000000..ae626283b --- /dev/null +++ b/plugins/azure/defender/enableDefenderForVMs.js @@ -0,0 +1,45 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender For Virtual Machines', + category: 'Defender', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that Microsoft Defender is enabled for all virtual machines.', + more_info: 'Turning on Microsoft Defender for Virtual Machines enables threat detection, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', + recommended_action: 'Enable Microsoft Defender for Servers in Defender plans for the subscription.', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities', + apis: ['pricings:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.pricings, function(location, rcb) { + var pricings = helpers.addSource(cache, source, + ['pricings', 'list', location]); + + if (!pricings) return rcb(); + + if (pricings.err || !pricings.data) { + helpers.addResult(results, 3, + 'Unable to query for Pricing: ' + helpers.addError(pricings), location); + return rcb(); + } + + if (!pricings.data.length) { + helpers.addResult(results, 0, 'No Pricing information found', location); + return rcb(); + } + + helpers.checkMicrosoftDefender(pricings, 'virtualmachines', 'Virtual Machines', results, location); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/enableDefenderForVMs.spec.js b/plugins/azure/defender/enableDefenderForVMs.spec.js new file mode 100644 index 000000000..1946100fb --- /dev/null +++ b/plugins/azure/defender/enableDefenderForVMs.spec.js @@ -0,0 +1,104 @@ +var expect = require('chai').expect; +var auth = require('./enableDefenderForVMs'); + +const createCache = (err, data) => { + return { + pricings: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableDefenderForVMs', function() { + describe('run', function() { + + it('should give unknow result if unable to query pricing information', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pricing'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if no pricings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pricing information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if Azure Defender for Virtual Machines is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for Virtual Machines'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "KubernetesService", + "type": "Microsoft.Security/pricings", + "pricingTier": "free", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if Azure Defender for Virtual Machines is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for Virtual Machines'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/providers/Microsoft.Security/pricings/default", + "name": "VirtualMachines", + "type": "Microsoft.Security/pricings", + "pricingTier": "Standard", + "location": "global" + } + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/defender/enableEndpointIntegration.js b/plugins/azure/defender/enableEndpointIntegration.js new file mode 100644 index 000000000..2bba4e9a1 --- /dev/null +++ b/plugins/azure/defender/enableEndpointIntegration.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Defender Endpoint Integration', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Microsoft Defender for Endpoint integration is enabled.', + more_info: 'Window Defender ATP integration brings comprehensive Endpoint Detection and Response (EDR) capabilities within Microsoft Defender for Cloud. This integration helps to spot abnormalities, detect and respond to advanced attacks on Windows server endpoints monitored by Microsoft Defender for Cloud.', + recommended_action: 'Enable "Allow Microsoft Defender for Endpoint to access my data setting" in Defender environment settings.', + link: 'https://learn.microsoft.com/en-in/azure/defender-for-cloud/integration-defender-for-endpoint?tabs=windows', + apis: ['securityCenter:list'], + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.securityCenter, function(location, rcb) { + var defenderSettings = helpers.addSource(cache, source, + ['securityCenter', 'list', location]); + + if (!defenderSettings) return rcb(); + + if (defenderSettings.err || !defenderSettings.data) { + helpers.addResult(results, 3, + 'Unable to query for Defender Settings: ' + helpers.addError(defenderSettings), location); + return rcb(); + } + + if (!defenderSettings.data.length) { + helpers.addResult(results, 0, 'No Defender Settings information found', location); + return rcb(); + } + + const wdatpIntegration = defenderSettings.data.find((settings) => settings.name && settings.name.toLowerCase() === 'wdatp'); + if (wdatpIntegration && wdatpIntegration.enabled) { + helpers.addResult(results, 0, 'Endpoint integration is enabled for Microsoft Defender', location, wdatpIntegration.id); + } else { + helpers.addResult(results, 2, 'Endpoint integration is not enabled for Microsoft Defender', location); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/enableEndpointIntegration.spec.js b/plugins/azure/defender/enableEndpointIntegration.spec.js new file mode 100644 index 000000000..b847881b8 --- /dev/null +++ b/plugins/azure/defender/enableEndpointIntegration.spec.js @@ -0,0 +1,87 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./enableEndpointIntegration'); + +const createCache = (err, data) => { + return { + securityCenter: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('enableEndpointIntegration', function() { + describe('run', function() { + it('should give passing result if no settings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Defender Settings information found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if Endpoint integration is not enabled for Azure Defender', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Endpoint integration is not enabled for Microsoft Defender'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/providers/Microsoft.Security/settings/WDATP', + name: 'WDATP', + type: 'Microsoft.Security/settings', + kind: 'DataExportSettings', + enabled: false + }, + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if Endpoint integration is enabled for Azure Defender', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Endpoint integration is enabled for Microsoft Defender'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/providers/Microsoft.Security/settings/WDATP', + name: 'WDATP', + type: 'Microsoft.Security/settings', + kind: 'DataExportSettings', + enabled: true + }, + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}); diff --git a/plugins/azure/defender/highSeverityAlertsEnabled.js b/plugins/azure/defender/highSeverityAlertsEnabled.js new file mode 100644 index 000000000..2a13ed372 --- /dev/null +++ b/plugins/azure/defender/highSeverityAlertsEnabled.js @@ -0,0 +1,77 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +const SEVERITY_LEVELS = ['low', 'medium', 'high']; + +module.exports = { + title: 'High Severity Alerts Enabled', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that high severity alerts are enabled and properly configured.', + more_info: 'Enabling high severity alerts ensures that microsoft alerts for potential security issues are sent and allows for quick mitigation of the associated risks.', + recommended_action: 'Enable email alert notification and configure its severity level.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications', + apis: ['securityContactv2:listAll'], + settings: { + alert_notifications_min_severity_level: { + name: 'Alert Notifications Minimum Severity Level', + description: 'Security issues severity level for which notifications should be sent. Use "low" option to receive notification for all security issues.', + regex: '^(high|medium|low)$', + default: 'medium' + } + }, + realtime_triggers: ['microsoftsecurity:securitycontacts:write','microsoftsecurity:securitycontacts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + alert_notifications_min_severity_level: settings.alert_notifications_min_severity_level || this.settings.alert_notifications_min_severity_level.default + }; + + let desiredSeverityLevel = SEVERITY_LEVELS.indexOf(config.alert_notifications_min_severity_level.toLowerCase()); + + async.each(locations.securityContactv2, (location, rcb) => { + var securityContacts = helpers.addSource(cache, source, + ['securityContactv2', 'listAll', location]); + if (!securityContacts) return rcb(); + + if (securityContacts.err || !securityContacts.data) { + helpers.addResult(results, 3, + 'Unable to query for security contacts: ' + helpers.addError(securityContacts), location); + return rcb(); + } + + if (!securityContacts.data.length) { + helpers.addResult(results, 2, 'No existing security contacts found', location); + return rcb(); + } + + for (let contact of securityContacts.data) { + if (!contact.id) continue; + + if ( contact.alertNotifications && contact.alertNotifications.state && + contact.alertNotifications.state.toLowerCase() === 'off') { + helpers.addResult(results, 2, 'Security contacts email alert notification are not enabled', location, contact.id); + } else { + let currentSeverityLevel = contact.alertNotifications.minimalSeverity.toLowerCase(); + if (contact.alertNotifications.minimalSeverity && + SEVERITY_LEVELS.indexOf(currentSeverityLevel) >= desiredSeverityLevel) { + helpers.addResult(results, 0, `Security contacts email alert notifications enabled with minimum severity level + ${currentSeverityLevel} which is greater or equal to + the desired severity level ${SEVERITY_LEVELS[desiredSeverityLevel]}`, location, contact.id); + } else { + helpers.addResult(results, 2, `Security contacts email alert notifications enabled with minimum severity + level ${currentSeverityLevel} which is less than the desired severity level ${SEVERITY_LEVELS[desiredSeverityLevel]}`, location, contact.id); + } + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/highSeverityAlertsEnabled.spec.js b/plugins/azure/defender/highSeverityAlertsEnabled.spec.js new file mode 100644 index 000000000..7e95b5d1f --- /dev/null +++ b/plugins/azure/defender/highSeverityAlertsEnabled.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var highSeverityAlertsEnabled = require('./highSeverityAlertsEnabled'); + +const securityContacts = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'On', + 'email': 'xyz@gmail.com', + + "notificationsByRole": { + "state": "On", + "roles": [ + "Owner", + ] + }, + alertNotifications : { state: "On", minimalSeverity: 'High' }, + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'Off', + 'email': '', + + "notificationsByRole": { + "state": "On", + "roles": [ + "Admin" + ] + }, + alertNotifications : { state: "On", minimalSeverity: 'Low' }, + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'Off', + 'email': '', + + "notificationsByRole": { + "state": "On", + "roles": [ + "Admin" + ] + }, + alertNotifications : { state: "Off", minimalSeverity: 'Low' }, + } +]; + +const createCache = (securityContacts) => { + return { + securityContactv2: { + listAll: { + global:{ + data: securityContacts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + securityContactv2: { + listAll: { + global: {} + } + } + }; +}; + +describe('highSeverityAlertsEnabled', function() { + describe('run', function() { + it('should give failing result if no security contacts', function(done) { + const cache = createCache([]); + highSeverityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing security contacts found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for security contacts', function(done) { + const cache = createErrorCache(); + highSeverityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if Security Contact email alert severity is greater or equal then desired', function(done) { + const cache = createCache([securityContacts[0]]); + highSeverityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security contacts email alert notifications enabled with minimum severity level'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Security Contact email alert severity is less then desired', function(done) { + const cache = createCache([securityContacts[1]]); + highSeverityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security contacts email alert notifications enabled with minimum severity'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Security Contact email alert notification not enabled', function(done) { + const cache = createCache([securityContacts[2]]); + highSeverityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security contacts email alert notification are not enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorEndpointProtection.js b/plugins/azure/defender/monitorEndpointProtection.js similarity index 61% rename from plugins/azure/securitycenter/monitorEndpointProtection.js rename to plugins/azure/defender/monitorEndpointProtection.js index b4754e35f..7c636b7f0 100644 --- a/plugins/azure/securitycenter/monitorEndpointProtection.js +++ b/plugins/azure/defender/monitorEndpointProtection.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor Endpoint Protection', - category: 'Security Center', - description: 'Ensures Endpoint Protection monitoring is enabled in Security Center', - more_info: 'When this setting is enabled, Security Center audits the Endpoint Protection setting for all virtual machines for malware protection.', - recommended_action: 'Enable Adaptive Application Controls for Endpoint Protection from the Azure Security Center by ensuring AuditIfNotExists setting is used to monitor missing Endpoint Protection.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Endpoint Protection monitoring is enabled in Microsoft Defender.', + more_info: 'When this setting is enabled, Microsoft Defender for Cloud audits the Endpoint Protection setting for all virtual machines for malware protection.', + recommended_action: 'Enable Adaptive Application Controls for Endpoint Protection from the Microsoft Defender by ensuring AuditIfNotExists setting is used to monitor missing Endpoint Protection.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/monitorEndpointProtection.spec.js b/plugins/azure/defender/monitorEndpointProtection.spec.js new file mode 100644 index 000000000..7500b1cd0 --- /dev/null +++ b/plugins/azure/defender/monitorEndpointProtection.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorEndpointProtection = require('./monitorEndpointProtection'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorEndpointProtection', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Endpoint Protection enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Endpoint Protection disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/monitorExternalAccounts.js b/plugins/azure/defender/monitorExternalAccounts.js new file mode 100644 index 000000000..e0e54cfc3 --- /dev/null +++ b/plugins/azure/defender/monitorExternalAccounts.js @@ -0,0 +1,36 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Monitor External Accounts with Write Permissions', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that External Accounts with Write Permissions are being Monitored in Microsoft Defender.', + more_info: 'External Accounts with Write Permissions should be monitored to meet you organization\'s security compliance requirements.', + recommended_action: 'Enable Monitor for External Accounts with Write Permissions by ensuring AuditIfNotExists setting is used for \'External accounts with write permissions should be removed from your subscription\' from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', + apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.policyAssignments, function(location, rcb) { + + const policyAssignments = helpers.addSource(cache, source, + ['policyAssignments', 'list', location]); + + helpers.checkPolicyAssignment(policyAssignments, + 'identityRemoveExternalAccountWithWritePermissionsMonitoringEffect', + 'Monitor for External Accounts with Write Permissions', results, location); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/monitorExternalAccounts.spec.js b/plugins/azure/defender/monitorExternalAccounts.spec.js new file mode 100644 index 000000000..91f453537 --- /dev/null +++ b/plugins/azure/defender/monitorExternalAccounts.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var monitorExternalAccounts = require('./monitorExternalAccounts'); + +const policyAssignments = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/456', + 'displayName': 'Test Policy', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': '456', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'identityRemoveExternalAccountWithWritePermissionsMonitoringEffect': { + 'value': 'AuditIfNotExists' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'identityRemoveExternalAccountWithWritePermissionsMonitoringEffect': { + 'value': 'Disabled' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'testParam': { + 'value': 'Disabled' + } + } + } +]; + +const createCache = (policyAssignments) => { + let assignment = {}; + if (policyAssignments) { + assignment['data'] = policyAssignments; + } + return { + policyAssignments: { + list: { + 'eastus': assignment + } + }, + }; +}; + +describe('monitorExternalAccounts', function() { + describe('run', function() { + it('should give passing result if no policy assignments', function(done) { + const cache = createCache([]); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Policy Assignments', function(done) { + const cache = createCache(null); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if external accounts with write permissions monitoring is enabled', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Monitor for External Accounts with Write Permissions is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if external accounts with write permissions monitoring is disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Monitor for External Accounts with Write Permissions is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if external accounts with write permissions monitoring is enabled by default', function (done) { + const cache = createCache([policyAssignments[3]]); + monitorExternalAccounts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Monitor for External Accounts with Write Permissions is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/monitorIpForwarding.js b/plugins/azure/defender/monitorIpForwarding.js new file mode 100644 index 000000000..91ad78b3b --- /dev/null +++ b/plugins/azure/defender/monitorIpForwarding.js @@ -0,0 +1,36 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Monitor IP Forwarding', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Virtual Machine IP Forwarding Monitoring is enabled in Microsoft Defender.', + more_info: 'IP Forwarding feature should be monitored to meet you organization\'s security compliance requirements.', + recommended_action: 'Enable IP Forwarding Monitoring by ensuring AuditIfNotExists setting is used for \'IP Forwarding on your virtual machine should be disabled\' from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', + apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.policyAssignments, function(location, rcb) { + + const policyAssignments = helpers.addSource(cache, source, + ['policyAssignments', 'list', location]); + + helpers.checkPolicyAssignment(policyAssignments, + 'disableIPForwardingMonitoringEffect', + 'IP Forwarding Monitoring', results, location); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/monitorIpForwarding.spec.js b/plugins/azure/defender/monitorIpForwarding.spec.js new file mode 100644 index 000000000..d200e72de --- /dev/null +++ b/plugins/azure/defender/monitorIpForwarding.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var monitorIpForwarding = require('./monitorIpForwarding'); + +const policyAssignments = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/456', + 'displayName': 'Test Policy', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': '456', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'disableIPForwardingMonitoringEffect': { + 'value': 'AuditIfNotExists' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'disableIPForwardingMonitoringEffect': { + 'value': 'Disabled' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'testParam': { + 'value': 'Disabled' + } + } + } +]; + +const createCache = (policyAssignments) => { + let assignment = {}; + if (policyAssignments) { + assignment['data'] = policyAssignments; + } + return { + policyAssignments: { + list: { + 'eastus': assignment + } + }, + }; +}; + +describe('monitorIpForwarding', function() { + describe('run', function() { + it('should give passing result if no policy assignments', function(done) { + const cache = createCache([]); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Policy Assignments', function(done) { + const cache = createCache(null); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if IP Forwarding Monitoring is enabled', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('IP Forwarding Monitoring is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if IP Forwarding Monitoring is disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('IP Forwarding Monitoring is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if external accounts with write permissions monitoring is enabled by default', function (done) { + const cache = createCache([policyAssignments[3]]); + monitorIpForwarding.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('IP Forwarding Monitoring is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorJitNetworkAccess.js b/plugins/azure/defender/monitorJitNetworkAccess.js similarity index 62% rename from plugins/azure/securitycenter/monitorJitNetworkAccess.js rename to plugins/azure/defender/monitorJitNetworkAccess.js index 497c525b8..e3b0b0ccb 100644 --- a/plugins/azure/securitycenter/monitorJitNetworkAccess.js +++ b/plugins/azure/defender/monitorJitNetworkAccess.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor JIT Network Access', - category: 'Security Center', - description: 'Ensures Just In Time Network Access monitoring is enabled in Security Center', - more_info: 'When this setting is enabled, Security Center audits Just In Time Network Access on all virtual machines (Windows and Linux as well) to enhance data protection at rest', - recommended_action: 'Ensure JIT Network Access monitoring is configured for compute and apps from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Just In Time Network Access monitoring is enabled in Microsoft Defender.', + more_info: 'When this setting is enabled, Microsoft Defender for Cloud audits Just In Time Network Access on all virtual machines (Windows and Linux as well) to enhance data protection at rest', + recommended_action: 'Ensure JIT Network Access monitoring is configured for compute and apps from Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/monitorJitNetworkAccess.spec.js b/plugins/azure/defender/monitorJitNetworkAccess.spec.js new file mode 100644 index 000000000..dec64ca08 --- /dev/null +++ b/plugins/azure/defender/monitorJitNetworkAccess.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorJitNetworkAccess = require('./monitorJitNetworkAccess'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorJitNetworkAccess', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorJitNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorJitNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorJitNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor JIT Network Access enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorJitNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor JIT Network Access disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorJitNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/monitorNextGenerationFirewall.js b/plugins/azure/defender/monitorNextGenerationFirewall.js new file mode 100644 index 000000000..9f3777b88 --- /dev/null +++ b/plugins/azure/defender/monitorNextGenerationFirewall.js @@ -0,0 +1,36 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Monitor Next Generation Firewall', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Next Generation Firewall (NGFW) Monitoring is enabled in Microsoft Defender.', + more_info: 'When this setting is enabled, Microsoft Defender for Cloud will search for deployments where a NGFW is recommended.', + recommended_action: 'Enable Next Generation Firewall Monitoring by ensuring AuditIfNotExists setting is used for \'All network ports should be restricted on network security groups associated to your virtual machine\' from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', + apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.policyAssignments, function(location, rcb) { + + const policyAssignments = helpers.addSource(cache, source, + ['policyAssignments', 'list', location]); + + helpers.checkPolicyAssignment(policyAssignments, + 'nextGenerationFirewallMonitoringEffect', + 'Next Generation Firewall Monitoring', results, location); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/monitorNextGenerationFirewall.spec.js b/plugins/azure/defender/monitorNextGenerationFirewall.spec.js new file mode 100644 index 000000000..ba5564dcd --- /dev/null +++ b/plugins/azure/defender/monitorNextGenerationFirewall.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var monitorNextGenerationFirewall = require('./monitorNextGenerationFirewall'); + +const policyAssignments = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/456', + 'displayName': 'Test Policy', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': '456', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'nextGenerationFirewallMonitoringEffect': { + 'value': 'AuditIfNotExists' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'nextGenerationFirewallMonitoringEffect': { + 'value': 'Disabled' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'testParam': { + 'value': 'Disabled' + } + } + } +]; + +const createCache = (policyAssignments) => { + let assignment = {}; + if (policyAssignments) { + assignment['data'] = policyAssignments; + } + return { + policyAssignments: { + list: { + 'eastus': assignment + } + }, + }; +}; + +describe('monitorNextGenerationFirewall', function() { + describe('run', function() { + it('should give passing result if no policy assignments', function(done) { + const cache = createCache([]); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Policy Assignments', function(done) { + const cache = createCache(null); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Next Generation Firewall Monitoring is enabled', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Next Generation Firewall Monitoring is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Next Generation Firewall Monitoring is disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Next Generation Firewall Monitoring is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if external accounts with write permissions monitoring is enabled by default', function (done) { + const cache = createCache([policyAssignments[3]]); + monitorNextGenerationFirewall.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Next Generation Firewall Monitoring is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/monitorSubscriptionOwners.js b/plugins/azure/defender/monitorSubscriptionOwners.js new file mode 100644 index 000000000..f69c71775 --- /dev/null +++ b/plugins/azure/defender/monitorSubscriptionOwners.js @@ -0,0 +1,36 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Monitor Total Number of Subscription Owners', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Total Number of Subscription Owners is being Monitored in Microsoft Defender.', + more_info: 'Total Number of Subscription Owners should be monitored to meet you organization\'s security compliance requirements.', + recommended_action: 'Enable Monitor for Total Number of Subscription Owners by ensuring AuditIfNotExists setting is used for \'A maximum of 3 owners should be designated for your subscription\' from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', + apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.policyAssignments, function(location, rcb) { + + const policyAssignments = helpers.addSource(cache, source, + ['policyAssignments', 'list', location]); + + helpers.checkPolicyAssignment(policyAssignments, + 'identityDesignateLessThanOwnersMonitoringEffect', + 'Monitor for Total Number of Subscription Owners', results, location); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/defender/monitorSubscriptionOwners.spec.js b/plugins/azure/defender/monitorSubscriptionOwners.spec.js new file mode 100644 index 000000000..399b54f21 --- /dev/null +++ b/plugins/azure/defender/monitorSubscriptionOwners.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var monitorSubscriptionOwners = require('./monitorSubscriptionOwners'); + +const policyAssignments = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/456', + 'displayName': 'Test Policy', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': '456', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'identityDesignateLessThanOwnersMonitoringEffect': { + 'value': 'AuditIfNotExists' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'identityDesignateLessThanOwnersMonitoringEffect': { + 'value': 'Disabled' + } + } + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn', + 'displayName': 'ASC Default (subscription: 123)', + 'type': 'Microsoft.Authorization/policyAssignments', + 'name': 'SecurityCenterBuiltIn', + 'location': 'eastus', + 'parameters': { + 'testParam': { + 'value': 'Disabled' + } + } + } +]; + +const createCache = (policyAssignments) => { + let assignment = {}; + if (policyAssignments) { + assignment['data'] = policyAssignments; + } + return { + policyAssignments: { + list: { + 'eastus': assignment + } + }, + }; +}; + +describe('monitorSubscriptionOwners', function() { + describe('run', function() { + it('should give passing result if no policy assignments', function(done) { + const cache = createCache([]); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Policy Assignments', function(done) { + const cache = createCache(null); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor for Total Number of Subscription Owners is enabled', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Monitor for Total Number of Subscription Owners is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor for Total Number of Subscription Owners is disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Monitor for Total Number of Subscription Owners is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if external accounts with write permissions monitoring is enabled by default', function (done) { + const cache = createCache([policyAssignments[3]]); + monitorSubscriptionOwners.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Monitor for Total Number of Subscription Owners is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorSystemUpdates.js b/plugins/azure/defender/monitorSystemUpdates.js similarity index 68% rename from plugins/azure/securitycenter/monitorSystemUpdates.js rename to plugins/azure/defender/monitorSystemUpdates.js index 19089d5e8..83d36c78b 100644 --- a/plugins/azure/securitycenter/monitorSystemUpdates.js +++ b/plugins/azure/defender/monitorSystemUpdates.js @@ -3,16 +3,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor System Updates', - category: 'Security Center', - description: 'Ensures that Monitor System Updates is enabled in Security Center', - more_info: 'When this setting is enabled, Security Center will audit virtual machines for pending OS or system updates.', - recommended_action: 'Ensure System Update monitoring is configured for virtual machines from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Monitor System Updates is enabled in Microsoft Defender.', + more_info: 'When this setting is enabled, Microsoft Defender for Cloud will audit virtual machines for pending OS or system updates.', + recommended_action: 'Ensure System Update monitoring is configured for virtual machines from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', apis: ['policyAssignments:list'], compliance: { pci: 'PCI requires all system components have the latest updates ' + 'and patches installed within a month of release.' }, + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/monitorSystemUpdates.spec.js b/plugins/azure/defender/monitorSystemUpdates.spec.js new file mode 100644 index 000000000..374fc209b --- /dev/null +++ b/plugins/azure/defender/monitorSystemUpdates.spec.js @@ -0,0 +1,272 @@ +var expect = require('chai').expect; +var monitorSystemUpdates = require('./monitorSystemUpdates'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Audit" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Audit" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "Disabled" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorSystemUpdates', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorSystemUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorSystemUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorSystemUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor System Updates enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorSystemUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor System Updates disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorSystemUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/securityConfigMonitoring.js b/plugins/azure/defender/securityConfigMonitoring.js similarity index 65% rename from plugins/azure/securitycenter/securityConfigMonitoring.js rename to plugins/azure/defender/securityConfigMonitoring.js index 39086df91..739a40b76 100644 --- a/plugins/azure/securitycenter/securityConfigMonitoring.js +++ b/plugins/azure/defender/securityConfigMonitoring.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Security Configuration Monitoring', - category: 'Security Center', - description: 'Ensures that Security Configuration Monitoring is enabled in Security Center', - more_info: 'When this setting is enabled, Security Center will monitor virtual machines for security configurations.', - recommended_action: 'Ensure Security Configuration Monitoring is configured for virtual machines from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/governance/policy/overview', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Security Configuration Monitoring is enabled in Microsoft Defender.', + more_info: 'When this setting is enabled, Microsoft Defender for Cloud will monitor virtual machines for security configurations.', + recommended_action: 'Ensure Security Configuration Monitoring is configured for virtual machines from the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/securityConfigMonitoring.spec.js b/plugins/azure/defender/securityConfigMonitoring.spec.js new file mode 100644 index 000000000..7db939aab --- /dev/null +++ b/plugins/azure/defender/securityConfigMonitoring.spec.js @@ -0,0 +1,272 @@ +var expect = require('chai').expect; +var securityConfigMonitoring = require('./securityConfigMonitoring'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Audit" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Audit" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Audit" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "Disabled" + }, + "systemConfigurationsMonitoringEffect": { + "value": "Disabled" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('securityConfigMonitoring', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + securityConfigMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + securityConfigMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + securityConfigMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Security Configuration enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + securityConfigMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Security Configuration disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + securityConfigMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/securityContactAdditionalEmail.js b/plugins/azure/defender/securityContactAdditionalEmail.js new file mode 100644 index 000000000..e669d7302 --- /dev/null +++ b/plugins/azure/defender/securityContactAdditionalEmail.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Security Contact Additional Email', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensure Additional email addresses are configured with security contact email.', + more_info: 'Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact\'s email address to the Additional email addresses field ensures that your organization\'s Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.', + recommended_action: 'Modify security contact information and add additional emails.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications', + apis: ['securityContactv2:listAll'], + realtime_triggers: ['microsoftsecurity:securitycontacts:write','microsoftsecurity:securitycontacts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.securityContacts, (location, rcb) => { + + var securityContacts = helpers.addSource(cache, source, + ['securityContactv2', 'listAll', location]); + + if (!securityContacts) return rcb(); + + if (securityContacts.err || !securityContacts.data) { + helpers.addResult(results, 3, + 'Unable to query for security contacts: ' + helpers.addError(securityContacts), location); + return rcb(); + } + + if (!securityContacts.data.length) { + helpers.addResult(results, 2, 'No existing security contacts', location); + return rcb(); + } + + let additionalEmails = securityContacts.data.find(contact => contact.emails && contact.emails.length); + + if (additionalEmails){ + helpers.addResult(results, 0, 'Additional email address is configured with security contact email', location); + } else { + helpers.addResult(results, 2, 'Additional email address is not configured with security contact email', location); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/securityContactAdditionalEmail.spec.js b/plugins/azure/defender/securityContactAdditionalEmail.spec.js new file mode 100644 index 000000000..84e7de6f7 --- /dev/null +++ b/plugins/azure/defender/securityContactAdditionalEmail.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var securityContactAdditionalEmail = require('./securityContactAdditionalEmail'); + +const securityContacts = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'On', + 'emails': 'xyz@gmail.com;abc@email.com' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'Off', + 'emails': '' + } +]; + +const createCache = (securityContacts) => { + return { + securityContactv2: { + listAll: { + global:{ + data: securityContacts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + securityContactv2: { + listAll: { + global: {} + } + } + }; +}; + +describe('securityContactAdditionalEmail', function() { + describe('run', function() { + it('should give failing result if no security contacts', function(done) { + const cache = createCache([]); + securityContactAdditionalEmail.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for security contacts', function(done) { + const cache = createErrorCache(); + securityContactAdditionalEmail.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if additional email is configured', function(done) { + const cache = createCache([securityContacts[0]]); + securityContactAdditionalEmail.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Additional email address is configured with security contact email'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if additional email is not configured', function(done) { + const cache = createCache([securityContacts[1]]); + securityContactAdditionalEmail.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Additional email address is not configured with security contact email'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/defender/securityContactRoleSetToOwner.js b/plugins/azure/defender/securityContactRoleSetToOwner.js new file mode 100644 index 000000000..aaa5bfb61 --- /dev/null +++ b/plugins/azure/defender/securityContactRoleSetToOwner.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Security Contact Enabled for Subscription Owner', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that security alert emails are enabled to subscription owners.', + more_info: 'Enabling security alert emails to subscription owners ensures that they receive security alert emails from Microsoft. This ensures that they are aware of any potential security issues and can mitigate the risk in a timely fashion.', + recommended_action: 'Modify security contact information and enable emails for subscription owners', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications', + apis: ['securityContactv2:listAll'], + realtime_triggers: ['microsoftsecurity:securitycontacts:write','microsoftsecurity:securitycontacts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.securityContactv2, (location, rcb) => { + + var securityContacts = helpers.addSource(cache, source, + ['securityContactv2', 'listAll', location]); + if (!securityContacts) return rcb(); + + if (securityContacts.err || !securityContacts.data) { + helpers.addResult(results, 3, + 'Unable to query for security contacts: ' + helpers.addError(securityContacts), location); + return rcb(); + } + + if (!securityContacts.data.length) { + helpers.addResult(results, 2, 'No existing security contacts', location); + return rcb(); + } + let ownerExists; + for (let contact of securityContacts.data){ + if (!contact.id) continue; + if (contact.notificationsByRole && contact.notificationsByRole.roles && contact.notificationsByRole.roles.includes('Owner')){ + ownerExists = true; + break; + } + } + + if (ownerExists) { + helpers.addResult(results, 0, 'Security Contact email is configured for subscription owners', location); + } else { + helpers.addResult(results, 2, 'Security Contact email is not configured for subscription owners', location); + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/defender/securityContactRoleSetToOwner.spec.js b/plugins/azure/defender/securityContactRoleSetToOwner.spec.js new file mode 100644 index 000000000..3cd4340e4 --- /dev/null +++ b/plugins/azure/defender/securityContactRoleSetToOwner.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var securityContactRoleSetToOwner = require('./securityContactRoleSetToOwner'); + +const securityContacts = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'On', + 'email': 'xyz@gmail.com', + + "notificationsByRole": { + "state": "On", + "roles": [ + "Owner", + ] + }, + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'Off', + 'email': '', + + "notificationsByRole": { + "state": "On", + "roles": [ + "Admin" + ] + } + } +]; + +const createCache = (securityContacts) => { + return { + securityContactv2: { + listAll: { + global:{ + data: securityContacts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + securityContactv2: { + listAll: { + global: {} + } + } + }; +}; + +describe('securityContactRoleSetToOwner', function() { + describe('run', function() { + it('should give failing result if no security contacts', function(done) { + const cache = createCache([]); + securityContactRoleSetToOwner.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for security contacts', function(done) { + const cache = createErrorCache(); + securityContactRoleSetToOwner.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if Security Contact email is configured for subscription owners', function(done) { + const cache = createCache([securityContacts[0]]); + securityContactRoleSetToOwner.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security Contact email is configured for subscription owners'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Security Contact email is not configured for subscription owners', function(done) { + const cache = createCache([securityContacts[1]]); + securityContactRoleSetToOwner.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security Contact email is not configured for subscription owners'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/securityContactsEnabled.js b/plugins/azure/defender/securityContactsEnabled.js similarity index 85% rename from plugins/azure/securitycenter/securityContactsEnabled.js rename to plugins/azure/defender/securityContactsEnabled.js index 4a16b002e..5d868c5ce 100644 --- a/plugins/azure/securitycenter/securityContactsEnabled.js +++ b/plugins/azure/defender/securityContactsEnabled.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Security Contacts Enabled', - category: 'Security Center', - description: 'Ensures that security contact phone number and email address are set', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that security contact phone number and email address are set.', more_info: 'Setting security contacts ensures that any security incidents detected by Azure are sent to a security team equipped to handle the incident.', - recommended_action: 'Ensure that email notifications are configured for the subscription from the Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details', + recommended_action: 'Ensure that email notifications are configured for the subscription from the Microsoft Defender for Cloud.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications', apis: ['securityContacts:list'], + realtime_triggers: ['microsoftsecurity:securitycontacts:write','microsoftsecurity:securitycontacts:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/defender/securityContactsEnabled.spec.js b/plugins/azure/defender/securityContactsEnabled.spec.js new file mode 100644 index 000000000..25e29fa97 --- /dev/null +++ b/plugins/azure/defender/securityContactsEnabled.spec.js @@ -0,0 +1,141 @@ +var expect = require('chai').expect; +var securityContactsEnabled = require('./securityContactsEnabled'); + +const securityContacts = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'email': 'test@test.com' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'phone': '0123456789' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'email': 'test@test.com', + 'phone': '0123456789' + } +]; + +const createCache = (securityContacts) => { + return { + securityContacts: { + list: { + global:{ + data: securityContacts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + securityContacts: { + list: { + global: {} + } + } + }; +}; + +describe('securityContactsEnabled', function() { + describe('run', function() { + it('should give failing result if no security contacts', function(done) { + const cache = createCache([]); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for security contacts', function(done) { + const cache = createErrorCache(); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if security contact has both phone and email set', function(done) { + const cache = createCache([securityContacts[3]]); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + //Phone verification + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security Contact phone number is set on the subscription'); + //Email verification + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Security Contact email address is set on the subscription'); + + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result for missing phone number', function(done) { + const cache = createCache([securityContacts[1]]); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + //Phone verification + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security Contact phone number is not set on the subscription'); + //Email verification + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Security Contact email address is set on the subscription'); + + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result for missing email', function(done) { + const cache = createCache([securityContacts[2]]); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + //Phone verification + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security Contact phone number is set on the subscription'); + //Email verification + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Security Contact email address is not set on the subscription'); + + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result for missing email and phone number', function(done) { + const cache = createCache([securityContacts[0]]); + securityContactsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + //Phone verification + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security Contact phone number is not set on the subscription'); + //Email verification + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Security Contact email address is not set on the subscription'); + + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/standardPricingEnabled.js b/plugins/azure/defender/standardPricingEnabled.js similarity index 81% rename from plugins/azure/securitycenter/standardPricingEnabled.js rename to plugins/azure/defender/standardPricingEnabled.js index c9180e062..761752d7a 100644 --- a/plugins/azure/securitycenter/standardPricingEnabled.js +++ b/plugins/azure/defender/standardPricingEnabled.js @@ -3,13 +3,16 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Standard Pricing Enabled', - category: 'Security Center', - description: 'Ensures that standard pricing is enabled in the security center', - more_info: 'Enabling standard pricing increases the security posture of the subscription. This enables advanced security monitoring for the services covered under the security center.', - recommended_action: 'Ensure that standard pricing is enabled in the security center.', - link: 'https://azure.microsoft.com/en-us/pricing/details/security-center/', + category: 'Defender', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that standard pricing is enabled in the Microsoft Defender.', + more_info: 'Enabling standard pricing increases the security posture of the subscription. This enables advanced security monitoring for the services covered under the Microsoft Defender for Cloud.', + recommended_action: 'Ensure that standard pricing is enabled in the Microsoft Defender.', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference', apis: ['pricings:list'], - + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + run: function(cache, settings, callback) { var results = []; var source = {}; diff --git a/plugins/azure/securitycenter/standardPricingEnabled.spec.js b/plugins/azure/defender/standardPricingEnabled.spec.js similarity index 100% rename from plugins/azure/securitycenter/standardPricingEnabled.spec.js rename to plugins/azure/defender/standardPricingEnabled.spec.js diff --git a/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js new file mode 100644 index 000000000..614cea0ce --- /dev/null +++ b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js @@ -0,0 +1,47 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Azure Entra ID App Organizational Directory Access', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that Azure Entra Id applications are accessible to accounts in organisational directory only.', + more_info: 'Entra ID provides different types of account access. By using single-tenant authentication, the impact gets limited to the application’s tenant i.e. all users from the same tenant could connect to the application and save app from unauthorised access.', + link: 'https://learn.microsoft.com/en-us/entra/identity-platform/single-and-multi-tenant-apps', + recommended_action: 'Modify the Azure app authentication setting and provide access to accounts in organisational directory only', + apis: ['applications:list'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.applications, function(location, rcb) { + const applications = helpers.addSource(cache, source, + ['applications', 'list', location]); + + if (!applications) return rcb(); + + if (applications.err || !applications.data) { + helpers.addResult(results, 3, 'Unable to query for Entra ID applications: ' + helpers.addError(applications), location); + return rcb(); + } + if (!applications.data.length) { + helpers.addResult(results, 0, 'No existing Entra ID applications found', location); + return rcb(); + } + for (let app of applications.data) { + if (!app.appId) continue; + + if (app.signInAudience && app.signInAudience === 'AzureADMultipleOrgs' || app.signInAudience === 'AzureADandPersonalMicrosoftAccount'){ + helpers.addResult(results, 2, 'Entra ID application has multi-tenant access enabled', location, app.appId); + } else { + helpers.addResult(results, 0, 'Entra ID application has single-tenant access enabled', location, app.appId); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js new file mode 100644 index 000000000..d00f401b2 --- /dev/null +++ b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var auth = require('./appOrgnaizationalDirectoryAccess'); +const apps = [ + { + id: '2f4c228d-8d30-47a5-92ee-fb70bafa4bbc3', + deletedDateTime: null, + appId: '19a72cec-22a4-477c-a1db-fb70bafa4bbc3', + applicationTemplateId: null, + disabledByMicrosoftStatus: null, + createdDateTime: '2023-03-19T03:21:07Z', + displayName: 'test-app', + description: null, + publisherDomain: 'khulnasoftdeveloper.onmicrosoft.com', + serviceManagementReference: null, + signInAudience: 'AzureADMultipleOrgs' + }, + { + id: '2f4c228d-8d30-47a5-92ee-fb70bafa4bbc3', + deletedDateTime: null, + appId: '19a72cec-22a4-477c-a1db-fb70bafa4bbc3', + applicationTemplateId: null, + disabledByMicrosoftStatus: null, + createdDateTime: '2023-03-19T03:21:07Z', + displayName: 'test-app', + description: null, + publisherDomain: 'khulnasoftdeveloper.onmicrosoft.com', + serviceManagementReference: null, + signInAudience: 'AzureADMyOrg' + } + +] +const createCache = (err, data) => { + return { + applications: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('appOrgnaizationalDirectoryAccess', function() { + describe('run', function() { + it('should give passing result if no applications', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Entra ID applications found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + it('should give unknown result if unable to query for applications', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Entra ID applications:'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + {}, + null + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if application has multi tenant access', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Entra ID application has multi-tenant access enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache(null,[apps[0]]); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if application is accessible to single tenant only', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Entra ID application has single-tenant access enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache(null,[apps[1]]); + + auth.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/azure/activedirectory/ensureNoGuestUser.js b/plugins/azure/entraid/ensureNoGuestUser.js similarity index 88% rename from plugins/azure/activedirectory/ensureNoGuestUser.js rename to plugins/azure/entraid/ensureNoGuestUser.js index 1e635988d..5116192d1 100644 --- a/plugins/azure/activedirectory/ensureNoGuestUser.js +++ b/plugins/azure/entraid/ensureNoGuestUser.js @@ -3,11 +3,13 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Ensure No Guest User', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that there are no guest users in the subscription', more_info: 'Guest users are usually users that are invited from outside the company structure, these users are not part of the onboarding/offboarding process and could be overlooked, causing security vulnerabilities.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/b2b/add-users-administrator', - recommended_action: 'Remove all guest users unless they are required to be members of the Active Directory account.', + link: 'https://learn.microsoft.com/en-us/entra/external-id/add-users-administrator', + recommended_action: 'Remove all guest users unless they are required to be members of the Entra ID tenant.', apis: ['users:list'], run: function(cache, settings, callback) { diff --git a/plugins/azure/activedirectory/ensureNoGuestUser.spec.js b/plugins/azure/entraid/ensureNoGuestUser.spec.js similarity index 100% rename from plugins/azure/activedirectory/ensureNoGuestUser.spec.js rename to plugins/azure/entraid/ensureNoGuestUser.spec.js diff --git a/plugins/azure/activedirectory/minPasswordLength.js b/plugins/azure/entraid/minPasswordLength.js similarity index 84% rename from plugins/azure/activedirectory/minPasswordLength.js rename to plugins/azure/entraid/minPasswordLength.js index e730ca32e..b02b799ae 100644 --- a/plugins/azure/activedirectory/minPasswordLength.js +++ b/plugins/azure/entraid/minPasswordLength.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Minimum Password Length', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures that all Azure passwords require a minimum length', more_info: 'Azure handles most password policy settings, including the minimum password length, defaulted to 8 characters.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/noCustomOwnerRoles.js b/plugins/azure/entraid/noCustomOwnerRoles.js similarity index 94% rename from plugins/azure/activedirectory/noCustomOwnerRoles.js rename to plugins/azure/entraid/noCustomOwnerRoles.js index 7a5081332..a6c6a0cf6 100644 --- a/plugins/azure/activedirectory/noCustomOwnerRoles.js +++ b/plugins/azure/entraid/noCustomOwnerRoles.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'No Custom Owner Roles', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that no custom owner roles exist.', more_info: 'Subscription owners should not include permissions to create custom owner roles. This follows the principle of least privilege.', - link: 'https://docs.microsoft.com/en-us/azure/role-based-access-control/custom-roles', + link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/custom-roles', recommended_action: 'Remove roles that allow permissions to create custom owner roles.', apis: ['roleDefinitions:list'], diff --git a/plugins/azure/activedirectory/noCustomOwnerRoles.spec.js b/plugins/azure/entraid/noCustomOwnerRoles.spec.js similarity index 100% rename from plugins/azure/activedirectory/noCustomOwnerRoles.spec.js rename to plugins/azure/entraid/noCustomOwnerRoles.spec.js diff --git a/plugins/azure/activedirectory/passwordRequiresLowercase.js b/plugins/azure/entraid/passwordRequiresLowercase.js similarity index 85% rename from plugins/azure/activedirectory/passwordRequiresLowercase.js rename to plugins/azure/entraid/passwordRequiresLowercase.js index 756ee7cff..bfa1183dd 100644 --- a/plugins/azure/activedirectory/passwordRequiresLowercase.js +++ b/plugins/azure/entraid/passwordRequiresLowercase.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Lowercase', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures that all Azure passwords require lowercase characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresNumbers.js b/plugins/azure/entraid/passwordRequiresNumbers.js similarity index 85% rename from plugins/azure/activedirectory/passwordRequiresNumbers.js rename to plugins/azure/entraid/passwordRequiresNumbers.js index 75183bed3..d15367385 100644 --- a/plugins/azure/activedirectory/passwordRequiresNumbers.js +++ b/plugins/azure/entraid/passwordRequiresNumbers.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Numbers', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures that all Azure passwords require numbers', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresSymbols.js b/plugins/azure/entraid/passwordRequiresSymbols.js similarity index 85% rename from plugins/azure/activedirectory/passwordRequiresSymbols.js rename to plugins/azure/entraid/passwordRequiresSymbols.js index dcd334647..a35a8e33c 100644 --- a/plugins/azure/activedirectory/passwordRequiresSymbols.js +++ b/plugins/azure/entraid/passwordRequiresSymbols.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Symbols', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures that all Azure passwords require symbol characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresUppercase.js b/plugins/azure/entraid/passwordRequiresUppercase.js similarity index 85% rename from plugins/azure/activedirectory/passwordRequiresUppercase.js rename to plugins/azure/entraid/passwordRequiresUppercase.js index 7b62eafb7..c5c4f71fd 100644 --- a/plugins/azure/activedirectory/passwordRequiresUppercase.js +++ b/plugins/azure/entraid/passwordRequiresUppercase.js @@ -3,10 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Uppercase', - category: 'Active Directory', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures that all Azure passwords require uppercase characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/eventGrid/domainDiagnosticLogs.js b/plugins/azure/eventGrid/domainDiagnosticLogs.js new file mode 100644 index 000000000..d65d4a14f --- /dev/null +++ b/plugins/azure/eventGrid/domainDiagnosticLogs.js @@ -0,0 +1,64 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Grid Domain Diagnostic Logs', + category: 'Event Grid', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Azure Event Grid domain has diagnostic logs enabled.', + more_info: 'Enabling diagnostics logs for Event Grid domains helps to capture failures and detailed information about events, their sources, and the health of the Event Grid service.This helps identifying potential security threats, providing essential insights for effective management and security of your environment.', + recommended_action: 'Enable diagnostic logs for all the Event Grid domains.', + link: 'https://learn.microsoft.com/en-us/azure/event-grid/enable-diagnostic-logs-topic', + apis: ['eventGrid:listDomains','diagnosticSettings:listByEventGridDomains'], + realtime_triggers: ['microsofteventgrid:domains:write', 'microsofteventgrid:domains:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.eventGrid, (location, rcb) => { + const domains = helpers.addSource(cache, source, + ['eventGrid', 'listDomains', location]); + + if (!domains) return rcb(); + + if (domains.err || !domains.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Grid domains: ' + helpers.addError(domains), location); + return rcb(); + } + + if (!domains.data.length) { + helpers.addResult(results, 0, 'No Event Grid domains found', location); + return rcb(); + } + + for (let domain of domains.data) { + if (!domain.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByEventGridDomains', location, domain.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Event Grid domains diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, domain.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Event Grid domain has diagnostic logs enabled', location, domain.id); + } else { + helpers.addResult(results, 2, 'Event Grid domain does not have diagnostic logs enabled', location, domain.id); + } + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/eventGrid/domainDiagnosticLogs.spec.js b/plugins/azure/eventGrid/domainDiagnosticLogs.spec.js new file mode 100644 index 000000000..4fd507eb3 --- /dev/null +++ b/plugins/azure/eventGrid/domainDiagnosticLogs.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +var domainDiagnosticLogs = require('./domainDiagnosticLogs'); + +const eventGridDomains = [ + { + "properties": { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded" + }, + "id": "/subscriptions/1234/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "westus2", + "name": "exampledomain1", + "publicNetworkAccess": "Enabled" + } +]; + +const diagnosticSettings = [ + { + + "id": "/subscriptions/subid/resourcegroups/rg1/providers/microsoft.eventgrid/domains/domain/providers/microsoft.insights/diagnosticSettings/testlogs", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "testlogs", + "location": null, + "kind": null, + "tags": null, + "storageAccountId": null, + "logs": [ + { + "category": "deliveryFailureLogs", + "categoryGroup": null, + "enabled": true, + } + ], + "logAnalyticsDestinationType": null, + + "identity": null + } + +]; + +const createCache = (eventGridDomains, ds) => { + const id = eventGridDomains && eventGridDomains.length ? eventGridDomains[0].id : null; + return { + eventGrid: { + listDomains: { + 'eastus': { + data: eventGridDomains + } + } + }, + diagnosticSettings: { + listByEventGridDomains: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + eventGrid: { + listDomains: { + 'eastus': {} + } + } + }; +}; + +describe('domainDiagnosticLogs', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Event Grid domains:', function (done) { + const cache = createCache(null); + domainDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Grid domains:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no event grid domains exist', function (done) { + const cache = createCache([]); + domainDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Grid domains found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([eventGridDomains[0]], null); + domainDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Grid domains diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Event Grid domain has diagnostic logs enabled', function (done) { + const cache = createCache([eventGridDomains[0]], [diagnosticSettings[0]]); + domainDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Grid domain has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Event Grid domain does not have diagnostic logs enabled', function (done) { + const cache = createCache([eventGridDomains[0]],[[]]); + domainDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Grid domain does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/eventGrid/domainLocalAuthDisabled.js b/plugins/azure/eventGrid/domainLocalAuthDisabled.js new file mode 100644 index 000000000..e065a902d --- /dev/null +++ b/plugins/azure/eventGrid/domainLocalAuthDisabled.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Grid Domain Local Authentication Disabled', + category: 'Event Grid', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensures that local authentication is disabled for Event Grid domains.', + more_info: 'For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication (shared access policies) for Azure Event Grid.', + recommended_action: 'Ensure that Event Grid domains have local authentication disabled.', + link: 'https://learn.microsoft.com/en-us/azure/event-grid/authenticate-with-microsoft-entra-id#disable-key-and-shared-access-signature-authentication', + apis: ['eventGrid:listDomains'], + realtime_triggers: ['microsofteventgrid:domains:write', 'microsofteventgrid:domains:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.eventGrid, (location, rcb) => { + const domains = helpers.addSource(cache, source, + ['eventGrid', 'listDomains', location]); + + if (!domains) return rcb(); + + if (domains.err || !domains.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Grid domains: ' + helpers.addError(domains), location); + return rcb(); + } + + if (!domains.data.length) { + helpers.addResult(results, 0, 'No Event Grid domains found', location); + return rcb(); + } + + for (let domain of domains.data) { + if (!domain.id) continue; + + if (domain.disableLocalAuth) { + helpers.addResult(results, 0, 'Event Grid domain has local authentication disabled', location, domain.id); + } else { + helpers.addResult(results, 2, 'Event Grid domain has local authentication enabled', location, domain.id); + } + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/eventGrid/domainLocalAuthDisabled.spec.js b/plugins/azure/eventGrid/domainLocalAuthDisabled.spec.js new file mode 100644 index 000000000..c5a4ac142 --- /dev/null +++ b/plugins/azure/eventGrid/domainLocalAuthDisabled.spec.js @@ -0,0 +1,82 @@ +var expect = require("chai").expect; +var domainLocalAuthDisabled = require("./domainLocalAuthDisabled"); + +const domains = [ + { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded", + "id": "/subscriptions/8f6b6269-84f2-4d09-9e31-1127efcd1e40/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "eastus", + "name": "exampledomain1", + "publicNetworkAccess": "Enabled", + "disableLocalAuth": false, + + }, + { + "id": "/subscriptions/8f6b6269-84f2-4d09-9e31-1127efcd1e40/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "eastus", + "name": "exampledomain1", + "publicNetworkAccess": "Disabled", + "disableLocalAuth": true, + + } +] +const createCache = (data) => { + return { + eventGrid: { + listDomains: { + 'eastus': { + data: data, + }, + }, + }, + }; +}; + +describe("domainLocalAuthDisabled", function () { + describe("run", function () { + it("should give Passing result if no domain found", function (done) { + const cache = createCache([]); + domainLocalAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No Event Grid domains found"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give unknown result if unable to query for domains", function (done) { + const cache = createCache(null); + domainLocalAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Event Grid domains:"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give passing result if Event Grid domain has local authentication disabled", function (done) { + const cache = createCache([domains[1]]); + domainLocalAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Event Grid domain has local authentication disabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give passing result if Event Grid domain has local authentication enabled", function (done) { + const cache = createCache([domains[0]]); + domainLocalAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("Event Grid domain has local authentication enabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/eventGrid/domainManagedIdentity.js b/plugins/azure/eventGrid/domainManagedIdentity.js new file mode 100644 index 000000000..29bbb2f34 --- /dev/null +++ b/plugins/azure/eventGrid/domainManagedIdentity.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Grid Domain Managed Identity', + category: 'Event Grid', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Event Grid domains have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Event Grid domains and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/event-grid/managed-service-identity', + apis: ['eventGrid:listDomains'], + realtime_triggers: ['microsofteventgrid:domains:write', 'microsofteventgrid:domains:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.eventGrid, (location, rcb) => { + const domains = helpers.addSource(cache, source, + ['eventGrid', 'listDomains', location]); + + if (!domains) return rcb(); + + if (domains.err || !domains.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Grid domains: ' + helpers.addError(domains), location); + return rcb(); + } + + if (!domains.data.length) { + helpers.addResult(results, 0, 'No Event Grid domains found', location); + return rcb(); + } + + for (let domain of domains.data) { + if (!domain.id) continue; + + if (domain.identity && domain.identity.type + && (domain.identity.type.toLowerCase() === 'userassigned' || domain.identity.type.toLowerCase() === 'systemassigned')) { + helpers.addResult(results, 0, 'Event Grid domain has managed identity enabled', location, domain.id); + } else { + helpers.addResult(results, 2, 'Event Grid domain does not have managed identity enabled', location, domain.id); + } + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/eventGrid/domainManagedIdentity.spec.js b/plugins/azure/eventGrid/domainManagedIdentity.spec.js new file mode 100644 index 000000000..929139958 --- /dev/null +++ b/plugins/azure/eventGrid/domainManagedIdentity.spec.js @@ -0,0 +1,88 @@ +var expect = require("chai").expect; +var domainManagedIdentity = require("./domainManagedIdentity"); + +const domains = [ + { + "id": "/subscriptions/1234/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "eastus", + "name": "exampledomain1", + "publicNetworkAccess": "Enabled", + "identity": { + "type": "None", + "principalId": null, + "tenantId": null, + "userAssignedIdentities": null + }, + }, + { + "id": "/subscriptions/1234/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "eastus", + "name": "exampledomain1", + "publicNetworkAccess": "Disabled", + "identity": { + "type": "SystemAssigned", + "principalId": "12345", + "tenantId": "1243567", + "userAssignedIdentities": null + }, + } +] +const createCache = (data) => { + return { + eventGrid: { + listDomains: { + 'eastus': { + data: data, + }, + }, + }, + }; +}; + +describe("domainManagedIdentity", function () { + describe("run", function () { + it("should give passing result if no domain found", function (done) { + const cache = createCache([]); + domainManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No Event Grid domains found"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give unknown result if unable to query for domains", function (done) { + const cache = createCache(null); + domainManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Event Grid domains:"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give failing result if event grid domain does not have managed identity enabled", function (done) { + const cache = createCache([domains[0]]); + domainManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("Event Grid domain does not have managed identity enabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give passing result if event grid domain has managed identity enabled", function (done) { + const cache = createCache([domains[1]]); + domainManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Event Grid domain has managed identity enabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/eventGrid/domainMinimumTlsVersion.js b/plugins/azure/eventGrid/domainMinimumTlsVersion.js new file mode 100644 index 000000000..5ccd3bdb4 --- /dev/null +++ b/plugins/azure/eventGrid/domainMinimumTlsVersion.js @@ -0,0 +1,58 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Grid Domain Minimum TLS Version', + category: 'Event Grid', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Azure Event Grid domain is using the latest TLS version.', + more_info: 'Using latest TLS version for Event Grid domains enforces strict security measures, which requires that clients send and receive data with a newer version of TLS. Azure Event Grid uses TLS 1.2 on public endpoints by default.', + recommended_action: 'Ensure that Event Grid domain is using latest TLS version.', + link: 'https://learn.microsoft.com/en-us/azure/event-grid/transport-layer-security-configure-minimum-version', + apis: ['eventGrid:listDomains'], + realtime_triggers: ['microsofteventgrid:domains:write', 'microsofteventgrid:domains:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + const tlsVersion = 1.2; + + async.each(locations.eventGrid, function(location, rcb) { + const domains = helpers.addSource(cache, source, + ['eventGrid', 'listDomains', location]); + + if (!domains) return rcb(); + + if (domains.err || !domains.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Grid domains: ' + helpers.addError(domains), location); + return rcb(); + } + + if (!domains.data.length) { + helpers.addResult(results, 0, 'No Event Grid domains found', location); + return rcb(); + } + + for (let domain of domains.data){ + if (!domain.id) continue; + + if (domain.minimumTlsVersionAllowed && parseFloat(domain.minimumTlsVersionAllowed) >= tlsVersion) { + helpers.addResult(results, 0, + `Event Grid domain is using latest TLS version: ${domain.minimumTlsVersionAllowed}`, + location, domain.id); + } else { + helpers.addResult(results, 2, + `Event Grid domain is not using latest TLS version of ${tlsVersion}`, + location, domain.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventGrid/domainMinimumTlsVersion.spec.js b/plugins/azure/eventGrid/domainMinimumTlsVersion.spec.js new file mode 100644 index 000000000..a28e8cb8c --- /dev/null +++ b/plugins/azure/eventGrid/domainMinimumTlsVersion.spec.js @@ -0,0 +1,85 @@ +var expect = require("chai").expect; +var domainMinimumTlsVersion = require("./domainMinimumTlsVersion"); + +const domains = [ + { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded", + "id": "/subscriptions/1234/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "westus2", + "name": "exampledomain1", + "publicNetworkAccess": "Enabled", + "minimumTlsVersionAllowed": "1.2", + }, + { + "properties": { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded" + }, + "id": "/subscriptions/1234/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "westus2", + "name": "exampledomain1", + "publicNetworkAccess": "Disabled", + "minimumTlsVersionAllowed": "1.1", + } +] +const createCache = (data) => { + return { + eventGrid: { + listDomains: { + 'eastus': { + data: data, + }, + }, + }, + }; +}; + +describe("domainMinimumTlsVersion", function () { + describe("run", function () { + it("should give passing result if no domain found", function (done) { + const cache = createCache([]); + domainMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No Event Grid domains found"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give unknown result if unable to query for domains", function (done) { + const cache = createCache(null); + domainMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Event Grid domains:"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give passing result if Event Grid domain is using TLS version 1.2", function (done) { + const cache = createCache([domains[0]]); + domainMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Event Grid domain is using latest TLS version: 1.2"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give failing result if event grid domain is not using desired tls version", function (done) { + const cache = createCache([domains[1]]); + domainMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("Event Grid domain is not using latest TLS version"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + }); +}); diff --git a/plugins/azure/eventGrid/domainPublicAccess.js b/plugins/azure/eventGrid/domainPublicAccess.js new file mode 100644 index 000000000..3db87c8cc --- /dev/null +++ b/plugins/azure/eventGrid/domainPublicAccess.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Grid Domain Public Access', + category: 'Event Grid', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Azure Event Grid domains are not publicly accessible.', + more_info: 'By default, domains are accessible from internet as long as the request comes with valid authentication and authorization exposing sensitive information. By disabling public access, Event Grid domains can be configured to use private endpoint.', + recommended_action: 'Modify the affected domains and disable public network access.', + link: 'https://learn.microsoft.com/en-us/azure/event-grid/configure-firewall', + apis: ['eventGrid:listDomains'], + realtime_triggers: ['microsofteventgrid:domains:write', 'microsofteventgrid:domains:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.eventGrid, (location, rcb) => { + const domains = helpers.addSource(cache, source, + ['eventGrid', 'listDomains', location]); + + if (!domains) return rcb(); + + if (domains.err || !domains.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Grid domains: ' + helpers.addError(domains), location); + return rcb(); + } + + if (!domains.data.length) { + helpers.addResult(results, 0, 'No Event Grid domains found', location); + return rcb(); + } + + for (let domain of domains.data) { + if (!domain.id) continue; + + if (domain.publicNetworkAccess && domain.publicNetworkAccess.toLowerCase() === 'enabled') { + helpers.addResult(results, 2, 'Event Grid domain has public network access enabled', location, domain.id); + } else { + helpers.addResult(results, 0, 'Event Grid domain does not have public network access enabled', location, domain.id); + } + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/eventGrid/domainPublicAccess.spec.js b/plugins/azure/eventGrid/domainPublicAccess.spec.js new file mode 100644 index 000000000..fcd6be09d --- /dev/null +++ b/plugins/azure/eventGrid/domainPublicAccess.spec.js @@ -0,0 +1,84 @@ +var expect = require("chai").expect; +var domainPublicAccess = require("./domainPublicAccess"); + +const domains = [ + { + "properties": { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded" + }, + "id": "/subscriptions/8f6b6269-84f2-4d09-9e31-1127efcd1e40/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "westus2", + "name": "exampledomain1", + "publicNetworkAccess": "Enabled" + }, + { + "properties": { + "endpoint": "https://exampledomain1.westus2-1.eventgrid.azure.net/api/events", + "provisioningState": "Succeeded" + }, + "id": "/subscriptions/8f6b6269-84f2-4d09-9e31-1127efcd1e40/resourceGroups/examplerg/providers/Microsoft.EventGrid/domains/exampledomain1", + "location": "westus2", + "name": "exampledomain1", + "publicNetworkAccess": "Disabled" + } +] +const createCache = (data) => { + return { + eventGrid: { + listDomains: { + 'eastus': { + data: data, + }, + }, + }, + }; +}; + +describe("domainPublicAccess", function () { + describe("run", function () { + it("should give Passing result if no domain found", function (done) { + const cache = createCache([]); + domainPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No Event Grid domains found"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give unknown result if unable to query for domains", function (done) { + const cache = createCache(null); + domainPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Event Grid domains:"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give failing result if public access enabled for domains", function (done) { + const cache = createCache([domains[0]]); + domainPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("Event Grid domain has public network access enabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + + it("should give passing result if public access not enabled for domains", function (done) { + const cache = createCache([domains[1]]); + domainPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Event Grid domain does not have public network access enabled"); + expect(results[0].region).to.equal("eastus"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/eventhub/eventHubDiagnosticLogs.js b/plugins/azure/eventhub/eventHubDiagnosticLogs.js new file mode 100644 index 000000000..9680cc534 --- /dev/null +++ b/plugins/azure/eventhub/eventHubDiagnosticLogs.js @@ -0,0 +1,64 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Namespace Diagnostic Logs', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Event Hubs namespace has diagnostic logs enabled.', + more_info: 'Enabling diagnostics logs for Event Hubs namespace helps to gain insights into the service operation and troubleshoot performance issues. This helps identifying security threats and recreate activity trails to use for investigation purposes.', + recommended_action: 'Enable diagnostic logs for all the Event Hubs namespaces.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/monitor-event-hubs', + apis: ['eventHub:listEventHub','diagnosticSettings:listByEventHubs'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByEventHubs', location, eventHub.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Event Hubs namespace diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, eventHub.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Event Hubs namespace has diagnostic logs enabled', location, eventHub.id); + } else { + helpers.addResult(results, 2, 'Event Hubs namespace does not have diagnostic logs enabled', location, eventHub.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubDiagnosticLogs.spec.js b/plugins/azure/eventhub/eventHubDiagnosticLogs.spec.js new file mode 100644 index 000000000..0bb110339 --- /dev/null +++ b/plugins/azure/eventhub/eventHubDiagnosticLogs.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var eventHubDiagnosticLogs = require('./eventHubDiagnosticLogs.js'); + +const eventHub = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false + }, +]; + +const diagnosticSettings = [ + { + + "id": "/subscriptions/subid/resourcegroups/rg1/providers/microsoft.eventHub/domains/domain/providers/microsoft.insights/diagnosticSettings/testlogs", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "testlogs", + "location": null, + "kind": null, + "tags": null, + "storageAccountId": null, + "logs": [ + { + "category": "runTime", + "categoryGroup": null, + "enabled": true, + } + ], + "logAnalyticsDestinationType": null, + + "identity": null + } + +]; + +const createCache = (eventHub, ds) => { + const id = eventHub && eventHub.length ? eventHub[0].id : null; + return { + eventHub: { + listEventHub: { + 'eastus': { + data: eventHub + } + } + }, + diagnosticSettings: { + listByEventHubs: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + eventHub: { + listEventHub: { + 'eastus': {} + } + } + }; +}; + +describe('eventHubDiagnosticLogs', function () { + describe('run', function () { + + it('should give unknown result if unable to query for Event Hubs namespaces:', function (done) { + const cache = createCache(null); + eventHubDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if No Event Hubs namespaces found', function (done) { + const cache = createCache([]); + eventHubDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([eventHub[0]], null); + eventHubDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespace diagnostic settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Event Hubs namespace has diagnostic logs enabled', function (done) { + const cache = createCache([eventHub[0]], [diagnosticSettings[0]]); + eventHubDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Event Hubs namespace does not have diagnostic logs enabled', function (done) { + const cache = createCache([eventHub[0]],[[]]); + eventHubDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubLocalAuthDisabled.js b/plugins/azure/eventhub/eventHubLocalAuthDisabled.js new file mode 100644 index 000000000..cc3d8586d --- /dev/null +++ b/plugins/azure/eventhub/eventHubLocalAuthDisabled.js @@ -0,0 +1,52 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hub Namespace Local Auth Disabled', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensures local authentication is disabled for Event Hub namespace.', + more_info: 'For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication in Azure Event Hubs namespaces.', + recommended_action: 'Ensure that Azure Event Hubs namespaces have local authentication disabled.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/authenticate-shared-access-signature#disabling-localsas-key-authentication', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + if (eventHub.disableLocalAuth) { + helpers.addResult(results, 0, 'Event Hubs namespace has local authentication disabled',location, eventHub.id); + } else { + helpers.addResult(results, 2, 'Event Hubs namespace has local authentication enabled', location, eventHub.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubLocalAuthDisabled.spec.js b/plugins/azure/eventhub/eventHubLocalAuthDisabled.spec.js new file mode 100644 index 000000000..23e112e38 --- /dev/null +++ b/plugins/azure/eventhub/eventHubLocalAuthDisabled.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var eventHubLocalAuthDisabled = require('./eventHubLocalAuthDisabled'); + +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Disabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": false, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + } +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubLocalAuthDisabled', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubLocalAuthDisabled.run(cache, {}, callback); + }); + + it('should give failing result if event hub has local auth enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace has local authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubLocalAuthDisabled.run(cache, {}, callback); + }); + + it('should give passing result if eventHub has local auth disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace has local authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubLocalAuthDisabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubLocalAuthDisabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubManagedIdentity.js b/plugins/azure/eventhub/eventHubManagedIdentity.js new file mode 100644 index 000000000..cde653721 --- /dev/null +++ b/plugins/azure/eventhub/eventHubManagedIdentity.js @@ -0,0 +1,58 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Namespace Managed Identity', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures Microsoft Azure Event Hubs namespaces have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Event Hubs namespace and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/authenticate-managed-identity', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + if (eventHub.sku && eventHub.sku.tier && eventHub.sku.tier.toLowerCase() === 'basic') { + helpers.addResult(results, 0, + 'Event Hubs namespace tier is basic', location, eventHub.id); + } else { + if (eventHub.identity) { + helpers.addResult(results, 0, 'Event Hubs namespace has managed identity enabled', location, eventHub.id); + } else { + helpers.addResult(results, 2, 'Event Hubs namespace does not have managed identity enabled', location, eventHub.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubManagedIdentity.spec.js b/plugins/azure/eventhub/eventHubManagedIdentity.spec.js new file mode 100644 index 000000000..9c1e60d88 --- /dev/null +++ b/plugins/azure/eventhub/eventHubManagedIdentity.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var eventHubManagedIdentity = require('./eventHubManagedIdentity'); +const eventHubPublicAccess = require("./eventHubPublicAccess"); + +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + "identity": { + "principalId": "12345", + "tenantId": "123243546", + "type": "SystemAssigned" + }, + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub2", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "sku": { + "name": "Basic", + "tier": "Basic", + "capacity": 1 + }, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false + }, +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubManagedIdentity.run(cache, {}, callback); + }); + + it('should give failing result if event hub does not have managed identity enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubManagedIdentity.run(cache, {}, callback); + }); + + it('should give passing result if eventHub has managed identity enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubManagedIdentity.run(cache, {}, callback); + }); + + it('should give passing result if eventHub is of basic tier', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace tier is basic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[2]]); + eventHubManagedIdentity.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubManagedIdentity.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/azure/eventhub/eventHubMinimumTLSversion.js b/plugins/azure/eventhub/eventHubMinimumTLSversion.js new file mode 100644 index 000000000..8e10765a1 --- /dev/null +++ b/plugins/azure/eventhub/eventHubMinimumTLSversion.js @@ -0,0 +1,61 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Minimum TLS Version', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures Microsoft Azure Event Hubs namespaces do not allow outdated TLS certificate versions.', + more_info: 'To enforce stricter security measures, you can configure your Event Hubs namespace to require that clients send and receive data with a newer version of TLS.', + recommended_action: 'Modify Event Hubs namespaces to set the desired minimum TLS version.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/transport-layer-security-enforce-minimum-version', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var event_hub_min_tls_version = '1.2'; + + var desiredVersion = parseFloat(event_hub_min_tls_version); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + if (eventHub.minimumTlsVersion && parseFloat(eventHub.minimumTlsVersion) >= desiredVersion) { + helpers.addResult(results, 0, + `Event Hubs namespace is using TLS version ${eventHub.minimumTlsVersion}`, + location, eventHub.id); + } else { + helpers.addResult(results, 2, + `Event Hubs namespace is using TLS version ${eventHub.minimumTlsVersion} instead of version ${event_hub_min_tls_version}`, + location, eventHub.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubMinimumTLSversion.spec.js b/plugins/azure/eventhub/eventHubMinimumTLSversion.spec.js new file mode 100644 index 000000000..7ff204f1a --- /dev/null +++ b/plugins/azure/eventhub/eventHubMinimumTLSversion.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var eventHubMinimumTLSversion = require('./eventHubMinimumTLSversion'); + +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + } +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubMinimumTLSversion', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubMinimumTLSversion.run(cache, {}, callback); + }); + + it('should give failing result if event hub is using TLS version less than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('instead of version 1.2'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubMinimumTLSversion.run(cache, { event_hub_min_tls_version: '1.2' }, callback); + }); + + it('should give passing result if eventHub is using TLS version equal to or higher than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is using TLS version 1.2'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubMinimumTLSversion.run(cache, { event_hub_min_tls_version: '1.0' }, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubMinimumTLSversion.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubNamespaceAutoInflate.js b/plugins/azure/eventhub/eventHubNamespaceAutoInflate.js new file mode 100644 index 000000000..4838725d4 --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceAutoInflate.js @@ -0,0 +1,63 @@ + +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Namespace Auto-Inflate Enabled', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Event Hubs namespaces have Auto-inflate feature enabled.', + more_info: 'Enabling Auto-inflate for your Azure Event Hubs namespace ensures seamless scaling by automatically adjusting the number of throughput units (TUs) based on workload demands. This feature helps prevent throttling issues by scaling up as needed, providing efficient and reliable data handling without manual intervention.', + recommended_action: 'Modify Event Hub namespace and enable auto-inflate feature.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-auto-inflate', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data) { + if (!eventHub.id) continue; + + if (eventHub.sku && + eventHub.sku.tier && + eventHub.sku.tier.toLowerCase() != 'standard') { + helpers.addResult(results, 0, + 'Event Hubs namespace is not a standard namespace', location, eventHub.id); + } else { + if (eventHub.isAutoInflateEnabled) { + helpers.addResult(results, 0, + 'Event Hubs namespace has auto inflate feature enabled', location, eventHub.id); + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace does not have auto inflate feature enabled', location, eventHub.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubNamespaceAutoInflate.spec.js b/plugins/azure/eventhub/eventHubNamespaceAutoInflate.spec.js new file mode 100644 index 000000000..9a4494f32 --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceAutoInflate.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var eventHubNamespaceAutoInflate = require('./eventHubNamespaceAutoInflate'); +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.2', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": true, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + "sku": { + "name": "Standard", + "tier": "Standard", + "capacity": 1 + }, + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + "sku": { + "name": "Standard", + "tier": "Standard", + "capacity": 1 + }, + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + "sku": { + "name": "Premium", + "tier": "Premium", + "capacity": 1 + }, + } +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubNamespaceAutoInflate', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubNamespaceAutoInflate.run(cache, {}, callback); + }); + + it('should give failing result if Event Hubs namespace does not have auto-inflate feature enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace does not have auto inflate feature enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubNamespaceAutoInflate.run(cache, {}, callback); + }); + + it('should give passing result if Event Hubs namespace has auto-inflate feature enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace has auto inflate feature enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubNamespaceAutoInflate.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubNamespaceAutoInflate.run(cache, {}, callback); + }); + + it('should give passing result if event hub namespace is not standard type', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace is not a standard namespace'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[2]]); + eventHubNamespaceAutoInflate.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.js b/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.js new file mode 100644 index 000000000..3ad467f5b --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.js @@ -0,0 +1,55 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Namespace CMK Encrypted', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Event Hubs premium namespaces are encrypted with CMK.', + more_info: 'Event Hubs allows you to encrypt data in your namespaces using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault.', + recommended_action: 'Ensure that Event Hubs premium namespaces have CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/configure-customer-managed-key', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + if (eventHub.sku && eventHub.sku.tier && eventHub.sku.tier.toLowerCase() !== 'premium') { + helpers.addResult(results, 0, 'Event Hubs namespace is not a premium namespace', location, eventHub.id); + } else if (eventHub.encryption && eventHub.encryption.keySource) { + helpers.addResult(results, 0, 'Event Hubs namespace is encrypted using CMK', location, eventHub.id); + } else { + helpers.addResult(results, 2, 'Event Hubs namespace is not encrypted using CMK', location, eventHub.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.spec.js b/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.spec.js new file mode 100644 index 000000000..901acc15b --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceCmkEncrypted.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var eventHubNamespaceCmkEncrypted = require('./eventHubNamespaceCmkEncrypted'); + +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "sku": { + "name": "Premium", + "tier": "Premium", + "capacity": 1 + }, + "encryption": { + "keySource": "Microsoft.KeyVault", + "keyVaultProperties": [ + { + "keyName": "test", + "keyVaultUri": "https://hcicluster.vault.azure.net" + } + ], + "requireInfrastructureEncryption": false + } + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "sku": { + "name": "Premium", + "tier": "Premium", + "capacity": 1 + }, + "minimumTlsVersion": '1.1', + "publicNetworkAccess": 'Enabled', + "disableLocalAuth": true, + "zoneRedundant": true, + "isAutoInflateEnabled": false, + "maximumThroughputUnits": 0, + "kafkaEnabled": false, + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + "sku": { + "name": "Basic", + "tier": "basic", + "capacity": 1 + }, + } +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubNamespaceCmkEncrypted', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubNamespaceCmkEncrypted.run(cache, {}, callback); + }); + + it('should give failing result if Event Hubs namespace is not encrypted using CMK', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubNamespaceCmkEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if Event Hubs namespace is encrypted using CMK', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubNamespaceCmkEncrypted.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubNamespaceCmkEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if event hub namespace is not premium namespace', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace is not a premium namespace'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[2]]); + eventHubNamespaceCmkEncrypted.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubNamespaceHasTags.js b/plugins/azure/eventhub/eventHubNamespaceHasTags.js new file mode 100644 index 000000000..9b088cc5b --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hubs Namespace Has Tags', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Event Hubs namespace have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify affected Event Hub namespace and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['eventHub:listEventHub'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data){ + if (!eventHub.id) continue; + + if (eventHub.tags && Object.entries(eventHub.tags).length > 0){ + helpers.addResult(results, 0, + 'Event Hubs namespace has tags associated',location, eventHub.id); + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace does not have tags associated', location, eventHub.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubNamespaceHasTags.spec.js b/plugins/azure/eventhub/eventHubNamespaceHasTags.spec.js new file mode 100644 index 000000000..a507c1488 --- /dev/null +++ b/plugins/azure/eventhub/eventHubNamespaceHasTags.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var eventHubNamespaceHasTags = require('./eventHubNamespaceHasTags'); + +const eventHubs = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {"key": "value"}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", + "name": "testHub", + "type": 'Microsoft.EventHub/Namespaces', + "location": 'East US', + "tags": {}, + }, +]; + +const createCache = (hub) => { + return { + eventHub: { + listEventHub: { + 'eastus': { + data: hub + } + } + } + } +}; + +describe('eventHubNamespaceHasTags', function() { + describe('run', function() { + it('should give passing result if no event hub found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Event Hubs namespaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + eventHubNamespaceHasTags.run(cache, {}, callback); + }); + + it('should give failing result if Event Hubs namespace does not have tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Event Hubs namespace does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[1]]); + eventHubNamespaceHasTags.run(cache, {}, callback); + }); + + it('should give passing result if Event Hubs namespace has tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Event Hubs namespace has tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([eventHubs[0]]); + eventHubNamespaceHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for event hubs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Event Hubs namespaces:'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null); + eventHubNamespaceHasTags.run(cache, {}, callback); + }); + + }) +}) \ No newline at end of file diff --git a/plugins/azure/eventhub/eventHubPublicAccess.js b/plugins/azure/eventhub/eventHubPublicAccess.js new file mode 100644 index 000000000..7b0717b8f --- /dev/null +++ b/plugins/azure/eventhub/eventHubPublicAccess.js @@ -0,0 +1,86 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Event Hub Public Access', + category: 'Event Hubs', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures Microsoft Azure Event Hubs are not publicly accessible.', + more_info: 'Configuring Azure Event Hubs namespace with public access poses a security risk. To mitigate this risk, it is advisable to limit access by allowing connections only from specific IP addresses or private networks.', + recommended_action: 'Ensure that public network access is disabled for each Event Hubs namespace.', + link: 'https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-ip-filtering#configure-public-access-when-creating-a-namespace', + apis: ['eventHub:listEventHub', 'eventHub:listNetworkRuleSet'], + realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete', 'microsofteventhub:namespaces:networkrulesets:write'], + settings: { + check_selected_networks: { + name: 'Evaluate Selected Networks', + description: 'Checks if specific IP addresses or virtual networks are set to restrict Event Hub access.', + regex: '^(true|false)$', + default: false, + } + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + let config = { + check_selected_networks: settings.check_selected_networks || this.settings.check_selected_networks.default + }; + + async.each(locations.eventHub, function(location, rcb) { + var eventHubs = helpers.addSource(cache, source, + ['eventHub', 'listEventHub', location]); + + if (!eventHubs) return rcb(); + + if (eventHubs.err || !eventHubs.data) { + helpers.addResult(results, 3, + 'Unable to query for Event Hubs namespaces: ' + helpers.addError(eventHubs), location); + return rcb(); + } + + if (!eventHubs.data.length) { + helpers.addResult(results, 0, 'No Event Hubs namespaces found', location); + return rcb(); + } + + for (let eventHub of eventHubs.data) { + if (!eventHub.id) continue; + + if (eventHub.sku && eventHub.sku.tier && eventHub.sku.tier.toLowerCase() === 'basic') { + helpers.addResult(results, 0, + 'Event Hubs namespace tier is basic', location, eventHub.id); + } else { + if (eventHub.publicNetworkAccess && eventHub.publicNetworkAccess.toLowerCase() === 'enabled') { + if (config.check_selected_networks) { + const listNetworkRuleSet = helpers.addSource(cache, source, + ['eventHub', 'listNetworkRuleSet', location, eventHub.id]); + if (!listNetworkRuleSet || listNetworkRuleSet.err || !listNetworkRuleSet.data) { + helpers.addResult(results, 3, + 'Unable to query Event Hubs network rule set: ' + helpers.addError(listNetworkRuleSet), location, eventHub.id); + continue; + } + if ((listNetworkRuleSet.data.ipRules && listNetworkRuleSet.data.ipRules.length > 0) || (listNetworkRuleSet.data.virtualNetworkRules && listNetworkRuleSet.data.virtualNetworkRules.length > 0)) { + helpers.addResult(results, 0, + 'Event Hubs namespace is not publicly accessible', location, eventHub.id); + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace is publicly accessible', location, eventHub.id); + } + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace is publicly accessible', location, eventHub.id); + } + } else { + helpers.addResult(results, 0, + 'Event Hubs namespace is not publicly accessible', location, eventHub.id); + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/eventhub/eventHubPublicAccess.spec.js b/plugins/azure/eventhub/eventHubPublicAccess.spec.js new file mode 100644 index 000000000..0043cddfb --- /dev/null +++ b/plugins/azure/eventhub/eventHubPublicAccess.spec.js @@ -0,0 +1,352 @@ +var expect = require('chai').expect; +var eventHubPublicAccess = require('./eventHubPublicAccess'); + +const eventHubs = [ + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Disabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.1", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub2", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + sku: { + name: "Basic", + tier: "Basic", + capacity: 1, + }, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + name: "testHub4", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, +]; + +const networkRuleSets = [ + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Allow", + virtualNetworkRules: [], + ipRules: [ + { + ipMask: "192.168.1.0/24", + action: "Allow", + }, + ], + }, + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Deny", + virtualNetworkRules: [], + ipRules: [ + { + ipMask: "102.18.161.9", + action: "Allow" + } + ], + }, + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Allow", + virtualNetworkRules: [], + ipRules: [ + ], + }, +]; + +const createCache = (eventHub, networkRuleSet) => { + const id = eventHub && eventHub.length ? eventHub[0].id : null; + return { + eventHub: { + listEventHub: { + 'eastus': { + data: eventHub + } + }, + listNetworkRuleSet: { + 'eastus': { + [id]: { + data: networkRuleSet + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + eventHub: { + listEventHub: { + eastus: { + err: "error", + }, + }, + }, + }; +}; + +const createNetworkRuleSetErrorCache = (hub) => { + let cache = { + eventHub: { + listEventHub: { + eastus: { + data: hub, + }, + }, + listNetworkRuleSet: { + eastus: {} + } + }, + }; + + if (Array.isArray(hub) && hub.length > 0) { + for (let eventHub of hub) { + if (eventHub.id) { + cache.eventHub.listNetworkRuleSet.eastus[eventHub.id] = { + err: "Unable to query network rule sets", + }; + } + } + } + + return cache; +}; + +describe("eventHubPublicAccess", function () { + describe("run", function () { + it("should give passing result if no event hub found", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("No Event Hubs namespaces found"); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + it("should give failing result if event hub is publicly accessible", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Event Hubs namespace is publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[1]]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + it("should give passing result if eventHub is not publicly accessible", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Event Hubs namespace is not publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[0]]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + it("should give passing result if eventHub is of basic tier", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Event Hubs namespace tier is basic" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[2]]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + it("should give unknown result if unable to query for event hubs", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include( + "Unable to query for Event Hubs namespaces:" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createErrorCache(); + eventHubPublicAccess.run(cache, {}, callback); + }); + + it("should give passing result when check_selected_networks is enabled and IP rules are configured", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Event Hubs namespace is not publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[3]], networkRuleSets[1]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should give failing result when check_selected_networks is enabled and no IP rules are configured", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Event Hubs namespace is publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[5]], networkRuleSets[2]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should give unknown result when check_selected_networks is enabled but unable to query network rule sets", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include( + "Unable to query Event Hubs network rule set:" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createNetworkRuleSetErrorCache([eventHubs[1]]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should skip event hub without ID", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache([eventHubs[4]]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/azure/fileservice/fileServiceAllAccessAcl.js b/plugins/azure/fileservice/fileServiceAllAccessAcl.js index e037f37a7..ba3412172 100644 --- a/plugins/azure/fileservice/fileServiceAllAccessAcl.js +++ b/plugins/azure/fileservice/fileServiceAllAccessAcl.js @@ -5,6 +5,8 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'File Service All Access ACL', category: 'File Service', + domain: 'Storage', + severity: 'Medium', description: 'Ensures file shares do not allow full write, delete, or read ACL permissions', more_info: 'File shares can be configured to allow to read, write, or delete permissions from a share. This option should not be configured unless there is a strong business requirement.', recommended_action: 'Disable global read, write, and delete policies on all file shares and ensure the share ACL is configured with least privileges.', @@ -16,6 +18,9 @@ module.exports = { pci: 'PCI data must be secured via least-privileged ACLs. File Service ACLs ' + 'enable granular permissions for data access.' }, + realtime_triggers: ['microsoftstorage:storageaccounts:write','microsoftstorage:storageaccounts:fileservices:shares:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:fileservices:shares:delete'], + + run: function(cache, settings, callback) { var results = []; @@ -59,11 +64,9 @@ module.exports = { 'No existing File Service shares found', location, storageAccount.id); } else { listSharesSegmented.data.forEach(function(fileShare) { - fileShare.id = `${storageAccount.id}/fileService/${fileShare.name}`; // Add share ACL var getShareAcl = helpers.addSource(cache, source, ['fileService', 'getShareAcl', location, fileShare.id]); - if (!getShareAcl || getShareAcl.err || !getShareAcl.data) { helpers.addResult(results, 3, 'Unable to query File Service share ACL: ' + helpers.addError(getShareAcl), location, fileShare.id); @@ -73,7 +76,7 @@ module.exports = { if (acl.signedIdentifiers && Object.keys(acl.signedIdentifiers).length) { for (var ident in acl.signedIdentifiers) { - var permissions = acl.signedIdentifiers[ident].Permissions; + var permissions = acl.signedIdentifiers[ident].accessPolicy.permissions; for (var i = 0; i <= permissions.length; i++) { switch (permissions.charAt(i)) { // case "r": diff --git a/plugins/azure/fileservice/fileServiceAllAccessAcl.spec.js b/plugins/azure/fileservice/fileServiceAllAccessAcl.spec.js new file mode 100644 index 000000000..449d0e171 --- /dev/null +++ b/plugins/azure/fileservice/fileServiceAllAccessAcl.spec.js @@ -0,0 +1,205 @@ +var expect = require('chai').expect; +var fileServiceAllAccessAcl = require('./fileServiceAllAccessAcl'); + +const storageAccounts = [ + { + sku: [Object], + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + tags: [Object], + privateEndpointConnections: [], + minimumTlsVersion: 'TLS1_2', + allowBlobPublicAccess: false, + networkAcls: [Object], + supportsHttpsTrafficOnly: true, + encryption: [Object], + accessTier: 'Hot', + provisioningState: 'Succeeded', + creationTime: '2021-03-09T16:54:18.2838672Z', + primaryEndpoints: [Object], + primaryLocation: 'eastus', + statusOfPrimary: 'available' + } +]; + +const listKeys = [ + { + keyName: 'key1', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, + { + keyName: 'key2', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, +]; + +const listSharesSegmented = [ + { + "name": "file1", + "id": "/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683/fileService/file1" + } +]; + +const getShareAcl = [ + { + name: 'file1', + signedIdentifiers: { accessPolicy: { permissions: 'raup' } } + }, + { + name: 'file1', + signedIdentifiers: [] + }, + { + name: 'file1', + signedIdentifiers: [{ accessPolicy: { permissions: 'cwdl' } }] + }, +]; + +const createCache = (list, listKeys, segments, acl, keysErr) => { + var id = (list && list.length) ? list[0].id : null; + var segmentName = (segments && segments.length) ? segments[0].name : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: list + }, + }, + listKeys: { + 'eastus': { + [id]: { + err: keysErr, + data: listKeys + }, + }, + }, + }, + fileService: { + listSharesSegmented: { + 'eastus': { + [id]: { + data: segments + } + } + }, + getShareAcl: { + 'eastus': { + [id + '/fileService/' + segmentName]: { + data: acl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': { + err: { + message: 'error while listing storageAccounts' + }, + }, + }, + listKeys: { + 'eastus': { + err: { + message: 'error while listing storageAccount keys' + }, + }, + }, + }, + fileService: { + listSharesSegmented: { + 'eastus': { + err: { + message: 'Unable to list file share' + } + } + }, + getShareAcl: { + 'eastus': { + err: { + message: 'Unable to get share Acl' + } + } + } + } + }; +}; + +describe('fileServiceAllAccessAcl', function () { + describe('run', function () { + it('should PASS if File Share ACL does not contain full access permissions', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listSharesSegmented[0]], [getShareAcl[2]]); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if File Share ACL has not been configured', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listSharesSegmented[0]], getShareAcl[1]); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if File Share ACL allows', function (done) { + const cache = createCache([storageAccounts[0]], [listKeys[0]], [listSharesSegmented[0]], getShareAcl[2]); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if No storage accounts found', function (done) { + const cache = createCache([]); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for File Service using Storage Account SAS', function (done) { + const cache = createErrorCache(); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for File Shares', function (done) { + const cache = createErrorCache(); + fileServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/accessLogsEnabled.js b/plugins/azure/frontdoor/accessLogsEnabled.js new file mode 100644 index 000000000..994e92a27 --- /dev/null +++ b/plugins/azure/frontdoor/accessLogsEnabled.js @@ -0,0 +1,71 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Access Logs Enabled', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Front Door Access Log is enabled.', + more_info: 'Azure Front Door captures several types of logs. Access logs can be used to identify slow requests, determine error rates, and understand how Front Door\'s caching behavior is working for your solution.', + recommended_action: 'Ensure that diagnostic setting for Front Door Access Log is enabled.', + link: 'https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-logs', + apis: ['profiles:list', 'diagnosticSettings:listByAzureFrontDoor'], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + const profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + return rcb(); + } + + var frontDoorProfile = false; + profiles.data.forEach(function(profile) { + if (!profile.id || profile.kind!='frontdoor') return; + + frontDoorProfile = true; + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAzureFrontDoor', location, profile.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Front Door diagnostics settings: ' + helpers.addError(diagnosticSettings), location, profile.id); + } else { + var frontDoorAccessLogEnabled = false; + diagnosticSettings.data.forEach(setting => { + var logs = setting.logs; + if (logs.some(log => (log.categoryGroup === 'audit' || log.categoryGroup === 'allLogs' || log.category === 'FrontDoorAccessLog') && log.enabled)) { + frontDoorAccessLogEnabled = true; + } + }); + if (frontDoorAccessLogEnabled) { + helpers.addResult(results, 0, 'Front Door access logs are enabled', location, profile.id); + } else { + helpers.addResult(results, 2, 'Front Door access logs are not enabled', location, profile.id); + } + } + }); + + if (!frontDoorProfile) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/accessLogsEnabled.spec.js b/plugins/azure/frontdoor/accessLogsEnabled.spec.js new file mode 100644 index 000000000..bb8f33a8a --- /dev/null +++ b/plugins/azure/frontdoor/accessLogsEnabled.spec.js @@ -0,0 +1,216 @@ +var expect = require('chai').expect; +var accessLogsEnabled = require('./accessLogsEnabled.js'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/profiles/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testaccesslogs', + location: 'global', + logs: [ + { + "category": "FrontDoorAccessLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/profiles/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testaccesslogs', + location: 'global', + logs: [ + { + "category": "FrontDoorAccessLog", + "categoryGroup": null, + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + {} +] + +const createCache = (profiles, diagnostics) => { + let diagnostic = {}; + if (profiles.length) { + diagnostic[profiles[0].id] = { + data: diagnostics + }; + } + + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'noprofile'){ + return { + profiles: { + list: { + 'global': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('accessLogsEnabled', function () { + describe('run', function () { + + it('should give pass result if No existing Azure Front Door profiles found', function (done) { + const cache = createErrorCache('noprofile'); + accessLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Azure Front Door profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query Front Door profiles:', function (done) { + const cache = createErrorCache('profile'); + accessLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door profiles:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function (done) { + const cache = createErrorCache('policy'); + accessLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door diagnostics settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if Access Log are enabled for Azure Front Door', function (done) { + const cache = createCache([profiles[0]], [diagnosticSettings[0]]); + accessLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door access logs are enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Access logging is not enabled for Azure Front Door', function (done) { + const cache = createCache([profiles[1]], [diagnosticSettings[1]]); + accessLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door access logs are not enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/afdSecurityLoggingEnabled.js b/plugins/azure/frontdoor/afdSecurityLoggingEnabled.js new file mode 100644 index 000000000..8472ebd83 --- /dev/null +++ b/plugins/azure/frontdoor/afdSecurityLoggingEnabled.js @@ -0,0 +1,72 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Security Logging Enabled', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Front Door Access and WAF logs are enabled.', + more_info: 'Azure Front Door captures several types of logs. Access logs can be used to identify slow requests, determine error rates, and understand how Front Door\'s caching behavior is working for your solution. Web application firewall (WAF) logs can be used to detect potential attacks, and false positive detections that might indicate legitimate requests that the WAF blocked.', + recommended_action: 'Modify Front Door profile and add diagnostic settings for Access and WAF Logs.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-monitor?pivots=front-door-standard-premium', + apis: ['profiles:list', 'diagnosticSettings:listByAzureFrontDoor'], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete' , 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + const profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + return rcb(); + } + + var frontDoorProfile = false; + profiles.data.forEach(function(profile) { + if (!profile.id || profile.kind != 'frontdoor') return; + + frontDoorProfile = true; + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAzureFrontDoor', location, profile.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Front Door diagnostics settings: ' + helpers.addError(diagnosticSettings), location, profile.id); + } else { + var missingLogs = ['FrontDoorAccessLog', 'FrontDoorWebApplicationFirewallLog']; + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + if (missingLogs.length) { + helpers.addResult(results, 2, `Front Door profile does not have security logging enabled. Missing Logs ${missingLogs}`, location, profile.id); + } else { + helpers.addResult(results, 0, 'Front Door profile has security logging enabled', location, profile.id); + } + } + }); + + if (!frontDoorProfile) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/afdSecurityLoggingEnabled.spec.js b/plugins/azure/frontdoor/afdSecurityLoggingEnabled.spec.js new file mode 100644 index 000000000..7201e95a8 --- /dev/null +++ b/plugins/azure/frontdoor/afdSecurityLoggingEnabled.spec.js @@ -0,0 +1,256 @@ +var expect = require('chai').expect; +var afdSecurityLoggingEnabled = require('./afdSecurityLoggingEnabled.js'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/profiles/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testaccesslogs', + location: 'global', + logs: [ + { + "category": "FrontDoorWebApplicationFirewallLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/profiles/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testwaflogs', + location: 'global', + logs: [ + { + "category": "FrontDoorWebApplicationFirewallLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "FrontDoorAccessLog", + "categoryGroup": null, + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + ], + "logAnalyticsDestinationType": null + }, + {}, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cdn/profiles/omer-cdn-profile-test/providers/microsoft.insights/diagnosticSettings/testaccesslogs', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'testwaflogs', + location: 'global', + logs: [ + { + "category": "", + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, +] + +const createCache = (profiles, diagnostics) => { + let diagnostic = {}; + if (profiles.length) { + diagnostic[profiles[0].id] = { + data: diagnostics + }; + } + + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'noprofile'){ + return { + profiles: { + list: { + 'global': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + listByAzureFrontDoor: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('afdSecurityLoggingEnabled', function () { + describe('run', function () { + + it('should give pass result if No existing Azure Front Door profiles found', function (done) { + const cache = createErrorCache('noprofile'); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Azure Front Door profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query Front Door profiles:', function (done) { + const cache = createErrorCache('profile'); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door profiles:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function (done) { + const cache = createErrorCache('policy'); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door diagnostics settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if front Door profile has security logging enabled', function (done) { + const cache = createCache([profiles[0]], [diagnosticSettings[1]]); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile has security logging enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Front Door profile does not have security logging enabled', function (done) { + const cache = createCache([profiles[1]], [diagnosticSettings[0]]); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door profile does not have security logging enabled. Missing Logs FrontDoorAccessLog'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + + it('should give pass result if Application Gateway have allLogs Enabled', function(done) { + const cache = createCache([profiles[1]], [diagnosticSettings[3]]); + afdSecurityLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile has security logging enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/botProtectionEnabled.js b/plugins/azure/frontdoor/botProtectionEnabled.js new file mode 100644 index 000000000..5d7e2141d --- /dev/null +++ b/plugins/azure/frontdoor/botProtectionEnabled.js @@ -0,0 +1,56 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door WAF Bot Protection', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Bot Protection for Azure Front Door WAF policy is enabled.', + more_info: 'Azure Web Application Firewall (WAF) for Front Door provides bot rules to protect from bad bots and to block or log requests from known malicious IP addresses.', + recommended_action: 'Modify Front Door WAF policy and add bot protection rule set in managed rules.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-policy-configure-bot-protection?pivots=portal', + apis: ['afdWafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationairewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.afdWafPolicies, (location, rcb) => { + + var afdWafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', location]); + + if (!afdWafPolicies) return rcb(); + + if (afdWafPolicies.err || !afdWafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Front Door WAF policies: ' + helpers.addError(afdWafPolicies), location); + return rcb(); + } + if (!afdWafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + return rcb(); + } + + for (let policy of afdWafPolicies.data) { + if (!policy.id) continue; + + var found = policy.managedRules && + policy.managedRules.managedRuleSets ? + policy.managedRules.managedRuleSets.find(ruleset => ruleset.ruleSetType && ruleset.ruleSetType.toLowerCase() == 'microsoft_botmanagerruleset') : false; + + if (found) { + helpers.addResult(results, 0, 'Front Door WAF policy has bot protection enabled', location, policy.id); + } else { + helpers.addResult(results, 2, 'Front Door WAF policy does not have bot protection enabled', location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/frontdoor/botProtectionEnabled.spec.js b/plugins/azure/frontdoor/botProtectionEnabled.spec.js new file mode 100644 index 000000000..6b5048d23 --- /dev/null +++ b/plugins/azure/frontdoor/botProtectionEnabled.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var botProtectionEnabled = require('./botProtectionEnabled.js'); + +const afdWafPolicies = [ + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy2", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "DefaultRuleSet", + "ruleSetVersion": "preview-0.1", + "ruleSetAction": null, + "ruleGroupOverrides": [], + "exclusions": [] + }, + { + "ruleSetType": "Microsoft_BotManagerRuleSet", + "ruleSetVersion": "1.0", + "ruleSetAction": null, + "ruleGroupOverrides": [], + "exclusions": [] + } + ] + } + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "DefaultRuleSet", + "ruleSetVersion": "preview-0.1", + "ruleSetAction": null, + "ruleGroupOverrides": [], + "exclusions": [] + }, + ] + } + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Classic_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "DefaultRuleSet", + "ruleSetVersion": "preview-0.1", + "ruleSetAction": null, + "ruleGroupOverrides": [], + "exclusions": [] + }, + ] + } + }, +]; + +const createCache = (afdWafPolicies) => { + return { + afdWafPolicies: { + listAll: { + 'global': { + data: afdWafPolicies + } + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'noAfd') { + return { + afdWafPolicies: { + listAll: { + 'global': { + data:{} + } + } + } + }; + } else { + return { + afdWafPolicies: { + listAll: { + 'global': {} + } + } + }; + } +}; +describe('botProtectionEnabled', function () { + describe('run', function () { + + it('should give pass result if bot protection is enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[0]]); + botProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door WAF policy has bot protection enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if bot protection is not enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[1]]); + botProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door WAF policy does not have bot protection enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if no existing front door waf policy found', function (done) { + const cache = createErrorCache('noAfd'); + botProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door WAF policies found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Front Door WAF policies:', function (done) { + const cache = createErrorCache(); + botProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Front Door WAF policies:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorAzureManagedDomain.js b/plugins/azure/frontdoor/frontDoorAzureManagedDomain.js new file mode 100644 index 000000000..43f23444c --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorAzureManagedDomain.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Azure Managed DNS', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Front Door standard and premium profile custom domains are configured to use Azure Managed DNS', + more_info: 'DNS domains in Azure DNS are hosted on the Azure global network of DNS name servers. This system uses Anycast networking so that each DNS query is answered by the closest available DNS server. Azure DNS provides fast performance and high availability for your domain.', + recommended_action: 'Ensure that Non-Azure validated domains for Front Door profiles are using Azure Managed DNS.', + link: 'https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-configure-https-custom-domain?tabs=powershell#azure-front-door-managed-certificates-for-non-azure-pre-validated-domains', + apis: ['profiles:list', 'customDomain:listByFrontDoorProfiles'], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete', 'microsoftcdn:profiles:customdomains:write', 'microsoftcdn:profiles:customdomains:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + + const profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, + 'Unable to query Azure Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + return rcb(); + } + + var frontDoorProfile = false; + profiles.data.forEach(function(profile) { + if (!profile.id || profile.kind != 'frontdoor') return; + + frontDoorProfile = true; + var failingDomains = []; + const customDomains = helpers.addSource(cache, source, + ['customDomain', 'listByFrontDoorProfiles', location, profile.id]); + + if (!customDomains || customDomains.err || !customDomains.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door custom domains: ' + helpers.addError(customDomains), location, profile.id); + } else if (!customDomains.data.length) { + helpers.addResult(results, 0, 'No existing Front Door custom domains found', location, profile.id); + } else { + failingDomains = customDomains.data.filter(customDomain => !customDomain.azureDnsZone) + .map(customDomain => customDomain.name); + + if (failingDomains.length){ + helpers.addResult(results, 2, + `Front Door profile custom domains are not using Azure managed DNS: ${failingDomains.join(', ')}`, location, profile.id); + } else { + helpers.addResult(results, 0, + 'Front Door profile custom domains are using Azure managed DNS', location, profile.id); + } + } + }); + + if (!frontDoorProfile) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorAzureManagedDomain.spec.js b/plugins/azure/frontdoor/frontDoorAzureManagedDomain.spec.js new file mode 100644 index 000000000..5a30b4b3f --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorAzureManagedDomain.spec.js @@ -0,0 +1,233 @@ +var expect = require('chai').expect; +var frontDoorAzureManagedDomain = require('./frontDoorAzureManagedDomain.js'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd", + "type": "Microsoft.Cdn/profiles", + "name": "mehak-fd", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "properties": { + "originResponseTimeoutSeconds": 60, + "frontDoorId": "40590271-c2c4-4264-8061-45b884a91a70", + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + + +const customDomain = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd/customdomains/test-naim-app-srvenv-appserviceenvironment-net", + "type": "Microsoft.Cdn/profiles/customdomains", + "name": "test-naim-app-srvenv-appserviceenvironment-net", + "hostName": "test.naim-app-srvenv.appserviceenvironment.net", + "tlsSettings": { + "certificateType": "ManagedCertificate", + "minimumTlsVersion": "TLS12", + "secret": null + }, + "validationProperties": { + "validationToken": "mh0nl1m0syywj6m6bt5s9hksxw1sk4h9", + "expirationDate": "2023-08-07T20:07:11.5302594+00:00" + }, + "azureDnsZone": { + "id": "/subscriptions/a7ddb462-bd4a-4c99-bda2-e008b2ab62f8/resourceGroups/naim-resources/providers/Microsoft.Network/dnszones/naim-app-srvenv.appserviceenvironment.net" + }, + "domainValidationState": "Pending", + "preValidatedCustomDomainResourceId": null, + "provisioningState": "Succeeded", + "deploymentStatus": "NotStarted" + }, + { + + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd/customdomains/test-naim-app-srvenv-appserviceenvironment-net", + "type": "Microsoft.Cdn/profiles/customdomains", + "name": "test-naim-app-srvenv-appserviceenvironment-net", + "hostName": "test.naim-app-srvenv.appserviceenvironment.net", + "tlsSettings": { + "certificateType": "ManagedCertificate", + "minimumTlsVersion": "TLS1", + "secret": null + }, + "validationProperties": { + "validationToken": "mh0nl1m0syywj6m6bt5s9hksxw1sk4h9", + "expirationDate": "2023-08-07T20:07:11.5302594+00:00" + }, + "azureDnsZone": null, + "domainValidationState": "Pending", + "preValidatedCustomDomainResourceId": null, + "provisioningState": "Succeeded", + "deploymentStatus": "NotStarted" + }, +] + +const createCache = (profiles, customDomains) => { + let customDomain = {}; + if (profiles.length) { + customDomain[profiles[0].id] = { + data: customDomains + }; + } + + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + customDomain: { + listByFrontDoorProfiles: { + 'global': customDomain + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'customDomains') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + customDomains: { + listByFrontDoorProfiles: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[0].id : null; + const customDomains = (customDomains && customDomains.length) ? customDomains[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + customDomains: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('frontDoorAzureManagedDomain', function () { + describe('run', function () { + + it('should give unknown if Unable to query Azure Front Door profiles:', function (done) { + const cache = createErrorCache('profile'); + frontDoorAzureManagedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Azure Front Door profiles'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + + it('should give unknown if Unable to query Front Door custom domains:', function (done) { + const cache = createErrorCache('customDomains'); + frontDoorAzureManagedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door custom domains:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass if No existing Front Door custom domains found', function (done) { + const cache = createCache([profiles[0]], customDomain[1]); + frontDoorAzureManagedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door custom domains found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if AFD profile custom domain is using Azure managed DNS', function (done) { + const cache = createCache([profiles[2]], [customDomain[0]]); + frontDoorAzureManagedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile custom domains are using Azure managed DNS'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if AFD profile custom domain is not using Azure managed DNS', function (done) { + const cache = createCache([profiles[2]], [customDomain[2]]); + frontDoorAzureManagedDomain.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door profile custom domains are not using Azure managed DNS:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorHttpsOnly.js b/plugins/azure/frontdoor/frontDoorHttpsOnly.js new file mode 100644 index 000000000..449248177 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorHttpsOnly.js @@ -0,0 +1,68 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door HTTPS only', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures HTTPS Only is enabled for Front Door classic profile, redirecting all HTTP traffic to HTTPS.', + more_info: 'By using the HTTPS only protocol, you ensure that your sensitive data is delivered securely via TLS/SSL encryption.', + recommended_action: 'Modify the Front Door classic profile and add HTTP to HTTPS redirect rule under the frontend hosts section.', + link: 'https://learn.microsoft.com/en-us/azure/frontdoor/front-door-how-to-redirect-https', + apis: ['classicFrontDoors:list'], + realtime_triggers: ['microsoftnetwork:frontdoors:write', 'microsoftnetwork:frontdoors:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.classicFrontDoors, (location, rcb) => { + const classicFrontDoors = + helpers.addSource(cache, source, + ['classicFrontDoors', 'list', location]); + + if (!classicFrontDoors) return rcb(); + + if (classicFrontDoors.err || !classicFrontDoors.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door profiles: ' + helpers.addError(classicFrontDoors), location); + return rcb(); + } + + if (!classicFrontDoors.data.length) { + helpers.addResult(results, 0, 'No existing Front Door profiles found', location); + return rcb(); + } + + classicFrontDoors.data.forEach(frontDoor => { + if (!frontDoor.id || !frontDoor.routingRules) return; + + var ruleFound = false; + for (var rule of frontDoor.routingRules) { + var ruleProperties = rule.properties? rule.properties : {}; + if (ruleProperties.acceptedProtocols && ruleProperties.acceptedProtocols[0].toLowerCase() =='http') { + if (ruleProperties.routeConfiguration && + ruleProperties.routeConfiguration.redirectType && + ruleProperties.routeConfiguration.redirectProtocol && + ruleProperties.routeConfiguration.redirectType.toLowerCase() == 'moved' && + ruleProperties.routeConfiguration.redirectProtocol.toLowerCase() == 'httpsonly') { + ruleFound = true; + break; + } + } + } + + if (ruleFound) { + helpers.addResult(results, 0, 'Front Door profile is configured to use HTTPS only', location, frontDoor.id); + } else { + helpers.addResult(results, 2, 'Front Door profile is not configured to use HTTPS only', location, frontDoor.id); + } + + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorHttpsOnly.spec.js b/plugins/azure/frontdoor/frontDoorHttpsOnly.spec.js new file mode 100644 index 000000000..d2c16942a --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorHttpsOnly.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var frontDoorHttpsOnly = require('./frontDoorHttpsOnly.js'); + +const classicFrontDoors = [ + { + id: '/subscriptions/1234567890/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoors/khulnasofttest', + type: 'Microsoft.Network/frontdoors', + name: 'khulnasofttest', + location: 'Global', + tags: {}, + routingRules: [ + { + "id": "/subscriptions/1234567890/resourcegroups/meerab-rg/providers/Microsoft.Network/Frontdoors/khulnasofttest/RoutingRules/test-instance", + "name": "test-instance", + "type": "Microsoft.Network/Frontdoors/RoutingRules", + "properties": { + "routeConfiguration": { + "redirectType": "Moved", + "redirectProtocol": "HttpsOnly", + }, + "resourceState": "Enabled", + "acceptedProtocols": [ + "Http" + ] + } + }, + { + "id": "/subscriptions/1234567890/resourcegroups/meerab-rg/providers/Microsoft.Network/Frontdoors/khulnasofttest/RoutingRules/rule2", + "name": "rule2", + "type": "Microsoft.Network/Frontdoors/RoutingRules", + "properties": { + "routeConfiguration": { + "redirectType": "Found", + "redirectProtocol": "HttpOnly", + }, + "acceptedProtocols": [ + "Https" + ], + } + } + ] + }, + { + id: '/subscriptions/1234567890/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoors/khulnasofttest', + type: 'Microsoft.Network/frontdoors', + name: 'khulnasofttest', + location: 'Global', + tags: {}, + routingRules: [ + { + "id": "/subscriptions/1234567890/resourcegroups/meerab-rg/providers/Microsoft.Network/Frontdoors/khulnasofttest/RoutingRules/rule2", + "name": "rule2", + "type": "Microsoft.Network/Frontdoors/RoutingRules", + "properties": { + "routeConfiguration": { + "redirectType": "Found", + "redirectProtocol": "HttpOnly", + }, + "acceptedProtocols": [ + "Https" + ], + } + } + ] + } +]; + +const createCache = (classicFrontDoors) => { + return { + classicFrontDoors: { + list: { + 'global': { + data: classicFrontDoors + } + } + } + }; +}; + +const createErrorCache = () => { + return { + classicFrontDoors: { + list: { + 'global': { + data:{} + } + } + } + }; +}; +describe('frontDoorHttpsOnly', function () { + describe('run', function () { + + it('should give pass result if no classic Front Door profiles found', function (done) { + const cache = createErrorCache(); + frontDoorHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if classic Front Door profile is configured to use Https only', function (done) { + const cache = createCache([classicFrontDoors[0]]); + frontDoorHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile is configured to use HTTPS only'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if classic Front Door profile is not configured to use Https only', function (done) { + const cache = createCache([classicFrontDoors[1]]); + frontDoorHttpsOnly.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door profile is not configured to use HTTPS only'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorManagedIdentity.js b/plugins/azure/frontdoor/frontDoorManagedIdentity.js new file mode 100644 index 000000000..314eac71e --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorManagedIdentity.js @@ -0,0 +1,60 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Managed Identity Enabled', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Front Door standard and premium profiles have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify the Front Door standard and premium profile and add managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/frontdoor/managed-identity', + apis: ['profiles:list'], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + const profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + return rcb(); + } + + var frontDoorProfile = false; + profiles.data.forEach(function(profile) { + if (!profile.id || profile.kind!='frontdoor') return; + + frontDoorProfile = true; + if (profile.identity && profile.identity.type && + (profile.identity.type.toLowerCase() === 'systemassigned' || profile.identity.type.toLowerCase() === 'userassigned')) { + helpers.addResult(results, 0, + 'Front Door profile has managed identity enabled', location, profile.id); + } else { + helpers.addResult(results, 2, + 'Front Door profile does not have managed identity enabled', location, profile.id); + } + }); + + if (!frontDoorProfile) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorManagedIdentity.spec.js b/plugins/azure/frontdoor/frontDoorManagedIdentity.spec.js new file mode 100644 index 000000000..9ca81163f --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorManagedIdentity.spec.js @@ -0,0 +1,114 @@ +var expect = require('chai').expect; +var frontDoorManagedIdentity = require('./frontDoorManagedIdentity'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + }, + "identity": { + "type": "SystemAssigned" + } + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + }, + "identity": { + "type": "None" + } + } +]; + + +const createCache = (profiles) => { + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + } + }; +}; + +const createErrorCache = () => { + return { + profiles: { + list: { + 'global': {} + } + } + }; +}; + +describe('frontDoorManagedIdentity', function () { + describe('run', function () { + + it('should give pass result if No existing Azure Front Door profiles found', function (done) { + const cache = createCache([]); + frontDoorManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Azure Front Door profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query Front Door profiles:', function (done) { + const cache = createErrorCache(); + frontDoorManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door profiles:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + + it('should give passing result if Access Log are enabled for Azure Front Door', function (done) { + const cache = createCache([profiles[0]]); + frontDoorManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile has managed identity enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Access logging is not enabled for Azure Front Door', function (done) { + const cache = createCache([profiles[1]]); + frontDoorManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door profile does not have managed identity enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.js b/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.js new file mode 100644 index 000000000..70e3d7390 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Minimum TLS Version', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Front Door Standard and Premium profile custom domains have minimum TLS version of 1.2.', + more_info: 'By setting the minimum TLS version to 1.2, you significantly improve the security of your custom domains. All Azure Front Door profiles created after September 2019 use TLS 1.2 as the default minimum.', + recommended_action: 'Ensure that Azure Front Door Standard and Premium are using minimum TLS version of 1.2.', + link: 'https://learn.microsoft.com/en-us/azure/frontdoor/end-to-end-tls?pivots=front-door-standard-premium#supported-tls-versions', + apis: ['profiles:list', 'customDomain:listByFrontDoorProfiles'], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete', 'microsoftcdn:profiles:customdomains:write', 'microsoftcdn:profiles:customdomains:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + const profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, + 'Unable to query Azure Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + return rcb(); + } + + var frontDoorProfile = false; + profiles.data.forEach(function(profile) { + if (!profile.id || profile.kind != 'frontdoor') return; + + frontDoorProfile = true; + var failingDomains = {}; + const customDomains = helpers.addSource(cache, source, + ['customDomain', 'listByFrontDoorProfiles', location, profile.id]); + if (!customDomains || customDomains.err || !customDomains.data) { + helpers.addResult(results, 3, + 'Unable to query Front Door custom domains: ' + helpers.addError(customDomains), location, profile.id); + } else if (!customDomains.data.length) { + helpers.addResult(results, 0, 'No existing Front Door custom domains found', location, profile.id); + } else { + failingDomains = customDomains.data.filter(customDomain => { + return !(customDomain.tlsSettings && + customDomain.tlsSettings.minimumTlsVersion && + customDomain.tlsSettings.minimumTlsVersion.toUpperCase() === 'TLS12'); + }).map(function(customDomain) { + return customDomain.name; + }); + + if (failingDomains.length){ + helpers.addResult(results, 2, + `Front Door Profile domains are not using TLS version 1.2: ${failingDomains.join(', ')}`, location, profile.id); + } else { + helpers.addResult(results, 0, + 'Front Door Profile domains are using TLS version 1.2', location, profile.id); + } + } + }); + + if (!frontDoorProfile) { + helpers.addResult(results, 0, 'No existing Azure Front Door profiles found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.spec.js b/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.spec.js new file mode 100644 index 000000000..7f4ab6292 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorMinimumTlsVersion.spec.js @@ -0,0 +1,235 @@ +var expect = require('chai').expect; +var frontDoorMinimumTlsVersion = require('./frontDoorMinimumTlsVersion.js'); + +const profiles = [ + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/234/resourcegroups/sadeedrg/providers/Microsoft.Cdn/profiles/test-profile", + "type": "Microsoft.Cdn/profiles", + "name": "test-profile", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_Microsoft" + }, + "properties": { + "resourceState": "Active", + "provisioningState": "Succeeded" + } + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd", + "type": "Microsoft.Cdn/profiles", + "name": "mehak-fd", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "properties": { + "originResponseTimeoutSeconds": 60, + "frontDoorId": "40590271-c2c4-4264-8061-45b884a91a70", + "resourceState": "Active", + "provisioningState": "Succeeded" + } + } +]; + + +const customDomain = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd/customdomains/test-naim-app-srvenv-appserviceenvironment-net", + "type": "Microsoft.Cdn/profiles/customdomains", + "name": "test-naim-app-srvenv-appserviceenvironment-net", + "hostName": "test.naim-app-srvenv.appserviceenvironment.net", + "tlsSettings": { + "certificateType": "ManagedCertificate", + "minimumTlsVersion": "TLS12", + "secret": null + }, + "validationProperties": { + "validationToken": "mh0nl1m0syywj6m6bt5s9hksxw1sk4h9", + "expirationDate": "2023-08-07T20:07:11.5302594+00:00" + }, + "azureDnsZone": { + "id": "/subscriptions/a7ddb462-bd4a-4c99-bda2-e008b2ab62f8/resourceGroups/naim-resources/providers/Microsoft.Network/dnszones/naim-app-srvenv.appserviceenvironment.net" + }, + "domainValidationState": "Pending", + "preValidatedCustomDomainResourceId": null, + "provisioningState": "Succeeded", + "deploymentStatus": "NotStarted" + }, + { + + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd/customdomains/test-naim-app-srvenv-appserviceenvironment-net", + "type": "Microsoft.Cdn/profiles/customdomains", + "name": "test-naim-app-srvenv-appserviceenvironment-net", + "hostName": "test.naim-app-srvenv.appserviceenvironment.net", + "tlsSettings": { + "certificateType": "ManagedCertificate", + "minimumTlsVersion": "TLS1", + "secret": null + }, + "validationProperties": { + "validationToken": "mh0nl1m0syywj6m6bt5s9hksxw1sk4h9", + "expirationDate": "2023-08-07T20:07:11.5302594+00:00" + }, + "azureDnsZone": { + "id": "/subscriptions/a7ddb462-bd4a-4c99-bda2-e008b2ab62f8/resourceGroups/naim-resources/providers/Microsoft.Network/dnszones/naim-app-srvenv.appserviceenvironment.net" + }, + "domainValidationState": "Pending", + "preValidatedCustomDomainResourceId": null, + "provisioningState": "Succeeded", + "deploymentStatus": "NotStarted" + }, +] + +const createCache = (profiles, customDomains) => { + let customDomain = {}; + if (profiles.length) { + customDomain[profiles[0].id] = { + data: customDomains + }; + } + + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + customDomain: { + listByFrontDoorProfiles: { + 'global': customDomain + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'customDomains') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + customDomains: { + listByFrontDoorProfiles: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[0].id : null; + const customDomains = (customDomains && customDomains.length) ? customDomains[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + diagnosticSettings: { + customDomains: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('frontDoorMinimumTlsVersion', function () { + describe('run', function () { + + it('should give unknown if Unable to query Azure Front Door profiles:', function (done) { + const cache = createErrorCache('profile'); + frontDoorMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Azure Front Door profiles'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + + it('should give unknown if Unable to query Front Door custom domains:', function (done) { + const cache = createErrorCache('customDomains'); + frontDoorMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door custom domains:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass if No existing Front Door custom domains found', function (done) { + const cache = createCache([profiles[0]], customDomain[1]); + frontDoorMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door custom domains found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if AFD profile custom domain is using TLS version 1.2', function (done) { + const cache = createCache([profiles[2]], [customDomain[0]]); + frontDoorMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door Profile domains are using TLS version 1.2'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if AFD profile custom domain is not using TLS version 1.2', function (done) { + const cache = createCache([profiles[2]], [customDomain[2]]); + frontDoorMinimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door Profile domains are not using TLS version 1.2'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorRequestBodyInspection.js b/plugins/azure/frontdoor/frontDoorRequestBodyInspection.js new file mode 100644 index 000000000..b03aa1d4e --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorRequestBodyInspection.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Request Body Inspection', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensures that request body inspection is enabled for Azure Front Door WAF policy.', + more_info: 'Web Application Firewalls associated to Azure Front Doors that have request body inspection enabled allow to inspect properties within the HTTP body that may not be evaluated in the HTTP headers, cookies, or URI.', + recommended_action: 'Modify Front Door WAF policy and enable request body inspection in policy settings.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-waf-request-size-limits#request-body-inspection', + apis: ['afdWafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.afdWafPolicies, (location, rcb) => { + + var afdWafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', location]); + + if (!afdWafPolicies) return rcb(); + + if (afdWafPolicies.err || !afdWafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Front Door WAF policies: ' + helpers.addError(afdWafPolicies), location); + return rcb(); + } + if (!afdWafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + return rcb(); + } + + for (let policy of afdWafPolicies.data) { + if (!policy.id) continue; + + if (policy.policySettings && + policy.policySettings.requestBodyCheck && + policy.policySettings.requestBodyCheck.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Front Door WAF policy has request body inspection enabled', location, policy.id); + } else { + helpers.addResult(results, 2, 'Front Door WAF policy does not have request body inspection enabled', location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorRequestBodyInspection.spec.js b/plugins/azure/frontdoor/frontDoorRequestBodyInspection.spec.js new file mode 100644 index 000000000..d6f2adcf1 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorRequestBodyInspection.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var frontDoorRequestBodyInspection = require('./frontDoorRequestBodyInspection.js'); + +const afdWafPolicies = [ + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy2", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "policySettings": { + "enabledState": "Enabled", + "mode": "Prevention", + "redirectUrl": null, + "customBlockResponseStatusCode": 403, + "customBlockResponseBody": null, + "requestBodyCheck": "Disabled" + }, + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "policySettings": { + "enabledState": "Enabled", + "mode": "Prevention", + "redirectUrl": null, + "customBlockResponseStatusCode": 403, + "customBlockResponseBody": null, + "requestBodyCheck": "Enabled" + }, + + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "classic" + }, + "policySettings": { + "enabledState": "Enabled", + "mode": "Prevention", + "redirectUrl": null, + "customBlockResponseStatusCode": 403, + "customBlockResponseBody": null, + "requestBodyCheck": "Enabled" + }, + + }, +]; + +const createCache = (afdWafPolicies) => { + return { + afdWafPolicies: { + listAll: { + 'global': { + data: afdWafPolicies + } + } + } + }; +}; + +const createErrorCache = () => { + return { + afdWafPolicies: { + listAll: { + 'global': { + err: 'Unable to query' + } + } + } + }; +}; +describe('frontDoorRequestBodyInspection', function () { + describe('run', function () { + + it('should give pass result if request body inspection is enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[1]]); + frontDoorRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door WAF policy has request body inspection enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if no existing front door waf policy found', function (done) { + const cache = createCache([]); + frontDoorRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door WAF policies found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if request body inspection is not enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[0]]); + frontDoorRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door WAF policy does not have request body inspection enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for front door WAF policies', function (done) { + const cache = createErrorCache(); + frontDoorRequestBodyInspection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Front Door WAF policies:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.js b/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.js new file mode 100644 index 000000000..9242cf062 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door WAF Rate limit', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensures that Front Door WAF policy has rate limit custom rule configured.', + more_info: 'Rate limiting enables you to detect and block abnormally high levels of traffic from any socket IP address. By using Azure Web Application Firewall in Azure Front Door, you can mitigate some types of denial-of-service attacks.', + recommended_action: 'Modify the Front Door WAF policy and add default rate limit custom rule.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-rate-limit', + apis: ['afdWafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.afdWafPolicies, (location, rcb) => { + + var afdWafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', location]); + + if (!afdWafPolicies) return rcb(); + + if (afdWafPolicies.err || !afdWafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Front Door WAF policies: ' + helpers.addError(afdWafPolicies), location); + return rcb(); + } + if (!afdWafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + return rcb(); + } + + for (let policy of afdWafPolicies.data) { + if (!policy.id) continue; + var found = policy.customRules && policy.customRules.rules? + policy.customRules.rules.find(rule => rule.ruleType && rule.ruleType.toLowerCase() == 'ratelimitrule' && rule.action && rule.action.toLowerCase() == 'block') : false; + + if (found) { + helpers.addResult(results, 0, 'Front Door WAF policy has rate limit custom rule configured', location, policy.id); + } else { + helpers.addResult(results, 2, 'Front Door WAF policy does not have rate limit custom rule configured', location, policy.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.spec.js b/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.spec.js new file mode 100644 index 000000000..ae789f5fa --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDefaultRateLimit.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var frontDoorWafDefaultRateLimit = require('./frontDoorWafDefaultRateLimit.js'); + +const afdWafPolicies = [ + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy2", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "customRules": { + "rules": [ + { + "name": "testcustomrule", + "enabledState": "Enabled", + "priority": 1, + "ruleType": "RateLimitRule", + "rateLimitDurationInMinutes": 0, + "rateLimitThreshold": 0, + "matchConditions": [ + { + "matchVariable": "SocketAddr", + "selector": null, + "operator": "GeoMatch", + "negateCondition": false, + "matchValue": [ + "PK" + ], + "transforms": [] + } + ], + "action": "Block" + } + ] + } + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "customRules": { + "rules": [ + { + "name": "testcustomrule", + "enabledState": "Enabled", + "priority": 1, + "ruleType": "MatchRule", + "rateLimitDurationInMinutes": 0, + "rateLimitThreshold": 0, + "matchConditions": [ + { + "matchVariable": "SocketAddr", + "selector": null, + "operator": "GeoMatch", + "negateCondition": false, + "matchValue": [ + "PK" + ], + "transforms": [] + } + ], + "action": "Block" + } + ] + } + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Classic_AzureFrontDoor" + }, + "customRules": { + "rules": [ + { + "name": "testcustomrule", + "enabledState": "Enabled", + "priority": 1, + "ruleType": "MatchRule", + "rateLimitDurationInMinutes": 0, + "rateLimitThreshold": 0, + "matchConditions": [ + { + "matchVariable": "SocketAddr", + "selector": null, + "operator": "GeoMatch", + "negateCondition": false, + "matchValue": [ + "PK" + ], + "transforms": [] + } + ], + "action": "Block" + } + ] + } + }, +]; + +const createCache = (afdWafPolicies) => { + return { + afdWafPolicies: { + listAll: { + 'global': { + data: afdWafPolicies + } + } + } + }; +}; + +const createErrorCache = () => { + return { + afdWafPolicies: { + listAll: { + 'global': 'err' + } + } + }; +}; +describe('frontDoorWafDefaultRateLimit', function () { + describe('run', function () { + + it('should give pass result front door waf policy has rate limit custom rule configured', function (done) { + const cache = createCache([afdWafPolicies[0]]); + frontDoorWafDefaultRateLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door WAF policy has rate limit custom rule configured'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if no existing front door premium waf policy found', function (done) { + const cache = createCache([]); + frontDoorWafDefaultRateLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door WAF policies found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if front door WAF policy does not have rate limit custom rule configured', function (done) { + const cache = createCache([afdWafPolicies[1]]); + frontDoorWafDefaultRateLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door WAF policy does not have rate limit custom rule configured'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result unable to query for Front Door WAF policie', function (done) { + const cache = createErrorCache(); + frontDoorWafDefaultRateLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Front Door WAF policies'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.js b/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.js new file mode 100644 index 000000000..d4218508f --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.js @@ -0,0 +1,72 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door WAF Latest Default Rule Set', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensures that Azure Front Door WAF is using the latest Azure managed default rule set with action set to block.', + more_info: 'Azure-managed rule sets provide an easy way to deploy protection against a common set of security threats. Azure updates these rules as needed to protect against new attack signatures. ', + recommended_action: 'Modify the Front Door WAF policy and add latest default rule set.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview#waf-policy-and-rules', + apis: ['afdWafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.afdWafPolicies, (location, rcb) => { + + const minimumRuleSetVersion = 2.1; + //check for minimum allowed default ruleset version + + var afdWafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', location]); + + if (!afdWafPolicies) return rcb(); + + if (afdWafPolicies.err || !afdWafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Front Door WAF policies: ' + helpers.addError(afdWafPolicies), location); + return rcb(); + } + if (!afdWafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + return rcb(); + } + + var premiumWAF = false; + + for (let policy of afdWafPolicies.data) { + if (!policy.id || (policy.sku && policy.sku.name != 'Premium_AzureFrontDoor')) continue; + + premiumWAF = true; + var ruleSet = (policy.managedRules && + policy.managedRules.managedRuleSets && + policy.managedRules.managedRuleSets.find(set => set.ruleSetType && set.ruleSetType.toLowerCase() === 'microsoft_defaultruleset')) || {}; + + var ruleSetVersion = ruleSet.ruleSetVersion ? parseFloat(ruleSet.ruleSetVersion) : ''; + var ruleSetAction = ruleSet.ruleSetAction ? ruleSet.ruleSetAction.toLowerCase() : ''; + + if (ruleSetVersion >= minimumRuleSetVersion) { + if (ruleSetAction == 'block') { + helpers.addResult(results, 0, `Front Door WAF policy has latest ${ruleSet.ruleSetType}: ${ruleSet.ruleSetVersion} default rule set configured with ${ruleSetAction} action`, location, policy.id); + } else { + helpers.addResult(results, 2, `Front Door WAF policy has latest ${ruleSet.ruleSetType}: ${ruleSet.ruleSetVersion} default rule set configured with ${ruleSetAction} action`, location, policy.id); + } + } else { + helpers.addResult(results, 2, `Front Door WAF policy have default rule set configured with version less than ${minimumRuleSetVersion}`, location, policy.id); + } + } + + if (!premiumWAF) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.spec.js b/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.spec.js new file mode 100644 index 000000000..f54ffe42e --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDefaultRuleSet.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +var frontDoorWafDefaultRuleSet = require('./frontDoorWafDefaultRuleSet.js'); + +const afdWafPolicies = [ + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy2", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "Microsoft_DefaultRuleSet", + "ruleSetVersion": "2.0", + "ruleSetAction": "Block", + "exclusions": [] + } + ] + }, + + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "Microsoft_DefaultRuleSet", + "ruleSetVersion": "2.1", + "ruleSetAction": "Block", + "exclusions": [] + } + ] + }, + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "managedRules": { + "managedRuleSets": [ + { + "ruleSetType": "Microsoft_DefaultRuleSet", + "ruleSetVersion": "2.1", + "ruleSetAction": "Log", + "exclusions": [] + } + ] + }, + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Classic_AzureFrontDoor" + }, + }, +]; + +const createCache = (afdWafPolicies) => { + return { + afdWafPolicies: { + listAll: { + 'global': { + data: afdWafPolicies + } + } + } + }; +}; + +const createErrorCache = () => { + return { + afdWafPolicies: { + listAll: { + 'global': 'err' + } + } + }; +}; +describe('frontDoorWafDefaultRuleSet', function () { + describe('run', function () { + + it('should give pass result front door waf policy has latest 2.1 default rule set configured with block action', function (done) { + const cache = createCache([afdWafPolicies[1]]); + frontDoorWafDefaultRuleSet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('default rule set configured with block action'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if no existing front door premium waf policy found', function (done) { + const cache = createCache([]); + frontDoorWafDefaultRuleSet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door WAF policies found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if front door WAF policy does not have latest default rule set configured', function (done) { + const cache = createCache([afdWafPolicies[0]]); + frontDoorWafDefaultRuleSet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door WAF policy have default rule set configured with version less than'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if front door WAF policy have latest default rule set configured without block action', function (done) { + const cache = createCache([afdWafPolicies[2]]); + frontDoorWafDefaultRuleSet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door WAF policy has latest Microsoft_DefaultRuleSet: 2.1 default rule set configured with log'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result unable to query for Front Door WAF policie', function (done) { + const cache = createErrorCache(); + frontDoorWafDefaultRuleSet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Front Door WAF policies'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafDetectionMode.js b/plugins/azure/frontdoor/frontDoorWafDetectionMode.js new file mode 100644 index 000000000..bdd5368d2 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDetectionMode.js @@ -0,0 +1,49 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door WAF Detection Mode', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that WAF policy for Azure Front Door is set to Detection mode.', + more_info: 'Web Application Firewall (WAF) on Front Door provides centralized protection of your web applications from common exploits and vulnerabilities. Web applications are increasingly targeted by malicious attacks that exploit commonly known vulnerabilities. It monitors and logs the request and its matched WAF rule to WAF logs.', + recommended_action: 'Modify Front Door WAF policy and enable prevention mode.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview', + apis: ['afdWafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.afdWafPolicies, (location, rcb) => { + var afdWafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', location]); + + if (!afdWafPolicies) return rcb(); + + if (afdWafPolicies.err || !afdWafPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for Front Door WAF policies: ' + helpers.addError(afdWafPolicies), location); + return rcb(); + } + if (!afdWafPolicies.data.length) { + helpers.addResult(results, 0, 'No existing Front Door WAF policies found', location); + return rcb(); + } + + for (let policy of afdWafPolicies.data) { + if (!policy.id) continue; + + if (policy.policySettings && policy.policySettings.mode && policy.policySettings.mode.toLowerCase() == 'detection') { + helpers.addResult(results, 0, 'Detection mode enabled for Front Door WAF policy', location, policy.id); + } else { + helpers.addResult(results, 2, 'Detection mode not enabled for Front Door WAF policy', location, policy.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/frontdoor/frontDoorWafDetectionMode.spec.js b/plugins/azure/frontdoor/frontDoorWafDetectionMode.spec.js new file mode 100644 index 000000000..12fb5b70a --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafDetectionMode.spec.js @@ -0,0 +1,109 @@ +var expect = require('chai').expect; +var frontDoorWafDetectionMode = require('./frontDoorWafDetectionMode.js'); + +const afdWafPolicies = [ + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy2", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "policySettings": { + "enabledState": "Enabled", + "mode": "Prevention", + "redirectUrl": null, + "customBlockResponseStatusCode": 403, + "customBlockResponseBody": null, + "requestBodyCheck": "Disabled" + }, + }, + { + "id": "/subscriptions/123456789/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/testpolicy2", + "type": "Microsoft.Network/frontdoorwebapplicationfirewallpolicies", + "name": "testpolicy1", + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "policySettings": { + "enabledState": "Enabled", + "mode": "Detection", + "redirectUrl": null, + "customBlockResponseStatusCode": 403, + "customBlockResponseBody": null, + "requestBodyCheck": "Disabled" + }, + }, +]; + +const createCache = (afdWafPolicies) => { + return { + afdWafPolicies: { + listAll: { + 'global': { + data: afdWafPolicies + } + } + } + }; +}; + +const createErrorCache = () => { + return { + afdWafPolicies: { + listAll: { + 'global': {} + } + } + }; +}; + +describe('frontDoorWafDetectionMode', function () { + describe('run', function () { + + it('should give pass result if detection mode is enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[1]]); + frontDoorWafDetectionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Detection mode enabled for Front Door WAF policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for Front Door WAF policies', function (done) { + const cache = createErrorCache(); + frontDoorWafDetectionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Front Door WAF policies'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result if no existing front door waf policies found', function (done) { + const cache = createCache([]); + frontDoorWafDetectionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door WAF policies found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if detection mode is not enabled for front door waf policy', function (done) { + const cache = createCache([afdWafPolicies[0]]); + frontDoorWafDetectionMode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Detection mode not enabled for Front Door WAF policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/frontdoor/frontDoorWafEnabled.js b/plugins/azure/frontdoor/frontDoorWafEnabled.js new file mode 100644 index 000000000..a8f72e069 --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafEnabled.js @@ -0,0 +1,59 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Front Door Waf Enabled', + category: 'Front Door', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Web Application Firewall (WAF) is enabled for Azure Front Door premium and standard profiles.', + more_info: 'WAF actively inspects incoming requests to the front door and blocks requests that are determined to be malicious based on a set of rules.', + recommended_action: 'Modify the Azure Front Door profile and attach WAF policy under security policies section.', + link: 'https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-policy-settings', + apis: ['profiles:list', 'afdSecurityPolicies:listByProfile',], + realtime_triggers: ['microsoftcdn:profiles:write', 'microsoftcdn:profiles:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + async.each(locations.profiles, (location, rcb) => { + + var profiles = helpers.addSource(cache, source, + ['profiles', 'list', location]); + + if (!profiles) return rcb(); + + if (profiles.err || !profiles.data) { + helpers.addResult(results, 3, 'Unable to query Front Door profiles: ' + helpers.addError(profiles), location); + return rcb(); + } + if (!profiles.data.length) { + helpers.addResult(results, 0, 'No existing Front Door profiles found', location); + return rcb(); + } + + + profiles.data.forEach(function(profile) { + if (!profile.id) return; + + const afdSecurityPolicies = helpers.addSource(cache, source, + ['afdSecurityPolicies', 'listByProfile', location, profile.id]); + + if (!afdSecurityPolicies || afdSecurityPolicies.err || !afdSecurityPolicies.data) { + helpers.addResult(results, 3, 'Unable to query Front Door security policies : ' + helpers.addError(afdSecurityPolicies), location, profile.id); + } else { + if (!afdSecurityPolicies.data.length) { + helpers.addResult(results, 2, 'Front Door profile does not have WAF enabled', location, profile.id); + } else { + helpers.addResult(results, 0, 'Front Door profile has WAF enabled', location, profile.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/frontdoor/frontDoorWafEnabled.spec.js b/plugins/azure/frontdoor/frontDoorWafEnabled.spec.js new file mode 100644 index 000000000..f01db77fe --- /dev/null +++ b/plugins/azure/frontdoor/frontDoorWafEnabled.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var frontDoorWafEnabled = require('./frontDoorWafEnabled.js'); + +const profiles = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/Microsoft.Cdn/profiles/omer-cdn-profile-test", + "type": "Microsoft.Cdn/profiles", + "name": "omer-cdn-profile-test", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Standard_AzureFrontDoor" + }, + "frontDoorId": "cd0e521b-8975-411d-b009-7db9de8f16a3", + "resourceState": "Active", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/mehak-fd", + "type": "Microsoft.Cdn/profiles", + "name": "mehak-fd", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "frontDoorId": "40590271-c2c4-4264-8061-45b884a91a70", + "resourceState": "Active", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/meerab-test", + "type": "Microsoft.Cdn/profiles", + "name": "meerab-test", + "location": "Global", + "kind": "frontdoor", + "tags": {}, + "sku": { + "name": "Premium_AzureFrontDoor" + }, + "frontDoorId": "7bc32535-6836-44ef-99fa-19dbdcf4dabf", + "resourceState": "Active", + "provisioningState": "Succeeded" + }, +]; + +const afdSecurityPolicies = [ + { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/meerab-test/securitypolicies/e9df717b-b2c0-4f37-9150-33d736402038", + "type": "Microsoft.Cdn/profiles/securitypolicies", + "name": "e9df717b-b2c0-4f37-9150-33d736402038", + "parameters": { + "wafPolicy": { + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/meerabpremiumtest" + }, + "associations": [ + { + "domains": [ + { + "isActive": true, + "id": "/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/meerab-rg/providers/Microsoft.Cdn/profiles/meerab-test/afdendpoints/test-meerab-runtime" + } + ], + "patternsToMatch": [ + "/*" + ] + } + ], + "type": "WebApplicationFirewall" + }, + "deploymentStatus": "NotStarted", + "provisioningState": "Succeeded" + } +] + +const createCache = (profiles, securityPolicies) => { + let securityPolicy = {}; + if (profiles.length) { + securityPolicy[profiles[0].id] = { + data: securityPolicies + }; + } + + + return { + profiles: { + list: { + 'global': { + data: profiles + } + } + }, + afdSecurityPolicies: { + listByProfile: { + 'global': securityPolicy + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'profile') { + return { + profiles: { + list: { + 'global': {} + } + } + }; + } else if (key === 'noprofile'){ + return { + profiles: { + list: { + 'global': { + data:{} + } + } + } + }; + }else if (key === 'securityPolicy') { + return { + profiles: { + list: { + 'global': { + data: [profiles[0]] + } + } + }, + afdSecurityPolicies: { + listByProfile: { + 'global': {} + } + } + }; + } else { + const profileId = (profiles && profiles.length) ? profiles[1].id : null; + const securityPolicy = (afdSecurityPolicies && afdSecurityPolicies.length) ? afdSecurityPolicies[0].id : null; + return { + profiles: { + list: { + 'global': { + data: [profiles[1]] + } + } + }, + afdSecurityPolicies: { + listByProfile: { + 'global': { + data: {} + } + } + } + }; + } +}; + +describe('frontDoorWafEnabled', function () { + describe('run', function () { + + it('should give pass result if No existing Azure Front Door profiles found', function (done) { + const cache = createErrorCache('noprofile'); + frontDoorWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Front Door profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query Front Door profiles:', function (done) { + const cache = createErrorCache('profile'); + frontDoorWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door profiles: '); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query security policies', function (done) { + const cache = createErrorCache('policy'); + frontDoorWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Front Door security policies'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give pass result Front Door profile have waf enabled', function (done) { + const cache = createCache([profiles[1]], [afdSecurityPolicies[0]]); + frontDoorWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Front Door profile has WAF enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give fail result if Front Door profile does not have waf enabled', function (done) { + const cache = createCache([profiles[1]], []); + frontDoorWafEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Front Door profile does not have WAF enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/allowedCertificateKeyTypes.js b/plugins/azure/keyvaults/allowedCertificateKeyTypes.js new file mode 100644 index 000000000..ba99d945d --- /dev/null +++ b/plugins/azure/keyvaults/allowedCertificateKeyTypes.js @@ -0,0 +1,86 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Allowed Certificates Key Types', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Key Vault SSL certificates are using the allowed key types.', + more_info: 'Having the right key type set for your Azure Key Vault SSL certificates will enforce the best practices as specified in the security and compliance regulations implemented within your organization.', + recommended_action: 'Ensure that Key Vault SSL certificates are using the allowed key types.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/certificates/certificate-access-control', + apis: ['vaults:list', 'vaults:getCertificates', 'getCertificatePolicy:get'], + settings: { + allowed_certificate_key_types: { + name: 'Key Vault Certificate Key Types', + description: 'Comma separated key types supported for certificates in Azure Key Vault', + regex: '^(RSA|EC)$', + default: '' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + allowed_certificate_key_types: settings.allowed_certificate_key_types || this.settings.allowed_certificate_key_types.default, + }; + + if (!config.allowed_certificate_key_types.length) return callback(null, results, source); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach((vault) => { + var certificates = helpers.addSource(cache, source, + ['vaults', 'getCertificates', location, vault.id]); + + if (!certificates || certificates.err || !certificates.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault certificates: ' + helpers.addError(certificates), location, vault.id); + } else if (!certificates.data.length) { + helpers.addResult(results, 0, 'No Key Vault Certificates found', location, vault.id); + } else { + certificates.data.forEach((certificate) => { + var certificatePolicy = helpers.addSource(cache, source, + ['getCertificatePolicy', 'get', location, certificate.id]); + + if (!certificatePolicy || certificatePolicy.err || !certificatePolicy.data) { + helpers.addResult(results, 3, 'Unable to query for Certificate Policy: ' + helpers.addError(certificatePolicy), location, certificate.id); + } else { + const certificateKeys = certificatePolicy.data.key_props; + if (certificateKeys && certificateKeys.kty) { + if (config.allowed_certificate_key_types.toLowerCase().includes(certificateKeys.kty.toLowerCase())) { + helpers.addResult(results, 0, 'Certificate key type is ' + certificateKeys.kty, location, certificate.id); + } else { + helpers.addResult(results, 2, 'Certificate key type is ' + certificateKeys.kty, location, certificate.id); + } + } else { + helpers.addResult(results, 3, 'Unable to list key type for Key Vault Certificate: ' + helpers.addError(certificatePolicy), location, certificate.id); + } + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/allowedCertificateKeyTypes.spec.js b/plugins/azure/keyvaults/allowedCertificateKeyTypes.spec.js new file mode 100644 index 000000000..52aa67354 --- /dev/null +++ b/plugins/azure/keyvaults/allowedCertificateKeyTypes.spec.js @@ -0,0 +1,197 @@ +var expect = require('chai').expect; +var auth = require('./allowedCertificateKeyTypes'); + +const listVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const certificates = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert', + x5t: 'wi16heNOLNuStvVprwu6rzs0_is', + attributes: [Object], + tags: {}, + subject: '' + } +]; + +const certificatePolicy = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + key_props: { exportable: true, kty: 'RSA', key_size: 2048, reuse_key: false }, + secret_props: { contentType: 'application/x-pkcs12' }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + }, + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + }, + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + key_props: { exportable: true, kty: 'ECC', key_size: 2048, reuse_key: false }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + } +]; + +const createCache = (err, list, certs, getCertificatePolicy) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getCertificates: { + 'eastus': certs + } + }, + getCertificatePolicy: { + get: { + 'eastus': getCertificatePolicy + } + } + } +}; + +describe('allowedCertificateKeyTypes', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give passing result if no key vault certificates found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vault Certificates found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [] } }, {}), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give unkown result if unable to query for Key Vault Certificates', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault certificates'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null, {}), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give unknown results if unable to query for certificate policy', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Certificate Policy'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, {}), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give unknown results if Unable to list key type for Key Vault Certificate', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list key type for Key Vault Certificate'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: [certificatePolicy[1]] } ), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give passing result if certificate has allowed key type', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Certificate key type is RSA'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[0] } } ), { allowed_certificate_key_types: 'rsa' }, callback); + }); + + it('should give failing result if certificate does not have allowed key type', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Certificate key type is ECC'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[2] } } ), { allowed_certificate_key_types: 'rsa' }, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/appTierCmkInUse.js b/plugins/azure/keyvaults/appTierCmkInUse.js new file mode 100644 index 000000000..e04af7f26 --- /dev/null +++ b/plugins/azure/keyvaults/appTierCmkInUse.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Tier CMK In Use', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that a Customer-Managed Key (CMK) is created and configured for your Microsoft Azure application tier.', + more_info: 'Setting a CMK for app tier, you gain full control over who can use this key to access the application data, implementing the principle of least privilege on the encryption key ownership and usage.', + recommended_action: 'Ensure a CMK created and configured for application tier in each region.', + link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/concept-customer-managed-keys', + apis: ['vaults:list', 'vaults:getKeys'], + settings: { + app_tier_tag_key: { + name: 'App-Tier Tag Key', + description: 'Tag key to indicate App-Tier Key Vault keys', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + app_tier_tag_key: settings.app_tier_tag_key || this.settings.app_tier_tag_key.default + }; + + if (!config.app_tier_tag_key.length) return callback(null, results, source); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 2, 'No Key Vaults found', location); + return rcb(); + } + + let appTierKey; + vaults.data.forEach((vault) => { + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + return; + } + + if (keys.data.length) { + for (let key of keys.data) { + var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); + var keyId = `${vault.id}/keys/${keyName}`; + if (key.tags) { + if (Object.keys(key.tags).includes(config.app_tier_tag_key)) { + appTierKey = keyId; + break; + } + } + } + } + }); + + if (appTierKey) { + helpers.addResult(results, 0, `CMK exists for application tier: ${appTierKey}`, location); + } else { + helpers.addResult(results, 2, 'CMK does not exist for application tier', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/appTierCmkInUse.spec.js b/plugins/azure/keyvaults/appTierCmkInUse.spec.js new file mode 100644 index 000000000..7e1f2852c --- /dev/null +++ b/plugins/azure/keyvaults/appTierCmkInUse.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var auth = require('./appTierCmkInUse'); + +const listVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + }, + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": { hello: 'world' }, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const getKeys = [ + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": { + "apptier": "app-rier" + } + }, + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": 1635448252, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: keys + } + } + } + } + } +}; + +describe('appTierCmkInUse', function() { + describe('run', function() { + it('should give failing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), { app_tier_tag_key: 'apptier' }, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}), { app_tier_tag_key: 'apptier' }, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults keys', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault keys'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null), { app_tier_tag_key: 'apptier' }, callback); + }); + + it('should give passing result if CMK exists for application tier', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CMK exists for application tier'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[0]]), { app_tier_tag_key: 'apptier' }, callback); + }); + + it('should give failing result if CMK does not exist for application tier', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CMK does not exist for application tier'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[0]]), { app_tier_tag_key: 'apptire' }, callback); + }) + }) +}); diff --git a/plugins/azure/keyvaults/databaseTierCmkInUse.js b/plugins/azure/keyvaults/databaseTierCmkInUse.js new file mode 100644 index 000000000..ba2a8e4b8 --- /dev/null +++ b/plugins/azure/keyvaults/databaseTierCmkInUse.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Database Tier CMK In Use', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that a Customer-Managed Key (CMK) is created and configured for your Microsoft Azure application tier.', + more_info: 'Setting a CMK for database tier, you gain full control over who can use this key to access the database tier data, implementing the principle of least privilege on the encryption key ownership and usage.', + recommended_action: 'Ensure a CMK created and configured for database tier in each region.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview?view=azuresql', + apis: ['vaults:list', 'vaults:getKeys'], + settings: { + db_tier_tag_key: { + name: 'Database-Tier Tag Key', + description: 'Tag key to indicate Database-Tier Key Vault keys', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + db_tier_tag_key: settings.db_tier_tag_key || this.settings.db_tier_tag_key.default + }; + + if (!config.db_tier_tag_key.length) return callback(null, results, source); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 2, 'No Key Vaults found', location); + return rcb(); + } + + let dbTierKey; + vaults.data.forEach((vault) => { + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + return; + } + + if (keys.data.length) { + for (let key of keys.data) { + var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); + var keyId = `${vault.id}/keys/${keyName}`; + if (key.tags) { + if (Object.keys(key.tags).includes(config.db_tier_tag_key)) { + dbTierKey = keyId; + break; + } + } + } + } + }); + + if (dbTierKey) { + helpers.addResult(results, 0, `CMK exists for database tier: ${dbTierKey}`, location); + } else { + helpers.addResult(results, 2, 'CMK does not exist for database tier', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/databaseTierCmkInUse.spec.js b/plugins/azure/keyvaults/databaseTierCmkInUse.spec.js new file mode 100644 index 000000000..fe70b57af --- /dev/null +++ b/plugins/azure/keyvaults/databaseTierCmkInUse.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var auth = require('./databaseTierCmkInUse'); + +const listVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + }, + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": { hello: 'world' }, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const getKeys = [ + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": { + "databasetier": "db-rier" + } + }, + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": 1635448252, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: keys + } + } + } + } + } +}; + +describe('databaseTierCmkInUse', function() { + describe('run', function() { + it('should give failing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), { db_tier_tag_key: 'dbtier' }, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}), { db_tier_tag_key: 'dbtier' }, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults keys', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault keys'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null), { db_tier_tag_key: 'dbtier' }, callback); + }); + + it('should give passing result if CMK exists for database tier', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CMK exists for database tier'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[0]]), { db_tier_tag_key: 'databasetier' }, callback); + }); + + it('should give failing result if CMK does not exist for database tier', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CMK does not exist for database tier'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[0]]), { db_tier_tag_key: 'dbtire' }, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyExpirationEnabled.js b/plugins/azure/keyvaults/keyExpirationEnabled.js index f8dcea838..89fc5bcf9 100644 --- a/plugins/azure/keyvaults/keyExpirationEnabled.js +++ b/plugins/azure/keyvaults/keyExpirationEnabled.js @@ -4,10 +4,12 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Key Expiration Enabled', category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensure that all Keys in Azure Key Vault have an expiry time set.', more_info: 'Setting an expiry time on all keys forces key rotation and removes unused and forgotten keys from being used.', recommended_action: 'Ensure each Key Vault has an expiry time set that provides for sufficient rotation.', - link: 'https://docs.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', apis: ['vaults:list', 'vaults:getKeys'], compliance: { pci: 'PCI has strict requirements regarding the use of encryption keys ' + @@ -15,6 +17,7 @@ module.exports = { 'the Key periodically. Key Vaults provides Key expiration capabilities that ' + 'should be enabled.' }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], run: function(cache, settings, callback) { var results = []; var source = {}; @@ -51,7 +54,7 @@ module.exports = { if (key.attributes) { let attributes = key.attributes; - if (attributes.expires && attributes.expires !== null && attributes.expires !== '') { + if ((attributes.expires && attributes.expires !== null && attributes.expires !== '') || (attributes.exp && attributes.exp !== null && attributes.exp !== '')) { helpers.addResult(results, 0, 'Expiry date is set for the key', location, keyId); } else { @@ -72,4 +75,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/azure/keyvaults/keyExpirationEnabled.spec.js b/plugins/azure/keyvaults/keyExpirationEnabled.spec.js new file mode 100644 index 000000000..bd66d5690 --- /dev/null +++ b/plugins/azure/keyvaults/keyExpirationEnabled.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var auth = require('./keyExpirationEnabled'); + +const listVaults = [ + { + "id": "/subscriptions/123/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + }, + { + "id": "/subscriptions/123/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": { hello: 'world' }, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const getKeys = [ + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": { + "apptier": "app-rier" + } + }, + { + "kid": "https://testvault.vault.azure.net/keys/test", + "attributes": { + "enabled": true, + "exp": 1635448252, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + }, + { + "kid": "https://testvault.vault.azure.net/keys/test", + "tags": {} + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/123/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: keys + } + } + } + } + } +}; + +describe('keyExpirationEnabled', function() { + describe('run', function() { + it('should give Passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults keys', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault keys'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null), {}, callback); + }); + + it('should give passing result if Expiry date is set for the key', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Expiry date is set for the key'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[1]]), {}, callback); + }); + + it('should give failing result if Expiry date is not set for the key', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Expiry date is not set for the key'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[0]]), {}, callback); + }); + + + it('should give failing result if Expiry date is not set for the key', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Expiry date is not set for the key'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]], [getKeys[2]]), {}, callback); + }) + }) +}); diff --git a/plugins/azure/keyvaults/keyVaultHasTags.js b/plugins/azure/keyvaults/keyVaultHasTags.js new file mode 100644 index 000000000..a961480e5 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultHasTags.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Has Tags', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that Azure Key Vault vaults have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify key vault and tags', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['vaults:list'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + for (let vault of vaults.data) { + if (vault.tags && Object.entries(vault.tags).length > 0){ + helpers.addResult(results, 0, 'Key Vault has tags associated', location, vault.id); + } else { + helpers.addResult(results, 2, 'Key Vault does not have tags associated', location, vault.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultHasTags.spec.js b/plugins/azure/keyvaults/keyVaultHasTags.spec.js new file mode 100644 index 000000000..c205522b8 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultHasTags.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var keyVaultHasTags = require('./keyVaultHasTags'); + +const listVaults = [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": { "key": "vault" }, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "defaultAction": "Deny", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + }, + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "AzureServices", + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + }, +]; + +const createCache = (err, list) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + } + } +}; + +describe('keyVaultHasTags', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + keyVaultHasTags.run(createCache(null, []), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + keyVaultHasTags.run(createCache(null, null), {}, callback); + }); + + it('should give passing result if key vault has tags', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault has tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + keyVaultHasTags.run(createCache(null, [listVaults[0]]), {}, callback); + }); + + it('should give failing result if key vault does not have tags', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + keyVaultHasTags.run(createCache(null, [listVaults[1]]), {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultInUse.js b/plugins/azure/keyvaults/keyVaultInUse.js new file mode 100644 index 000000000..59e0c8b79 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultInUse.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault In Use', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures that Key Vaults are being used to store secrets.', + more_info: 'App secrets control access to the application and thus need to be secured externally to the app configuration, storing the secrets externally and referencing them in the configuration also enables key rotation without having to redeploy the app service.', + recommended_action: 'Ensure that Azure Key Vaults are being used to store secrets.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references', + apis: ['vaults:list', 'vaults:getKeys', 'vaults:getSecrets'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 2, 'Key Vaults are not being used to store secrets', location); + return rcb(); + } + + let keyVaultsBeingUsed = false; + for (let vault of vaults.data) { + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + var secrets = helpers.addSource(cache, source, + ['vaults', 'getSecrets', location, vault.id]); + + if ((keys && keys.data && keys.data.length) || (secrets && secrets.data && secrets.data.length)) { + keyVaultsBeingUsed = true; + break; + } + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + } + + if (!secrets || secrets.err || !secrets.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault secrets: ' + helpers.addError(secrets), location, vault.id); + } + } + + if (keyVaultsBeingUsed) { + helpers.addResult(results, 0, 'Key Vaults are being used to store secrets', location); + } else { + helpers.addResult(results, 2, 'Key Vaults are not being used to store secrets', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultInUse.spec.js b/plugins/azure/keyvaults/keyVaultInUse.spec.js new file mode 100644 index 000000000..9baffbc9b --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultInUse.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultInUse'); + +const listKeyVaults = [ + { + id: '/subscriptions/qdn32rdm-ebf6-437f-a3b0-28fc0d22111e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/nauman-test', + name: 'nauman-test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tags: { owner: 'kubernetes' }, + sku: { family: 'A', name: 'Standard' }, + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: 'b4062000-c33b-448b-817e-fa0f17bef4b9', + permissions: { + keys: ['Get', 'List'], + secrets: ['Get', 'List'], + certificates: ['Get', 'List'] + } + } + ], + enableSoftDelete: true, + softDeleteRetentionInDays: 7, + enableRbacAuthorization: false, + vaultUri: 'https://nauman-test.vault.azure.net/', + provisioningState: 'Succeeded' + } +]; + +const getKeys = [ + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": null, + "notBefore": null, + "recoveryLevel": "CustomizedRecoverable+Purgeable", + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://nauman-test.vault.azure.net/keys/nauman-test", + "managed": null, + "name": "nauman-test", + "tags": { + "hello": "world" + } + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/qdn32rdm-ebf6-437f-a3b0-28fc0d22111e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/nauman-test': { + err: err, + data: keys + } + } + } + } + } +}; + +describe('keyVaultInUse', function() { + describe('run', function() { + it('should give failing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vaults are not being used to store secrets'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if key vaults are being used to store secrets', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vaults are being used to store secrets'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[0]]), {}, callback); + }); + + it('should give failing result if no keys in key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vaults are not being used to store secrets'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiry.js b/plugins/azure/keyvaults/keyVaultKeyExpiry.js new file mode 100644 index 000000000..6ba32db01 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiry.js @@ -0,0 +1,94 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Key Expiry RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that expiration date is set for all keys in RBAC Key Vaults.', + more_info: 'Setting an expiration date on keys helps in key lifecycle management and ensures that keys are rotated regularly.', + recommended_action: 'Modify keys in RBAC Key Vaults to have an expiration date set.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getKeys'], + settings: { + key_vault_key_expiry_fail: { + name: 'Key Vault Key Expiry Fail', + description: 'Return a failing result when key expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_key_expiry_fail: parseInt(settings.key_vault_key_expiry_fail || this.settings.key_vault_key_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + if (!vault.enableRbacAuthorization) { + return; + } + + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + } else if (!keys.data.length) { + helpers.addResult(results, 0, 'No Key Vault keys found in RBAC vault', location, vault.id); + } else { + keys.data.forEach(function(key) { + var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); + var keyId = `${vault.id}/keys/${keyName}`; + + if (!key.attributes || !key.attributes.enabled) { + helpers.addResult(results, 0, + 'Key in RBAC vault is not enabled', location, keyId); + } else if (key.attributes && (key.attributes.expires || key.attributes.exp)) { + let keyExpiry = key.attributes.exp ? key.attributes.exp * 1000 : key.attributes.expires; + let difference = Math.round((new Date(keyExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_key_expiry_fail) { + helpers.addResult(results, 0, + `Key in RBAC vault expires in ${difference} days`, location, keyId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Key in RBAC vault expires in ${difference} days`, location, keyId); + } else { + helpers.addResult(results, 2, + `Key in RBAC vault expired ${Math.abs(difference)} days ago`, location, keyId); + } + } else { + helpers.addResult(results, 0, + 'Key expiration is not enabled in RBAC vault', location, keyId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js b/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js new file mode 100644 index 000000000..7aa4a804b --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultKeyExpiry'); + +var keyExpiryPass = new Date(); +keyExpiryPass.setMonth(keyExpiryPass.getMonth() + 2); + +var keyExpiryFail = new Date(); +keyExpiryFail.setMonth(keyExpiryFail.getMonth() + 1); + +var keyExpired = new Date(); +keyExpired.setMonth(keyExpired.getMonth() - 1); + +const listKeyVaults = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault', + name: 'test-vault', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: true, + properties: { + vaultUri: 'https://test-vault.vault.azure.net/' + } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault-2', + name: 'test-vault-2', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: false, + properties: { + vaultUri: 'https://test-vault-2.vault.azure.net/' + } + } +]; + +const getKeys = [ + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": null, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key", + "name": "test-key" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryPass, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-2", + "name": "test-key-2" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryFail, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-3", + "name": "test-key-3" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpired, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-4", + "name": "test-key-4" + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault': { + err: err, + data: keys + } + } + } + } + } +}; + +describe('keyVaultKeyExpiryRbac', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if expiration is not set on keys', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key expiration is not enabled in RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[0]]), {}, callback); + }); + + it('should give passing result if expiry date is not yet reached', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key in RBAC vault expires in'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[1]]), { key_vault_key_expiry_fail: '30' }, callback); + }); + + it('should give failing results if the key has expired', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key in RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[3]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + + it('should give failing result if the key expires within failure expiry date', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key in RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[2]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + }); +}); diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js new file mode 100644 index 000000000..df9665a29 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Key Expiry Non RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that expiration date is set for all keys in non RBAC Key Vaults.', + more_info: 'Setting an expiration date on keys helps in key lifecycle management and ensures that keys are rotated regularly.', + recommended_action: 'Modify keys in non-RBAC Key Vaults to have an expiration date set.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getKeys'], + settings: { + non_rbac_key_vault_key_expiry_fail: { + name: 'Key Vault Key Expiry Fail', + description: 'Return a failing result when key expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_key_expiry_fail: parseInt(settings.non_rbac_key_vault_key_expiry_fail || this.settings.non_rbac_key_vault_key_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + if (vault.enableRbacAuthorization) { + return; + } + + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, + 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + } else if (!keys.data.length) { + helpers.addResult(results, 0, + 'No Key Vault keys found in non RBAC vault', location, vault.id); + } else { + keys.data.forEach(function(key) { + var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); + var keyId = `${vault.id}/keys/${keyName}`; + + if (!key.attributes || !key.attributes.enabled) { + helpers.addResult(results, 0, + 'Key in non RBAC vault is not enabled', location, keyId); + } else if (key.attributes && (key.attributes.expires || key.attributes.exp)) { + let keyExpiry = key.attributes.exp ? key.attributes.exp * 1000 : key.attributes.expires; + let difference = Math.round((new Date(keyExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_key_expiry_fail) { + helpers.addResult(results, 0, + `Key in non RBAC vault expires in ${difference} days`, location, keyId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Key in non RBAC vault expires in ${difference} days`, location, keyId); + } else { + helpers.addResult(results, 2, + `Key in non RBAC vault expired ${Math.abs(difference)} days ago`, location, keyId); + } + } else { + helpers.addResult(results, 0, + 'Key expiration is not enabled in non RBAC vault', location, keyId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js new file mode 100644 index 000000000..43ed0ab13 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultKeyExpiryNonRbac'); + +var keyExpiryPass = new Date(); +keyExpiryPass.setMonth(keyExpiryPass.getMonth() + 2); + +var keyExpiryFail = new Date(); +keyExpiryFail.setDate(keyExpiryFail.getDate() + 25); + +var keyExpired = new Date(); +keyExpired.setMonth(keyExpired.getMonth() - 1); + +const listKeyVaults = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault', + name: 'test-vault', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: false, + properties: { + vaultUri: 'https://test-vault.vault.azure.net/' + } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault-2', + name: 'test-vault-2', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: true, + properties: { + vaultUri: 'https://test-vault-2.vault.azure.net/' + } + } +]; + +const getKeys = [ + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": null, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key", + "name": "test-key" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryPass, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-2", + "name": "test-key-2" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryFail, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-3", + "name": "test-key-3" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpired, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-4", + "name": "test-key-4" + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault': { + err: err, + data: keys + } + } + } + } + } +}; + +describe('keyVaultKeyExpiryNonRbac', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if no non-RBAC key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); // No results since we skip RBAC vaults + done() + }; + + auth.run(createCache(null, [listKeyVaults[1]], []), {}, callback); + }); + + it('should give passing result if expiration is not set on keys in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key expiration is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[0]]), {}, callback); + }); + + it('should give passing result if expiry date is not yet reached in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key in non RBAC vault expires in'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[1]]), { key_vault_key_expiry_fail: '30' }, callback); + }); + + it('should give failing result if the key has expired in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key in non RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[3]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + + it('should give failing result if the key expires within failure expiry date in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[2]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + }); +}); diff --git a/plugins/azure/keyvaults/keyVaultPublicAccess.js b/plugins/azure/keyvaults/keyVaultPublicAccess.js new file mode 100644 index 000000000..1e0459769 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultPublicAccess.js @@ -0,0 +1,118 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Public Access', + category: 'Key Vault', + domain: 'Security', + severity: 'High', + description: 'Ensures that Azure Key Vaults do not allow unrestricted public access', + more_info: 'Azure Key Vaults should be configured to restrict public access to protect sensitive data. This can be achieved by either disabling public network access or implementing strict network rules.', + recommended_action: 'Modify Key Vault network settings to disable public access or appropriate configure network rules.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/network-security', + apis: ['vaults:list'], + settings: { + keyvault_allowed_ips: { + name: 'Key Vault Allowed IPs', + description: 'Comma-separated list of IP addresses that are explicitly allowed to access Key Vaults', + regex: '^(?:\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?:/\\d{1,2})?(?:,\\s*)?)+$', + default: '' + } + }, + realtime_triggers: ['microsoft.keyvault:vaults:write', 'microsoft.keyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var config = { + keyvault_allowed_ips: settings.keyvault_allowed_ips || this.settings.keyvault_allowed_ips.default + }; + + var allowedIps = []; + if (config.keyvault_allowed_ips && config.keyvault_allowed_ips.length) { + allowedIps = config.keyvault_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, + 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + if (!vault.id) return; + + if (vault && + vault.publicNetworkAccess && + vault.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'Key Vault is protected from outside traffic', + location, vault.id); + return; + } + + + if (vault && vault.networkAcls) { + var networkAcls = vault.networkAcls; + var defaultAction = networkAcls.defaultAction ? networkAcls.defaultAction.toLowerCase() : null; + + if (!defaultAction || defaultAction === 'allow') { + helpers.addResult(results, 2, + 'Key Vault is open to outside traffic', + location, vault.id); + return; + } + + if (defaultAction === 'deny') { + var ipRules = networkAcls.ipRules || []; + var hasPublicAccess = false; + var publicAccessFound = []; + + for (var rule of ipRules) { + if (checkAllowedIps) { + if (!allowedIps.includes(rule.value)) { + hasPublicAccess = true; + publicAccessFound.push(rule.value); + } + } else if (rule.value === '0.0.0.0/0' || rule.value === '0.0.0.0') { + hasPublicAccess = true; + publicAccessFound.push(rule.value); + } + } + + if (hasPublicAccess) { + helpers.addResult(results, 2, + `Key Vault is open to outside traffic through IP rules: ${publicAccessFound.join(', ')}`, + location, vault.id); + } else { + var message = 'Key Vault is protected from outside traffic'; + helpers.addResult(results, 0, message, location, vault.id); + } + } + } else { + helpers.addResult(results, 2, + 'Key Vault is open to outside traffic', + location, vault.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js b/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js new file mode 100644 index 000000000..e79bdbee4 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js @@ -0,0 +1,192 @@ +var expect = require('chai').expect; +var keyVaultPublicAccess = require('./keyVaultPublicAccess'); + +const vaults = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test1", + "name": "test1", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Disabled" + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test2", + "name": "test2", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "10.0.0.0/16" + } + ] + } + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test3", + "name": "test3", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [] + } + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test4", + "name": "test4", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "0.0.0.0/0" + } + ] + } + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test6", + "name": "test6", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled" + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test7", + "name": "test7", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "192.168.1.1" + } + ] + + } + } +]; + +const createCache = (vaults) => { + return { + vaults: { + list: { + 'eastus': { + data: vaults + } + } + } + }; +}; + +const createErrorCache = () => { + return { + vaults: { + list: { + 'eastus': { + err: { + message: 'error loading vaults' + } + } + } + } + }; +}; + +describe('keyVaultPublicAccess', function () { + describe('run', function () { + it('should give passing result if no key vaults found', function (done) { + const cache = createCache([]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for key vaults', function (done) { + const cache = createErrorCache(); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is disabled', function (done) { + const cache = createCache([vaults[0]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if default action is deny and no public IPs allowed', function (done) { + const cache = createCache([vaults[1]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if default action is allow', function (done) { + const cache = createCache([vaults[2]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if IPv4 public access is allowed', function (done) { + const cache = createCache([vaults[3]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no network ACLs configured', function (done) { + const cache = createCache([vaults[4]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if IP is in allowed list', function (done) { + const cache = createCache([vaults[5]]); + keyVaultPublicAccess.run(cache, { keyvault_allowed_ips: '192.168.1.1' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiry.js b/plugins/azure/keyvaults/keyVaultSecretExpiry.js new file mode 100644 index 000000000..2dd31788d --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiry.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Secret Expiry RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Proactively check for RBAC Key Vault secrets expiry date and rotate them before expiry date is reached.', + more_info: 'After the expiry date has reached for Key Vault secret, it cannot be used for storing sensitive and confidential data such as passwords and database connection strings anymore.', + recommended_action: 'Ensure that Key Vault secrets are rotated before they get expired.', + link: 'https://learn.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getSecrets'], + settings: { + key_vault_secret_expiry_fail: { + name: 'Key Vault Secret Expiry Fail', + description: 'Return a failing result when secret expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_secret_expiry_fail: parseInt(settings.key_vault_secret_expiry_fail || this.settings.key_vault_secret_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + // Check if vault is RBAC-enabled + if (!vault.enableRbacAuthorization) { + return; + } + + var secrets = helpers.addSource(cache, source, + ['vaults', 'getSecrets', location, vault.id]); + + if (!secrets || secrets.err || !secrets.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault secrets: ' + helpers.addError(secrets), location, vault.id); + } else if (!secrets.data.length) { + helpers.addResult(results, 0, 'No Key Vault secrets found in RBAC vault', location, vault.id); + } else { + secrets.data.forEach(function(secret) { + var secretName = secret.id.substring(secret.id.lastIndexOf('/') + 1); + var secretId = `${vault.id}/secrets/${secretName}`; + + if (!secret.attributes || !secret.attributes.enabled) { + helpers.addResult(results, 0, 'Secret in RBAC vault is not enabled', location, secretId); + } else if (secret.attributes && (secret.attributes.exp || secret.attributes.expiry)) { + let attributes = secret.attributes; + let secretExpiry = attributes.exp ? attributes.exp * 1000 : attributes.expiry; + let difference = Math.round((new Date(secretExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_secret_expiry_fail) { + helpers.addResult(results, 0, + `Secret in RBAC vault expires in ${difference} days`, location, secretId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Secret in RBAC vault expires in ${difference} days`, location, secretId); + } else { + helpers.addResult(results, 2, + `Secret in RBAC vault expired ${Math.abs(difference)} days ago`, location, secretId); + } + } else { + helpers.addResult(results, 0, + 'Secret expiration is not enabled in RBAC vault', location, secretId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js b/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js new file mode 100644 index 000000000..4bf693b9e --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultSecretExpiry'); + +var secretExpiryPass = new Date(); +secretExpiryPass.setMonth(secretExpiryPass.getMonth() + 2); + +var secretExpiryFail = new Date(); +secretExpiryFail.setMonth(secretExpiryFail.getMonth() + 1); + +var secretExpired = new Date(); +secretExpired.setMonth(secretExpired.getMonth() - 1); + +const listKeyVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "enableRbacAuthorization": true, + + }, + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "enableRbacAuthorization": false, + } +]; + +const getSecrets = [ + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + },{ + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryPass, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryFail, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": false, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + } +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('keyVaultSecretExpiry', function() { + describe('run', function() { + it('should give passing result if no secrets found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if secret expiration is not enabled in RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret expiration is not enabled in RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[0]), {}, callback); + }); + + it('should give passing result if secret expiry is not yet reached in RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret in RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[1]), {}, callback); + }); + + it('should give failing result if secret has expired', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secret in RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[3]), {}, callback); + }); + + it('should give failing result if secret expires within failure expiry date', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secret in RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[2]), { key_vault_secret_expiry_fail: '40' }, callback); + }); + + it('should give passing result if key is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret in RBAC vault is not enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[4]), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js new file mode 100644 index 000000000..e2f8a15fd --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Secret Expiry Non RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Proactively check for non RBAC Key Vault secrets expiry date and rotate them before expiry date is reached.', + more_info: 'After the expiry date has reached for Key Vault secret, it cannot be used for storing sensitive and confidential data such as passwords and database connection strings anymore.', + recommended_action: 'Ensure that Key Vault secrets are rotated before they get expired.', + link: 'https://learn.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getSecrets'], + settings: { + non_rbac_key_vault_secret_expiry_fail: { + name: 'Key Vault Secret Expiry Fail', + description: 'Return a failing result when secret expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_secret_expiry_fail: parseInt(settings.non_rbac_key_vault_secret_expiry_fail || this.settings.non_rbac_key_vault_secret_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + // Check if vault is non-RBAC + if (vault.enableRbacAuthorization) { + return; + } + + var secrets = helpers.addSource(cache, source, + ['vaults', 'getSecrets', location, vault.id]); + + if (!secrets || secrets.err || !secrets.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault secrets: ' + helpers.addError(secrets), location, vault.id); + } else if (!secrets.data.length) { + helpers.addResult(results, 0, 'No Key Vault secrets found in non RBAC vault', location, vault.id); + } else { + secrets.data.forEach(function(secret) { + var secretName = secret.id.substring(secret.id.lastIndexOf('/') + 1); + var secretId = `${vault.id}/secrets/${secretName}`; + + if (!secret.attributes || !secret.attributes.enabled) { + helpers.addResult(results, 0, 'Secret is not enabled in non RBAC vault', location, secretId); + } else if (secret.attributes && (secret.attributes.exp || secret.attributes.expiry)) { + let attributes = secret.attributes; + let secretExpiry = attributes.exp ? attributes.exp * 1000 : attributes.expiry; + let difference = Math.round((new Date(secretExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_secret_expiry_fail) { + helpers.addResult(results, 0, + `Secret in non RBAC vault expires in ${difference} days`, location, secretId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Secret in non RBAC vault expires in ${difference} days`, location, secretId); + } else { + helpers.addResult(results, 2, + `Secret in non RBAC vault expired ${Math.abs(difference)} days ago`, location, secretId); + } + } else { + helpers.addResult(results, 0, + 'Secret expiration is not enabled in non RBAC vault', location, secretId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js new file mode 100644 index 000000000..2a64720d2 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js @@ -0,0 +1,221 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultSecretExpiryNonRbac'); + +var secretExpiryPass = new Date(); +secretExpiryPass.setMonth(secretExpiryPass.getMonth() + 2); + +var secretExpiryFail = new Date(); +secretExpiryFail.setDate(secretExpiryFail.getDate() + 25); // Set to 35 days in the future + +var secretExpired = new Date(); +secretExpired.setMonth(secretExpired.getMonth() - 1); + +const listKeyVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "properties": { + "enableRbacAuthorization": false // Non-RBAC vault + } + }, + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "properties": { + "enableRbacAuthorization": true // RBAC vault + } + } +]; + +const getSecrets = [ + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + },{ + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryPass, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryFail, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": false, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + } +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('keyVaultSecretExpiryNonRbac', function() { + describe('run', function() { + it('should give passing result if no secrets found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if secret expiration is not enabled in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret expiration is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[0]), {}, callback); + }); + + it('should give passing result if secret expiry is not yet reached in non-RBAC vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[1]), {}, callback); + }); + + it('should give failing result if secret has expired', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secret in non RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[3]), {}, callback); + }); + + it('should give failing result if secret expires within failure expiry date', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secret in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[2]), { key_vault_secret_expiry_fail: '40' }, callback); + }); + + it('should give passing result if key is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secret is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[4]), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.js b/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.js new file mode 100644 index 000000000..2cb1c507c --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vaults Private Endpoint', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Azure Key vaults have private endpoints configured.', + more_info: 'Configuring private link endpoints ensures secure network traffic from Azure Key Vault to the resources requesting secrets and keys. Using Private endpoints minimize the risk of public internet exposure and protect against external attacks.', + recommended_action: 'Ensure that private endpoints are configured properly and public network access is disabled for Key Vaults.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/private-link-service', + apis: ['vaults:list'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete','microsoftnetwork:privatednszones:virtualnetworklinks:write','microsoftkeyvault:vaults:privateendpointconnections:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + for (let vault of vaults.data) { + if (!vault.id) continue; + + if (vault.privateEndpointConnections && vault.privateEndpointConnections.length ) { + helpers.addResult(results, 0, 'Key Vault has private endpoints configured', location, vault.id); + } else { + helpers.addResult(results, 2, 'Key Vault does not have private endpoints configured', location, vault.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.spec.js b/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.spec.js new file mode 100644 index 000000000..c0eb17c1d --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var privateEndpoint = require('./keyVaultsPrivateEndpoint'); + +const listVaults = [ + { + id: '/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + }, + { + id: '/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + privateEndpointConnections: [ + { + id: '/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + properties: [Object] + } + ], + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '123d1b11-52f8-4dfc-bf08-1b66fa2de1d5', + }, + ], + } +]; + +const createCache = (err, list) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + } + } +}; + +describe('keyVaultsPrivateEndpoint', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + privateEndpoint.run(createCache(null, [], {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + privateEndpoint.run(createCache(null, null, {}), {}, callback); + }); + + it('should give passing result if private endpoints are configured for the Key Vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault has private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + privateEndpoint.run(createCache(null, [listVaults[1]]), {}, callback); + }); + + it('should give failing result if private endpoints are not configured for key vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault does not have private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + privateEndpoint.run(createCache(null, [listVaults[0]]), {}, callback); + }) + }) +}); diff --git a/plugins/azure/monitor/kvLogAnalyticsEnabled.js b/plugins/azure/keyvaults/kvLogAnalyticsEnabled.js similarity index 88% rename from plugins/azure/monitor/kvLogAnalyticsEnabled.js rename to plugins/azure/keyvaults/kvLogAnalyticsEnabled.js index 357bce7a1..7a4883107 100644 --- a/plugins/azure/monitor/kvLogAnalyticsEnabled.js +++ b/plugins/azure/keyvaults/kvLogAnalyticsEnabled.js @@ -3,17 +3,20 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Key Vault Log Analytics Enabled', - category: 'Monitor', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures Key Vault Log Analytics logs are being properly delivered to Azure Monitor', more_info: 'Enabling Send to Log Analytics ensures that all Key Vault logs are being properly monitored and managed.', recommended_action: 'Send all diagnostic logs for Key Vault from the Azure Monitor service to Log Analytics.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', apis: ['vaults:list', 'diagnosticSettings:listByKeyVault'], compliance: { hipaa: 'HIPAA requires that a secure audit record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete','microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/keyvaults/kvRecoveryEnabled.js b/plugins/azure/keyvaults/kvRecoveryEnabled.js index 164338cc1..9cba4dee0 100644 --- a/plugins/azure/keyvaults/kvRecoveryEnabled.js +++ b/plugins/azure/keyvaults/kvRecoveryEnabled.js @@ -4,15 +4,18 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Key Vault Recovery Enabled', category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures that Purge Protection and Soft Delete are enabled on all Key Vaults', more_info: 'Purge Protection and Soft Delete are features that safeguard losing key access. With these setting enabled, key vaults have recovery actions available to restore deleted or compromised key vaults.', recommended_action: 'Once Key Vaults are created, the Azure CLI must be used to update the vault Soft Delete and Purge Protection settings.', - link: 'https://docs.microsoft.com/en-us/azure/key-vault/key-vault-ovw-soft-delete', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/key-vault-ovw-soft-delete', apis: ['vaults:list'], compliance: { hipaa: 'HIPAA requires that all encryption mechanisms be protected against ' + 'modifications or loss.' }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], run: function(cache, settings, callback) { const results = []; @@ -36,27 +39,13 @@ module.exports = { } vaults.data.forEach(function(vault) { - let vaultProperties = vault.properties; - let enablePurgeProtection = false; - let enableSoftDelete = false; - - if (vaultProperties && - vaultProperties.enablePurgeProtection) { - enablePurgeProtection = true; - } - - if (vaultProperties && - vaultProperties.enableSoftDelete) { - enableSoftDelete = true; - } - - if (enablePurgeProtection && enableSoftDelete) { + if (vault.enablePurgeProtection && vault.enableSoftDelete) { helpers.addResult(results, 0, 'Purge protection and soft delete are enabled for the Key Vault', location, vault.id); } else { let msg = [ - `Purge protection is ${enablePurgeProtection ? '' : 'not'} enabled.`, - `Soft delete is ${enableSoftDelete ? '' : 'not'} enabled.` + `Purge protection is ${vault.enablePurgeProtection ? '' : 'not'} enabled.`, + `Soft delete is ${vault.enableSoftDelete ? '' : 'not'} enabled.` ]; helpers.addResult(results, 2, msg.join(' '), location, vault.id); } diff --git a/plugins/azure/keyvaults/kvRecoveryEnabled.spec.js b/plugins/azure/keyvaults/kvRecoveryEnabled.spec.js new file mode 100644 index 000000000..2a16d4b53 --- /dev/null +++ b/plugins/azure/keyvaults/kvRecoveryEnabled.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var auth = require('./kvRecoveryEnabled'); + +const listVaults = [ + { + id: '/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tags: { owner: 'kubernetes' }, + sku: { family: 'A', name: 'Standard' }, + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '123d1b11-52f8-4dfc-bf08-1b66fa2de1d5', + permissions: [Object] + }, + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: 'b4062000-c33b-448b-817e-fa0f17bef4b9', + permissions: [Object] + }, + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '0ef24dfb-2712-44f2-98d2-2df7f946b338', + permissions: [Object] + } + ], + enabledForDeployment: true, + enabledForDiskEncryption: true, + enabledForTemplateDeployment: true, + enableSoftDelete: true, + enablePurgeProtection: true, + softDeleteRetentionInDays: 7, + enableRbacAuthorization: false, + vaultUri: 'https://test.vault.azure.net/', + provisioningState: 'Succeeded' + }, + { + id: '/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tags: { owner: 'kubernetes' }, + sku: { family: 'A', name: 'Standard' }, + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '123d1b11-52f8-4dfc-bf08-1b66fa2de1d5', + }, + ], + enabledForDeployment: true, + enabledForDiskEncryption: true, + enabledForTemplateDeployment: true, + enableSoftDelete: false, + softDeleteRetentionInDays: 7, + enableRbacAuthorization: false, + vaultUri: 'https://test.vault.azure.net/', + provisioningState: 'Succeeded' + } +]; + +const createCache = (err, list) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + } + } +}; + +describe('kvRecoveryEnabled', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}), {}, callback); + }); + + it('should give passing result if Purge protection and soft delete are enabled for the Key Vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Purge protection and soft delete are enabled for the Key Vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]]), {}, callback); + }); + + it('should give failing result if Purge protection or soft delete is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Purge protection'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]]), {}, callback); + }) + }) +}); diff --git a/plugins/azure/keyvaults/manageKeyAccessAndPermissions.js b/plugins/azure/keyvaults/manageKeyAccessAndPermissions.js new file mode 100644 index 000000000..3ff68e90f --- /dev/null +++ b/plugins/azure/keyvaults/manageKeyAccessAndPermissions.js @@ -0,0 +1,122 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); +var _ = require('underscore'); + +module.exports = { + title: 'Manage Key Access and Permissions', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures that no Microsoft Azure user, group or application has administrator privileges to the Key Vaults.', + more_info: 'A principal such as a user, group or application should have access to execute only specific operations for Azure Key Vault keys, secrets or certificates as a security best practice.', + recommended_action: 'Ensure that no Microsoft Azure user, group or application is using administrator privileges.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide?tabs=azure-cli', + apis: ['vaults:list'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + const fullPermissions = { + 'certificates': [ + 'Get', + 'List', + 'Update', + 'Create', + 'Import', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'ManageContacts', + 'ManageIssuers', + 'GetIssuers', + 'ListIssuers', + 'SetIssuers', + 'DeleteIssuers', + 'Purge' + ], + 'keys': [ + 'Get', + 'List', + 'Update', + 'Create', + 'Import', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'Decrypt', + 'Encrypt', + 'UnwrapKey', + 'WrapKey', + 'Verify', + 'Sign', + 'Purge' + ], + 'secrets': [ + 'Get', + 'List', + 'Set', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'Purge' + ], + 'storage': [ + 'get', + 'list', + 'delete', + 'set', + 'update', + 'regeneratekey', + 'setsas', + 'listsas', + 'getsas', + 'deletesas' + ] + }; + + vaults.data.forEach((vault) => { + let policyFound = false; + + vault.accessPolicies.forEach((policy) => { + if (_.isEqual(fullPermissions, policy.permissions)) { + policyFound = true; + } + }); + + if (policyFound) { + helpers.addResult(results, 2, + 'User/Group or Application has full access to the vault', location, vault.id); + } else { + helpers.addResult(results, 0, + 'No User/Group or Application has full access to the vault', location, vault.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/manageKeyAccessAndPermissions.spec.js b/plugins/azure/keyvaults/manageKeyAccessAndPermissions.spec.js new file mode 100644 index 000000000..7bcd858e5 --- /dev/null +++ b/plugins/azure/keyvaults/manageKeyAccessAndPermissions.spec.js @@ -0,0 +1,190 @@ +var expect = require('chai').expect; +var auth = require('./manageKeyAccessAndPermissions'); + +const listVaults = [ + { + id: '/subscriptions/dcsqwwww-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tags: { owner: 'kubernetes' }, + sku: { family: 'A', name: 'Standard' }, + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '123d1b11-52f8-4dfc-bf08-1b66fa2de1d5', + permissions: [Object] + }, + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: 'b4062000-c33b-448b-817e-fa0f17bef4b9', + permissions: [Object] + }, + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '0ef24dfb-2712-44f2-98d2-2df7f946b338', + permissions: [Object] + } + ], + enabledForDeployment: true, + enabledForDiskEncryption: true, + enabledForTemplateDeployment: true, + enableSoftDelete: true, + softDeleteRetentionInDays: 7, + enableRbacAuthorization: false, + vaultUri: 'https://test.vault.azure.net/', + provisioningState: 'Succeeded' + }, + { + id: '/subscriptions/dcsqwwww-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/test', + name: 'test', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + tags: { owner: 'kubernetes' }, + sku: { family: 'A', name: 'Standard' }, + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + accessPolicies: [ + { + tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', + objectId: '123d1b11-52f8-4dfc-bf08-1b66fa2de1d5', + permissions: { + 'certificates': [ + 'Get', + 'List', + 'Update', + 'Create', + 'Import', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'ManageContacts', + 'ManageIssuers', + 'GetIssuers', + 'ListIssuers', + 'SetIssuers', + 'DeleteIssuers', + 'Purge' + ], + 'keys': [ + 'Get', + 'List', + 'Update', + 'Create', + 'Import', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'Decrypt', + 'Encrypt', + 'UnwrapKey', + 'WrapKey', + 'Verify', + 'Sign', + 'Purge' + ], + 'secrets': [ + 'Get', + 'List', + 'Set', + 'Delete', + 'Recover', + 'Backup', + 'Restore', + 'Purge' + ], + 'storage': [ + 'get', + 'list', + 'delete', + 'set', + 'update', + 'regeneratekey', + 'setsas', + 'listsas', + 'getsas', + 'deletesas' + ] + } + }, + ], + enabledForDeployment: true, + enabledForDiskEncryption: true, + enabledForTemplateDeployment: true, + enableSoftDelete: true, + softDeleteRetentionInDays: 7, + enableRbacAuthorization: false, + vaultUri: 'https://test.vault.azure.net/', + provisioningState: 'Succeeded' + } +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('manageKeyAccessAndPermissions', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), {}, callback); + }); + + it('should give passing result if No User/Group or Application has full access to the vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No User/Group or Application has full access to the vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]]), {}, callback); + }); + + it('should give failing result if a User/Group or Application has full access to the vault', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('User/Group or Application has full access to the vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]]), {}, callback); + }) + }) +}); diff --git a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js new file mode 100644 index 000000000..e9362dbcd --- /dev/null +++ b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Restrict Default Network Access', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Microsoft Azure Key Vaults are configured to deny access to traffic from all networks.', + more_info: 'Access to Azure Key Vaults should be granted to specific Virtual Networks, which allow a secure network boundary for specific applications, or to public IP addresses/IP address ranges, which can enable connections from trusted Internet services and on-premises networks.', + recommended_action: 'Ensure that Microsoft Azure Key Vaults can only be accessed by specific Virtual Networks.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/overview-vnet-service-endpoints', + apis: ['vaults:list'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach((vault) => { + if (vault.networkAcls && + vault.networkAcls.defaultAction && + vault.networkAcls.defaultAction === 'Deny') { + helpers.addResult(results, 0, + 'Key Vault does not allow access to all networks', location, vault.id); + } else { + helpers.addResult(results, 2, + 'Key Vault allows access to all networks', location, vault.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js new file mode 100644 index 000000000..4b3d624b7 --- /dev/null +++ b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js @@ -0,0 +1,334 @@ +var expect = require('chai').expect; +var auth = require('./restrictDefaultNetworkAccess'); + +const listVaults = [ + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "defaultAction": "Deny", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + "accessPolicies": [ + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://xzbb.vault.azure.net/", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + "accessPolicies": [ + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://xzbb.vault.azure.net/", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/noNetworkAcls", + "name": "noNetworkAcls", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "privateEndpointConnections": [], + "accessPolicies": [], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://nonetworkacls.vault.azure.net/", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/emptyDefaultAction", + "name": "emptyDefaultAction", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "ipRules": [], + "virtualNetworkRules": [] + }, + "privateEndpointConnections": [], + "accessPolicies": [], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://emptydefaultaction.vault.azure.net/", + "provisioningState": "Succeeded" + } +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('restrictDefaultNetworkAccess', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), {}, callback); + }); + + it('should give passing result if Key Vault does not allow access to all networks', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault does not allow access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]]), {}, callback); + }); + + it('should give failing result if Key Vault allows access to all networks', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault allows access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]]), {}, callback); + }); + + it('should give failing result if Key Vault has no networkAcls configured', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault allows access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[2]]), {}, callback); + }); + + it('should give failing result if Key Vault has networkAcls but no defaultAction', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault allows access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[3]]), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/rsaCertificateKeySize.js b/plugins/azure/keyvaults/rsaCertificateKeySize.js new file mode 100644 index 000000000..12c795233 --- /dev/null +++ b/plugins/azure/keyvaults/rsaCertificateKeySize.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'RSA Certificate Allowed Key Size', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Key Vault RSA certificates are using the allowed minimum key size.', + more_info: 'Having the right key size for your Azure Key Vault RSA certificates will enforce the best practices as specified in the security and compliance regulations implemented within your organization.', + recommended_action: 'Ensure that Key Vault RSA certificates are using the allowed minimum key size.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/certificates/about-certificates', + apis: ['vaults:list', 'vaults:getCertificates', 'getCertificatePolicy:get'], + settings: { + min_rsa_certificate_key_size: { + name: 'Minimum RSA Certificate Allowed Key Size', + description: 'Key sizes supported for rsa certificates in Azure Key Vault', + regex: '^[1-9]{1}[0-9]{0,5}$', + default: '2048' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + min_rsa_certificate_key_size: parseInt(settings.min_rsa_certificate_key_size || this.settings.min_rsa_certificate_key_size.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach((vault) => { + var certificates = helpers.addSource(cache, source, + ['vaults', 'getCertificates', location, vault.id]); + + if (!certificates || certificates.err || !certificates.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault certificates: ' + helpers.addError(certificates), location, vault.id); + } else if (!certificates.data.length) { + helpers.addResult(results, 0, 'No Key Vault Certificates found', location, vault.id); + } else { + certificates.data.forEach((certificate) => { + var certificatePolicy = helpers.addSource(cache, source, + ['getCertificatePolicy', 'get', location, certificate.id]); + + if (!certificatePolicy || certificatePolicy.err || !certificatePolicy.data) { + helpers.addResult(results, 3, 'Unable to query for Certificate Policy: ' + helpers.addError(certificatePolicy), location, certificate.id); + } else { + if (certificatePolicy.data.attributes && certificatePolicy.data.attributes.enabled) { + const certificateKeys = certificatePolicy.data.key_props; + + if (certificateKeys && certificateKeys.kty) { + if (certificateKeys.key_size >= config.min_rsa_certificate_key_size) { + helpers.addResult(results, 0, `RSA Certificate key size is ${certificateKeys.key_size}`, location, certificate.id); + } else { + helpers.addResult(results, 2, `RSA Certificate key size is ${certificateKeys.key_size}`, location, certificate.id); + } + } else { + helpers.addResult(results, 3, 'Unable to list key type for Key Vault Certificate: ' + helpers.addError(certificatePolicy), location, certificate.id); + } + } else { + helpers.addResult(results, 0, 'RSA Certificate is not enabled', location, certificate.id); + } + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/rsaCertificateKeySize.spec.js b/plugins/azure/keyvaults/rsaCertificateKeySize.spec.js new file mode 100644 index 000000000..2ce275f0c --- /dev/null +++ b/plugins/azure/keyvaults/rsaCertificateKeySize.spec.js @@ -0,0 +1,197 @@ +var expect = require('chai').expect; +var auth = require('./rsaCertificateKeySize'); + +const listVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const certificates = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert', + x5t: 'wi16heNOLNuStvVprwu6rzs0_is', + attributes: [Object], + tags: {}, + subject: '' + } +]; + +const certificatePolicy = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + key_props: { exportable: true, kty: 'RSA', key_size: 2048, reuse_key: false }, + secret_props: { contentType: 'application/x-pkcs12' }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + }, + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + }, + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + key_props: { exportable: true, kty: 'ECC', key_size: 1098, reuse_key: false }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ [Object] ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + } +]; + +const createCache = (err, list, certs, getCertificatePolicy) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getCertificates: { + 'eastus': certs + } + }, + getCertificatePolicy: { + get: { + 'eastus': getCertificatePolicy + } + } + } +}; + +describe('rsaCertificateKeySize', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), {}, callback); + }); + + it('should give passing result if no key vault certificates found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vault Certificates found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [] } }, {}), {}, callback); + }); + + it('should give unkown result if unable to query for Key Vault Certificates', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault certificates'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null, {}), {}, callback); + }); + + it('should give unknown results if unable to query for certificate policy', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Certificate Policy'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, {}), {}, callback); + }); + + it('should give unknown results if Unable to list key type for Key Vault Certificate', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list key type for Key Vault Certificate'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: certificatePolicy[1] } ), {}, callback); + }); + + it('should give passing result if certificate has allowed key type', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RSA Certificate key size is 2048'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[0] } } ), {}, callback); + }); + + it('should give failing result if certificate does not have allowed key type', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RSA Certificate key size is 1098'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[2] } } ), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/secretExpirationEnabled.js b/plugins/azure/keyvaults/secretExpirationEnabled.js index 69ee8a4e7..e94abbd6c 100644 --- a/plugins/azure/keyvaults/secretExpirationEnabled.js +++ b/plugins/azure/keyvaults/secretExpirationEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Secret Expiration Enabled', category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures that all secrets in Azure Key Vault have an expiry time set.', more_info: 'Setting an expiry time on all secrets forces secret rotation and removes unused and forgotten secrets from being used.', recommended_action: 'Ensure each Key Vault has an expiry time set that provides for sufficient rotation.', - link: 'https://docs.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', + link: 'https://learn.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', apis: ['vaults:list', 'vaults:getSecrets'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/keyvaults/sslCertificateAutoRenewal.js b/plugins/azure/keyvaults/sslCertificateAutoRenewal.js new file mode 100644 index 000000000..10dc966bf --- /dev/null +++ b/plugins/azure/keyvaults/sslCertificateAutoRenewal.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SSL Certificate Auto Renewal', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Key Vault SSL certificates have auto renewal enabled.', + more_info: 'Configure auto renewal for SSL certificates in order to prevent any application or service outage, credential leak, or process violation that can disrupt your business.', + recommended_action: 'Ensure that Key Vault SSL certificates are configured to have auto renewal enabled.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/certificates/overview-renew-certificate', + apis: ['vaults:list', 'vaults:getCertificates', 'getCertificatePolicy:get'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach((vault) => { + var certificates = helpers.addSource(cache, source, + ['vaults', 'getCertificates', location, vault.id]); + + if (!certificates || certificates.err || !certificates.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault certificates: ' + helpers.addError(certificates), location, vault.id); + } else if (!certificates.data.length) { + helpers.addResult(results, 0, 'No Key Vault Certificates found', location, vault.id); + } else { + certificates.data.forEach((certificate) => { + var certificatePolicy = helpers.addSource(cache, source, + ['getCertificatePolicy', 'get', location, certificate.id]); + + if (!certificatePolicy || certificatePolicy.err || !certificatePolicy.data) { + helpers.addResult(results, 3, 'Unable to query for Certificate Policy: ' + helpers.addError(certificatePolicy), location, certificate.id); + } else { + if (certificatePolicy.data.attributes && certificatePolicy.data.attributes.enabled) { + const lifetimeActions = certificatePolicy.data.lifetime_actions; + + let autoRenew = lifetimeActions && lifetimeActions.find(lifetimeAction => + lifetimeAction.action && lifetimeAction.action.action_type && + lifetimeAction.action.action_type.toLowerCase() == 'autorenew'); + + if (autoRenew) { + helpers.addResult(results, 0, 'SSL Certificate auto renewal is enabled', location, certificate.id); + } else { + helpers.addResult(results, 2, 'SSL Certificate auto renewal is not enabled', location, certificate.id); + } + } else { + helpers.addResult(results, 0, 'SSL Certificate is not enabled', location, certificate.id); + } + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/sslCertificateAutoRenewal.spec.js b/plugins/azure/keyvaults/sslCertificateAutoRenewal.spec.js new file mode 100644 index 000000000..c2f711636 --- /dev/null +++ b/plugins/azure/keyvaults/sslCertificateAutoRenewal.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var auth = require('./sslCertificateAutoRenewal'); + +const listVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + } + } +]; + +const certificates = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert', + x5t: 'wi16heNOLNuStvVprwu6rzs0_is', + attributes: [Object], + tags: {}, + subject: '' + } +]; + +const certificatePolicy = [ + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ { + trigger: { lifetime_percentage: 80 }, + action: { action_type: 'AutoRenew' } + } ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + }, + { + id: 'https://testvault.vault.azure.net/certificates/test-cert/policy', + secret_props: { contentType: 'application/x-pkcs12' }, + key_props: { exportable: true, kty: 'ECC', key_size: 1098, reuse_key: false }, + x509_props: { + subject: 'CN=test.com', + sans: [Object], + ekus: [Array], + key_usage: [Array], + validity_months: 12, + basic_constraints: [Object] + }, + lifetime_actions: [ { + trigger: { lifetime_percentage: 80 }, + action: { action_type: 'EmailContacts' } + } ], + issuer: { name: 'Self' }, + attributes: { enabled: true, created: 1649758944, updated: 1649758944 } + } +]; + +const createCache = (err, list, certs, getCertificatePolicy) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getCertificates: { + 'eastus': certs + } + }, + getCertificatePolicy: { + get: { + 'eastus': getCertificatePolicy + } + } + } +}; + +describe('sslCertificateAutoRenewal', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), {}, callback); + }); + + it('should give passing result if no key vault certificates found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vault Certificates found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [] } }, {}), {}, callback); + }); + + it('should give unkown result if unable to query for Key Vault Certificates', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vault certificates'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], null, {}), {}, callback); + }); + + it('should give unknown results if unable to query for certificate policy', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Certificate Policy'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, {}), {}, callback); + }); + + it('should give passing result if SSL Certificate auto renewal is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SSL Certificate auto renewal is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[0] } } ), {}, callback); + }); + + it('should give failing result if SSL Certificate auto renewal is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SSL Certificate auto renewal is not enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]], { "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault": { data: [certificates] } }, { data: { ...certificatePolicy[1] } } ), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/trustedServicesEnabled.js b/plugins/azure/keyvaults/trustedServicesEnabled.js new file mode 100644 index 000000000..1ebeaf9b2 --- /dev/null +++ b/plugins/azure/keyvaults/trustedServicesEnabled.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'KeyVault Trusted Services Enabled', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that "Allow trusted Microsoft services to bypass this firewall" feature is enabled for Azure Key Vault network firewall configuration.', + more_info: 'Enabling network firewall rules for your Key Vaults will block access to incoming requests for data, including from other Azure services. ' + + 'To allow certain Azure cloud services access your vault resources, you need to add an exception so that the trusted cloud services can bypass the firewall rules.', + recommended_action: 'Ensure that Microsoft Azure Key Vault network firewall configuration allows trusted Microsoft services to bypass the firewall.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/network-security', + apis: ['vaults:list'], + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach((vault) => { + if (vault.networkAcls){ + if (vault.networkAcls && vault.networkAcls.bypass && vault.networkAcls.bypass === 'None') { + helpers.addResult(results, 2, + 'Trusted Microsoft services are not allowed to access the key vault resources', location, vault.id); + } else { + helpers.addResult(results, 0, + 'Trusted Microsoft services are allowed to access the key vault resources', location, vault.id); + } + } else { + helpers.addResult(results, 0, + 'Network Acls are not configured for key vault', location, vault.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/trustedServicesEnabled.spec.js b/plugins/azure/keyvaults/trustedServicesEnabled.spec.js new file mode 100644 index 000000000..ecf65996e --- /dev/null +++ b/plugins/azure/keyvaults/trustedServicesEnabled.spec.js @@ -0,0 +1,261 @@ +var expect = require('chai').expect; +var auth = require('./trustedServicesEnabled'); + +const listVaults = [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "defaultAction": "Deny", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + "accessPolicies": [ + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://xzbb.vault.azure.net/", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb", + "name": "xZbb", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "AzureServices", + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + "accessPolicies": [ + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://xzbb.vault.azure.net/", + "provisioningState": "Succeeded" + }, +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('trustedServicesEnabled', function() { + describe('run', function() { + it('should give passing result if no key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}, {}), {}, callback); + }); + + it('should give unkown result if Unable to query for Key Vaults', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, null, {}, {}), {}, callback); + }); + + it('should give passing result if trusted Microsoft Azure cloud services are allowed to access the key vault resources', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Trusted Microsoft services are allowed to access the key vault resources'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[1]]), {}, callback); + }); + + it('should give failing result if no trusted Microsoft Azure cloud services are allowed to access the key vault resources', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Trusted Microsoft services are not allowed to access the key vault resources'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[0]]), {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksAgentVersion.js b/plugins/azure/kubernetesservice/aksAgentVersion.js new file mode 100644 index 000000000..516eb0635 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksAgentVersion.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); +//var compareVersions = require('compare-versions'); + +module.exports = { + title: 'Kubernetes Version For Agent Pools', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures the kubernetes version is same across the node pools with the cluster.', + more_info: 'AKS supports provisioning clusters from several versions of Kubernetes. Node pools should be at per with the cluster kubernetes version.', + recommended_action: 'Upgrade the version of Kubernetes on all AKS clusters node pool to the same version as the cluster.', + link: 'https://learn.microsoft.com/en-us/azure/aks/aad-integration', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + managedClusters.data.forEach(managedCluster => { + var kubernetesVersion = managedCluster.kubernetesVersion; + var agentPoolProfiles = managedCluster.agentPoolProfiles; + + if (!agentPoolProfiles || !agentPoolProfiles.length) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes cluster node profile' , location, managedCluster.id); + } else { + agentPoolProfiles.forEach(agentPoolProfile =>{ + if (agentPoolProfile.orchestratorVersion && + helpers.compareVersions(agentPoolProfile.orchestratorVersion,kubernetesVersion) === -1 ) { + helpers.addResult(results, 2, + `The node pool ${agentPoolProfile.name} does not have the cluster Kubernetes version: ${kubernetesVersion}`, location, managedCluster.id); + } else { + helpers.addResult(results, 0, + `The node pool ${agentPoolProfile.name} has the cluster Kubernetes version`, location, managedCluster.id); + } + }); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksAgentVersion.spec.js b/plugins/azure/kubernetesservice/aksAgentVersion.spec.js new file mode 100644 index 000000000..e87d4e698 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksAgentVersion.spec.js @@ -0,0 +1,168 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./aksAgentVersion'); + +const createCache = (err, list) => { + return { + managedClusters: { + list: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('aksAgentVersion', function() { + describe('run', function() { + it('should give passing result if no managed clusters', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }); + + it('should give failing result if the Kubernetes clusters does not have the latest version for one pool', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('does not have the cluster Kubernetes version'); + expect(results[1].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "provisioningState": "Succeeded", + "kubernetesVersion": "1.17.9", + "dnsPrefix": "absbaks2-dns", + "fqdn": "absbaks2-dns-9b85fd77.hcp.eastus.azmk8s.io", + "agentPoolProfiles": [ + { + "name": "agentpool", + "count": 2, + "vmSize": "Standard_DS2_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "nodeLabels": {}, + "mode": "System", + "osType": "Linux" + }, + { + "name": "test", + "count": 1, + "vmSize": "Standard_DS1_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.16.10", + "mode": "User", + "osType": "Linux" + } + ], + "nodeResourceGroup": "MC_ABSBAKS2_absbaks2_eastus", + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile": { + "enablePrivateCluster": false + } + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should give passing result if the Kubernetes clusters have the latest version for all the agent pool', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + results.forEach(result =>{ + expect(result.status).to.equal(0); + expect(result.message).to.include('has the cluster Kubernetes version'); + expect(result.region).to.equal('eastus');}); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "provisioningState": "Succeeded", + "kubernetesVersion": "1.17.9", + "dnsPrefix": "absbaks2-dns", + "fqdn": "absbaks2-dns-9b85fd77.hcp.eastus.azmk8s.io", + "agentPoolProfiles": [ + { + "name": "agentpool", + "count": 2, + "vmSize": "Standard_DS2_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "nodeLabels": {}, + "mode": "System", + "osType": "Linux" + }, + { + "name": "test", + "count": 1, + "vmSize": "Standard_DS1_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "mode": "User", + "osType": "Linux" + } + ], + "nodeResourceGroup": "MC_ABSBAKS2_absbaks2_eastus", + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile": { + "enablePrivateCluster": false + } + } + ] + ); + + auth.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.js b/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.js new file mode 100644 index 000000000..fc4bb9461 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'AKS API Server Authorized IP Ranges', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Low', + description: 'Ensures that Azure Kubernetes clusters have authorized IP ranges configured.', + more_info: 'Specifying IP ranges improves the security of your clusters and minimizes the risk of attacks by limiting the IP address ranges that can access the API server. This helps ensure that only authorized users and systems can interact with your cluster, enhancing overall security posture.', + recommended_action: 'Modify AKS clusters and configure authorized IP ranges.', + link: 'https://learn.microsoft.com/en-us/azure/aks/api-server-authorized-ip-ranges', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + if (cluster.apiServerAccessProfile && cluster.apiServerAccessProfile.authorizedIPRanges && cluster.apiServerAccessProfile.authorizedIPRanges.length){ + helpers.addResult(results, 0, 'AKS cluster has authorized IP ranges configured for secure access to API server', location, cluster.id); + } else { + helpers.addResult(results, 2, 'AKS cluster does not have authorized IP ranges configured for secure access to API server', location, cluster.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.spec.js b/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.spec.js new file mode 100644 index 000000000..87c2cd4ec --- /dev/null +++ b/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var aksApiAuthorizedIpRanges = require('./aksApiAuthorizedIpRanges.js'); +const listCluster = [ + { + "id": "/subscriptions/1234/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': { 'key': 'value' }, + "name": "absbaks2", + "apiServerAccessProfile": { + "authorizedIPRanges": [ + "10.0.0.0/24" + ] + }, + }, + { + "id": "/subscriptions/1234/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': {}, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "apiServerAccessProfile": { + "enablePrivateCluster": false + } + } +] + +const createCache = (err, list) => { + return { + managedClusters: { + list: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('aksApiAuthorizedIpRanges', function() { + describe('run', function() { + it('should give passing result if no managed clusters', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[]); + aksApiAuthorizedIpRanges.run(cache, {}, callback); + }); + + it('should give Unknown result if unable to query AKS', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,null); + aksApiAuthorizedIpRanges.run(cache, {}, callback); + }) + + it('should give failing result if Kubernetes Service does not have authorized IP ranges configured for secure access to API server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster does not have authorized IP ranges configured for secure access to API server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[listCluster[1]]); + aksApiAuthorizedIpRanges.run(cache, {}, callback); + }); + + it('should give passing result if Kubernetes Service has authorized IP ranges configured for secure access to API server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster has authorized IP ranges configured for secure access to API server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,[listCluster[0]]); + aksApiAuthorizedIpRanges.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksClusterHasTags.js b/plugins/azure/kubernetesservice/aksClusterHasTags.js new file mode 100644 index 000000000..859664ac2 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksClusterHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'AKS Cluster Has Tags', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Low', + description: 'Ensures that Azure Kubernetes clusters have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify AKS clusters and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/aks/use-tags', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + if (cluster.tags && Object.entries(cluster.tags).length > 0){ + helpers.addResult(results, 0, 'AKS cluster has tags', location, cluster.id); + } else { + helpers.addResult(results, 2, 'AKS cluster does not have tags', location, cluster.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksClusterHasTags.spec.js b/plugins/azure/kubernetesservice/aksClusterHasTags.spec.js new file mode 100644 index 000000000..abfbaf2f3 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksClusterHasTags.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var aksHasTags = require('./aksClusterHasTags'); +const listCluster = [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': { 'key': 'value' }, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "provisioningState": "Succeeded", + "kubernetesVersion": "1.17.9", + "dnsPrefix": "absbaks2-dns", + "fqdn": "absbaks2-dns-9b85fd77.hcp.eastus.azmk8s.io", + "agentPoolProfiles": [ + { + "name": "agentpool", + "count": 2, + "vmSize": "Standard_DS2_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "nodeLabels": {}, + "mode": "System", + "osType": "Linux" + }, + { + "name": "test", + "count": 1, + "vmSize": "Standard_DS1_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.16.10", + "mode": "User", + "osType": "Linux" + } + ], + "nodeResourceGroup": "MC_ABSBAKS2_absbaks2_eastus", + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile": { + "enablePrivateCluster": false + } + }, + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': {}, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "provisioningState": "Succeeded", + "kubernetesVersion": "1.17.9", + "dnsPrefix": "absbaks2-dns", + "fqdn": "absbaks2-dns-9b85fd77.hcp.eastus.azmk8s.io", + "agentPoolProfiles": [ + { + "name": "agentpool", + "count": 2, + "vmSize": "Standard_DS2_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "nodeLabels": {}, + "mode": "System", + "osType": "Linux" + }, + { + "name": "test", + "count": 1, + "vmSize": "Standard_DS1_v2", + "osDiskSizeGB": 128, + "maxPods": 110, + "type": "VirtualMachineScaleSets", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "mode": "User", + "osType": "Linux" + } + ], + "nodeResourceGroup": "MC_ABSBAKS2_absbaks2_eastus", + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile": { + "enablePrivateCluster": false + } + } +] + +const createCache = (err, list) => { + return { + managedClusters: { + list: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('aksHasTags', function() { + describe('run', function() { + it('should give passing result if no managed clusters', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[]); + aksHasTags.run(cache, {}, callback); + }); + + it('should give Unknown result if unable to query AKS', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,null); + aksHasTags.run(cache, {}, callback); + }) + + it('should give failing result if Kubernetes Service does not have tags', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster does not have tags'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[listCluster[1]]); + aksHasTags.run(cache, {}, callback); + }); + + it('should give passing result if Kubernetes Service have tags', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,[listCluster[0]]); + aksHasTags.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.js b/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.js new file mode 100644 index 000000000..92543a0bd --- /dev/null +++ b/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'AKS Cluster Diagnostic Logs', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures that Azure Kubernetes clusters have diagnostic logs enabled.', + more_info: 'Enabling diagnostic logging for AKS clusters helps with performance monitoring, troubleshooting, and security optimization.', + recommended_action: 'Enable diagnostic logging for all AKS clusters.', + link: 'https://learn.microsoft.com/en-us/azure/aks/monitor-aks#logs', + apis: ['managedClusters:list','diagnosticSettings:listByAksClusters'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByAksClusters', location, cluster.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Kubernetes cluster diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, cluster.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'AKS cluster has diagnostic logs enabled', location, cluster.id); + } else { + helpers.addResult(results, 2, 'AKS cluster does not have diagnostic logs enabled', location, cluster.id); + } + + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.spec.js b/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.spec.js new file mode 100644 index 000000000..938638a58 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var aksDiagnosticLogsEnabled = require('./aksDiagnosticLogsEnabled'); + +const clusters = [ + { + "id": "/subscriptions/123-test/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + }, +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ContainerService/managedClusters/absbaks2/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'server-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (clusters, ds) => { + const id = clusters && clusters.length ? clusters[0].id : null; + return { + managedClusters: { + list: { + 'eastus': { + data: clusters + } + } + }, + diagnosticSettings: { + listByAksClusters: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +const createErrorCache = () => { + return { + managedClusters: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('aksDiagnosticLogsEnabled', function() { + describe('run', function() { + it('should give passing result if no clusters', function(done) { + const cache = createCache([]); + aksDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for kubernetes clusters', function(done) { + const cache = createErrorCache(); + aksDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([clusters[0]], null); + aksDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes cluster diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([clusters[0]], [diagnosticSettings[0]]); + aksDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([clusters[0]], [[]]); + aksDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.js b/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.js new file mode 100644 index 000000000..4b695f4ec --- /dev/null +++ b/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'AKS Encryption At Rest with BYOK', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'High', + description: 'Ensure that Azure Kubernetes cluster data is encrypted with CMK.', + more_info: 'AKS Cluster allows you to encrypt your data using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Your keys encrypt the backup data must be stored in Azure Key Vault.This provides you with full control over the data and the keys.', + recommended_action: 'When creating a new Kubernetes Cluster, ensure that encryption at rest using CMK is enabled under the Node pool tab during creation.', + link: 'https://learn.microsoft.com/en-us/azure/aks/azure-disk-customer-managed-keys', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters', location); + return rcb(); + } + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + if (!cluster.diskEncryptionSetID) { + helpers.addResult(results, 2, 'AKS cluster data is not encrypted using CMK', location, cluster.id); + } else { + helpers.addResult(results, 0, 'AKS cluster data is encrypted using CMK', location, cluster.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.spec.js b/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.spec.js new file mode 100644 index 000000000..dc1f6c220 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksEncryptionAtRestWithCMK.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var aksEncryptionAtRestWithCMK = require('./aksEncryptionAtRestWithCMK'); + +const managedClusters = [ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile" : { "enablePrivateCluster": false }, + }, + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": false, + "maxAgentPools": 10, + "diskEncryptionSetID" : '/path/to/resource' + } +]; + +const createCache = (managedClusters) => { + return { + managedClusters: { + list: { + 'eastus': { + data: managedClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + managedClusters: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('aksEncryptionAtRestWithCMK', function() { + describe('run', function() { + it('should give passing result if no clusters', function(done) { + const cache = createCache([]); + aksEncryptionAtRestWithCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if kubernetes cluster data is not encrypted using CMK', function(done) { + const cache = createCache([managedClusters[0]]); + aksEncryptionAtRestWithCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster data is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for kubernetes clusters', function(done) { + const cache = createErrorCache(); + aksEncryptionAtRestWithCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if kubernetes cluster data is encrypted using CMK', function(done) { + const cache = createCache([managedClusters[1]]); + aksEncryptionAtRestWithCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster data is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksHostBasedEncryption.js b/plugins/azure/kubernetesservice/aksHostBasedEncryption.js new file mode 100644 index 000000000..2f8084d20 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksHostBasedEncryption.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'AKS Cluster Host Based Encryption', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Low', + description: 'Ensures that host-based encryption is enabled for all node pools in AKS Cluster.', + more_info: 'Enabling host-based encryption ensures that data stored on the VM host of your AKS agent node VMs is encrypted at rest and flows encrypted to the Storage service. This capability provides an additional measure of security as the data is encrypted end-to-end.', + recommended_action: 'Enable host-based encryption for all node pools in your AKS clusters.', + link: 'https://learn.microsoft.com/en-us/azure/aks/enable-host-encryption', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write','microsoftcontainerservice:managedclusters:delete','microsoftcontainerservice:managedclusters:agentpools:write','microsoftcontainerservice:managedclusters:agentpools:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + managedClusters.data.forEach(managedCluster => { + var agentPoolProfiles = managedCluster.agentPoolProfiles; + + if (!agentPoolProfiles || !agentPoolProfiles.length) { + helpers.addResult(results, 0, + 'No existing node pools found', location, managedCluster.id); + } else { + var unencryptedAtHost = agentPoolProfiles.filter(profile => !profile.enableEncryptionAtHost).map(profile => profile.name); + + if (unencryptedAtHost.length) { + helpers.addResult(results, 2, + `AKS Cluster does not have encryption at host enabled for following node pools: ${unencryptedAtHost.join(',')} `, location, managedCluster.id); + } else { + helpers.addResult(results, 0, + 'AKS Cluster has encryption at host enabled for all node pools', location, managedCluster.id); + } + + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksHostBasedEncryption.spec.js b/plugins/azure/kubernetesservice/aksHostBasedEncryption.spec.js new file mode 100644 index 000000000..4af1682c1 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksHostBasedEncryption.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var aksHostBasedEncryption = require('./aksHostBasedEncryption'); +const listCluster = [ + { + "id": "/subscriptions/12345/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': { 'key': 'value' }, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "agentPoolProfiles": [ + { + "name": "agentpool", + "osType": "Linux", + "enableEncryptionAtHost": true, + }, + { + "name": "test", + "osType": "Linux", + "enableEncryptionAtHost": true, + } + ], + }, + { + "id": "/subscriptions/12345/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': {}, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "agentPoolProfiles": [ + { + "name": "agentpool", + "mode": "System", + "osType": "Linux", + "enableEncryptionAtHost": true + }, + { + "name": "test", + "provisioningState": "Succeeded", + "orchestratorVersion": "1.17.9", + "mode": "User", + "osType": "Linux" + } + ], + }, + { + "id": "/subscriptions/12345/resourcegroups/ABSBAKS2/providers/Microsoft.ContainerService/managedClusters/absbaks2", + "location": "eastus", + 'tags': {}, + "name": "absbaks2", + "type": "Microsoft.ContainerService/ManagedClusters", + "sku": { + "name": "Basic", + "tier": "Free" + }, + "agentPoolProfiles": [ + ], + } +] + +const createCache = (err, list) => { + return { + managedClusters: { + list: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('aksHostBasedEncryption', function() { + describe('run', function() { + it('should give passing result if no managed clusters', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[]); + aksHostBasedEncryption.run(cache, {}, callback); + }); + + it('should give Unknown result if unable to query AKS', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,null); + aksHostBasedEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query node profile', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing node pools found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,[listCluster[2]]); + aksHostBasedEncryption.run(cache, {}, callback); + }) + + + it('should give failing result if Kubernetes Service node pools does not have encryption at host enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS Cluster does not have encryption at host enabled for following node pools: test '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null,[listCluster[1]]); + aksHostBasedEncryption.run(cache, {}, callback); + }); + + it('should give passing result if Kubernetes Service have encryption at host enabled for all node pools', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS Cluster has encryption at host enabled for all node pools'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache(null,[listCluster[0]]); + aksHostBasedEncryption.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksLatestVersion.js b/plugins/azure/kubernetesservice/aksLatestVersion.js index 3c4c4b9d5..e5900accd 100644 --- a/plugins/azure/kubernetesservice/aksLatestVersion.js +++ b/plugins/azure/kubernetesservice/aksLatestVersion.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Kubernetes Latest Version', category: 'Kubernetes Service', + domain: 'Containers', + severity: 'High', description: 'Ensures the latest version of Kubernetes is installed on AKS clusters', more_info: 'AKS supports provisioning clusters from several versions of Kubernetes. Clusters should be kept up to date to ensure Kubernetes security patches are applied.', recommended_action: 'Upgrade the version of Kubernetes on all AKS clusters to the latest available version.', - link: 'https://docs.microsoft.com/en-us/azure/aks/aad-integration', + link: 'https://learn.microsoft.com/en-us/azure/aks/upgrade-aks-cluster?tabs=azure-portal', apis: ['managedClusters:list', 'managedClusters:getUpgradeProfile'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], run: function(cache, settings, callback) { var results = []; @@ -44,7 +47,7 @@ module.exports = { getUpgradeProfile.data.controlPlaneProfile.upgrades && getUpgradeProfile.data.controlPlaneProfile.upgrades.length) { helpers.addResult(results, 2, - `The managed cluster does not have the latest Kubernetes version: ${getUpgradeProfile.data.controlPlaneProfile.upgrades[0]}`, location, managedCluster.id); + `The managed cluster does not have the latest Kubernetes version: ${getUpgradeProfile.data.controlPlaneProfile.upgrades[0].kubernetesVersion}`, location, managedCluster.id); } else { helpers.addResult(results, 0, 'The managed cluster has the latest Kubernetes version', location, managedCluster.id); diff --git a/plugins/azure/kubernetesservice/aksLatestVersion.spec.js b/plugins/azure/kubernetesservice/aksLatestVersion.spec.js index 8043d8554..9d0db2b22 100644 --- a/plugins/azure/kubernetesservice/aksLatestVersion.spec.js +++ b/plugins/azure/kubernetesservice/aksLatestVersion.spec.js @@ -65,8 +65,8 @@ describe('aksLatestVersion', function() { "kubernetesVersion": "1.11.10", "osType": "Linux", "upgrades": [ - "1.12.7", - "1.12.8" + {"kubernetesVersion": "1.12.7"}, + {"kubernetesVersion": "1.12.8"} ] }, "agentPoolProfiles": [ diff --git a/plugins/azure/kubernetesservice/aksManagedIdentity.js b/plugins/azure/kubernetesservice/aksManagedIdentity.js new file mode 100644 index 000000000..75492f01e --- /dev/null +++ b/plugins/azure/kubernetesservice/aksManagedIdentity.js @@ -0,0 +1,55 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'AKS Cluster Managed Identity Enabled', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures a system or user assigned managed identity is enabled to authenticate to AKS Cluster.', + more_info: 'Maintaining cloud connection credentials in code is a security risk. Credentials should never appear on developer workstations and should not be checked into source control. Managed identities for Azure resources provides Azure services with a managed identity in Azure AD which can be used to authenticate to any service that supports Azure AD authentication, without having to include any credentials in code.', + recommended_action: 'Enable system or user-assigned identities for all AKS Clusters.', + link: 'https://learn.microsoft.com/en-us/azure/aks/use-managed-identity', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters found', location); + return rcb(); + } + + managedClusters.data.forEach(managedCluster => { + + if (managedCluster.identity && managedCluster.identity.type) { + helpers.addResult(results, 0, 'AKS cluster has managed identity enabled', location, managedCluster.id); + } else { + helpers.addResult(results, 2, 'AKS cluster does not have managed identity enabled', location, managedCluster.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/kubernetesservice/aksManagedIdentity.spec.js b/plugins/azure/kubernetesservice/aksManagedIdentity.spec.js new file mode 100644 index 000000000..1445dbf18 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksManagedIdentity.spec.js @@ -0,0 +1,102 @@ +var expect = require('chai').expect; +var aksManagedIdentity = require('./aksManagedIdentity'); + +const managedClusters = [ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": true, + "maxAgentPools": 10, + "identity": { + "type": 'SystemAssigned' + } + }, + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": false, + "maxAgentPools": 10, + } +]; + +const createCache = (managedClusters) => { + return { + managedClusters: { + list: { + 'eastus': { + data: managedClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + managedClusters: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('aksManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no clusters', function(done) { + const cache = createCache([]); + aksManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Managed Identity is not enabled on kubernetes clusters', function(done) { + const cache = createCache([managedClusters[1]]); + aksManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for kubernetes clusters', function(done) { + const cache = createErrorCache(); + aksManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Managed Identity is enabled on kubernetes clusters', function(done) { + const cache = createCache([managedClusters[0]]); + aksManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksNetworkExposure.js b/plugins/azure/kubernetesservice/aksNetworkExposure.js new file mode 100644 index 000000000..0dd8326fe --- /dev/null +++ b/plugins/azure/kubernetesservice/aksNetworkExposure.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Internet Exposure', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'High', + description: 'Ensures that Azure Kubernetes clusters are not exposed to the internet.', + more_info: 'In a private cluster, the control plane or API server has internal IP addresses that are defined in the RFC1918 - Address Allocation for Private Internet document. By using a private cluster, you can ensure network traffic between your API server and your node pools remains on the private network only.', + recommended_action: 'Modify cluster network configuration and enable private cluster feature.', + link: 'https://learn.microsoft.com/en-us/azure/aks/private-clusters', + apis: ['managedClusters:list', 'resourceGroups:list', 'resources:listByResourceGroup', 'networkSecurityGroups:listAll', 'virtualNetworks:listAll',], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete', 'microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete', 'microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters', location); + return rcb(); + } + var resources = helpers.addSource(cache, source, + ['resources', 'listByResourceGroup', location]); + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + // check for api server access + let publicIPs = ['*', '0.0.0.0', '0.0.0.0/0', '', '/0', '::/0', 'internet']; + let internetExposed = ''; + if (cluster.apiServerAccessProfile && !cluster.apiServerAccessProfile.enablePrivateCluster) { + let authorizedIPRanges = cluster.apiServerAccessProfile.authorizedIpRanges; + if (!authorizedIPRanges || !authorizedIPRanges.length || authorizedIPRanges.some(range => publicIPs.includes(range))) { + internetExposed = 'public endpoint access'; + } + } + if (!internetExposed || !internetExposed.length) { + // check NSG rules for node pools + let securityGroupIDs = [], subnets = [], vnets = [], securityGroups = []; + + if (networkSecurityGroups && !networkSecurityGroups.err && networkSecurityGroups.data && networkSecurityGroups.data.length) { + if (virtualNetworks && !virtualNetworks.err && virtualNetworks.data && virtualNetworks.data.length) { + if (cluster.agentPoolProfiles && cluster.agentPoolProfiles.length) { + subnets = cluster.agentPoolProfiles.map(profile => profile.vnetSubnetId); + } + + if (cluster.nodeResourceGroup && resources && Object.keys(resources).length) { + let groupID = Object.keys(resources).find(key => key.toLowerCase().endsWith(cluster.nodeResourceGroup.toLowerCase())); + if (groupID && resources[groupID] && resources[groupID].data && resources[groupID].data.length) { + vnets = resources[groupID].data.filter(resource => resource.type === 'Microsoft.Network/virtualNetworks').map(vnet => vnet.id); + + } + } + + virtualNetworks.data.forEach(vnet => { + if (vnet.subnets && vnet.subnets.length) { + vnet.subnets.forEach(subnet => { + if ((subnets.includes(subnet.id) || vnets.includes(vnet.id)) && subnet.properties && subnet.properties.networkSecurityGroup && subnet.properties.networkSecurityGroup.id) { + securityGroupIDs.push(subnet.properties.networkSecurityGroup.id); + } + }); + } + }); + securityGroups = networkSecurityGroups.data.filter(nsg => securityGroupIDs.includes(nsg.id)); + internetExposed = helpers.checkNetworkExposure(cache, source, [], securityGroups, location, results, {}, cluster); + } + } + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `AKS cluster is exposed to the internet through ${internetExposed}`, location, cluster.id); + } else { + helpers.addResult(results, 0, 'AKS cluster is not exposed to the internet', location, cluster.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/kubernetesservice/aksPrivateCluster.js b/plugins/azure/kubernetesservice/aksPrivateCluster.js new file mode 100644 index 000000000..196260f94 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksPrivateCluster.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'AKS Cluster Private', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'High', + description: 'Ensures that Azure Kubernetes clusters are private.', + more_info: 'In a private cluster, the control plane or API server has internal IP addresses that are defined in the RFC1918 - Address Allocation for Private Internet document. By using a private cluster, you can ensure network traffic between your API server and your node pools remains on the private network only.', + recommended_action: 'Modify cluster network configuration and enable private cluster feature.', + link: 'https://learn.microsoft.com/en-us/azure/aks/private-clusters', + apis: ['managedClusters:list'], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters', location); + return rcb(); + } + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + if (!cluster.apiServerAccessProfile || !cluster.apiServerAccessProfile.enablePrivateCluster) { + helpers.addResult(results, 2, 'AKS cluster is not private', location, cluster.id); + } else { + helpers.addResult(results, 0, 'AKS cluster is private', location, cluster.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksPrivateCluster.spec.js b/plugins/azure/kubernetesservice/aksPrivateCluster.spec.js new file mode 100644 index 000000000..d36149dec --- /dev/null +++ b/plugins/azure/kubernetesservice/aksPrivateCluster.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var aksPrivateCluster = require('./aksPrivateCluster'); + +const managedClusters = [ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": true, + "maxAgentPools": 10, + "apiServerAccessProfile" : { "enablePrivateCluster": false }, + }, + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": false, + "maxAgentPools": 10, + "apiServerAccessProfile" : { "enablePrivateCluster": true }, + } +]; + +const createCache = (managedClusters) => { + return { + managedClusters: { + list: { + 'eastus': { + data: managedClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + managedClusters: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('aksPrivateCluster', function() { + describe('run', function() { + it('should give passing result if no clusters', function(done) { + const cache = createCache([]); + aksPrivateCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if kubernetes clusters is not private', function(done) { + const cache = createCache([managedClusters[0]]); + aksPrivateCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AKS cluster is not private'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for kubernetes clusters', function(done) { + const cache = createErrorCache(); + aksPrivateCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if kubernetes clusters is private', function(done) { + const cache = createCache([managedClusters[1]]); + aksPrivateCluster.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('AKS cluster is private'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js b/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js new file mode 100644 index 000000000..4206e57c8 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js @@ -0,0 +1,23 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Info', + description: 'Ensures that AKS clusters and workloads are not granted excessive permissions.', + more_info: 'AKS clusters often use managed identities to interact with Azure resources. Over-privileged identities can lead to privilege escalation or lateral movement within the cluster or the Azure environment. Following the principle of least privilege helps minimize potential attack surfaces.', + link: 'https://docs.microsoft.com/en-us/azure/aks/use-managed-identity', + recommended_action: 'Review and minimize Azure AD permissions granted to AKS managed identities and workload identities. Use Azure RBAC and Kubernetes RBAC best practices to ensure only required access is permitted.', + realtime_triggers: [ + 'Microsoft.ContainerService/managedClusters/write', + 'Microsoft.ContainerService/managedClusters/delete', + 'Microsoft.ContainerService/managedClusters/agentPools/write', + 'Microsoft.ManagedIdentity/userAssignedIdentities/assign/action', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; diff --git a/plugins/azure/kubernetesservice/rbacEnabled.js b/plugins/azure/kubernetesservice/rbacEnabled.js index 7270555f6..d7bba795a 100644 --- a/plugins/azure/kubernetesservice/rbacEnabled.js +++ b/plugins/azure/kubernetesservice/rbacEnabled.js @@ -4,12 +4,15 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Kubernetes RBAC Enabled', category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Medium', description: 'Ensures that RBAC is enabled on all Azure Kubernetes Service instances', more_info: 'Role Based Access Control (RBAC) provides greater control and security for Kubernetes clusters and should be enabled on all instances.', recommended_action: 'Enable RBAC authentication for all Azure Kubernetes Clusters', - link: 'https://docs.microsoft.com/en-us/azure/aks/aad-integration', + link: 'https://learn.microsoft.com/en-us/azure/aks/aad-integration', apis: ['managedClusters:list'], - + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], + run: function(cache, settings, callback) { var results = []; var source = {}; diff --git a/plugins/azure/kubernetesservice/rbacEnabled.spec.js b/plugins/azure/kubernetesservice/rbacEnabled.spec.js new file mode 100644 index 000000000..8439f7593 --- /dev/null +++ b/plugins/azure/kubernetesservice/rbacEnabled.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var roleBasedAccessControl = require('./rbacEnabled'); + +const managedClusters = [ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": true, + "maxAgentPools": 10, + }, + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.ContainerService/managedClusters/tes-cluster', + "location": 'eastus', + "name": 'tes-cluster', + "type": 'Microsoft.ContainerService/ManagedClusters', + "provisioningState": 'Succeeded', + "kubernetesVersion": '1.18.14', + "dnsPrefix": 'tes-cluster-dns', + "fqdn": 'tes-cluster-dns-f7b98b1e.hcp.eastus.azmk8s.io', + "enableRBAC": false, + "maxAgentPools": 10, + } +]; + +const createCache = (managedClusters) => { + return { + managedClusters: { + list: { + 'eastus': { + data: managedClusters + } + } + } + }; +}; + +const createErrorCache = () => { + return { + managedClusters: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('roleBasedAccessControl', function() { + describe('run', function() { + it('should give passing result if no clusters', function(done) { + const cache = createCache([]); + roleBasedAccessControl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Kubernetes clusters'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if RBAC is not enabled on kubernetes clusters', function(done) { + const cache = createCache([managedClusters[1]]); + roleBasedAccessControl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('RBAC is not enabled on the cluster'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for kubernetes clusters', function(done) { + const cache = createErrorCache(); + roleBasedAccessControl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Kubernetes clusters: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if RBAC is enabled on kubernetes clusters', function(done) { + const cache = createCache([managedClusters[0]]); + roleBasedAccessControl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RBAC is enabled on the cluster'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/loadbalancer/lbHasTags.js b/plugins/azure/loadbalancer/lbHasTags.js new file mode 100644 index 000000000..5f18eb53d --- /dev/null +++ b/plugins/azure/loadbalancer/lbHasTags.js @@ -0,0 +1,72 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Load Balancer Has Tags', + category: 'Load Balancer', + domain: 'Availability', + severity: 'Low', + description: 'Ensures that Azure Load Balancers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify affected load balancers and add tags.', + apis: ['loadBalancers:listAll'], + settings: { + ignore_internal_lbs_tags: { + name: 'Ignore Internal Load Balancers', + description: 'When set to true, skips checking internal load balancers', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + ignore_internal_lbs_tags: settings.ignore_internal_lbs_tags || this.settings.ignore_internal_lbs_tags.default + }; + + config.ignore_internal_lbs_tags = (config.ignore_internal_lbs_tags == 'true'); + + async.each(locations.loadBalancers, function(location, rcb) { + + const loadBalancers = helpers.addSource(cache, source, + ['loadBalancers', 'listAll', location]); + + if (!loadBalancers) return rcb(); + + if (loadBalancers.err || !loadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query Load Balancers: ' + helpers.addError(loadBalancers), location); + return rcb(); + } + + if (!loadBalancers.data.length) { + helpers.addResult(results, 0, 'No existing Load Balancers found', location); + return rcb(); + } + + for (let lb of loadBalancers.data) { + if (!lb.id) continue; + if (config.ignore_internal_lbs_tags && lb.frontendIPConfigurations + && lb.frontendIPConfigurations.length && + lb.frontendIPConfigurations.some(ipconfig => + ipconfig.properties && ipconfig.properties.privateIPAddress) + ) continue; + + if (lb.tags && Object.entries(lb.tags).length > 0){ + helpers.addResult(results, 0, 'Load Balancer has tags associated', location, lb.id); + } else { + helpers.addResult(results, 2, 'Load Balancer does not have tags associated', location, lb.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/loadbalancer/lbHasTags.spec.js b/plugins/azure/loadbalancer/lbHasTags.spec.js new file mode 100644 index 000000000..dd2286077 --- /dev/null +++ b/plugins/azure/loadbalancer/lbHasTags.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var lbHasTags = require('./lbHasTags'); + +const loadBalancers = [ + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "tags": { "key": "value" } + }, + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "tags": {} + }, +]; + +const createCache = (lbs, err) => { + return { + loadBalancers: { + listAll: { + 'eastus': { + err: err, + data: lbs + } + } + } + } +}; + +describe('lbHasTags', function() { + describe('run', function() { + it('should give passing result if no existing Load Balancers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Load Balancers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + lbHasTags.run(cache, {}, callback); + }); + + it('should give passing result if lb has tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Load Balancer has tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[0]] + ); + + lbHasTags.run(cache, {}, callback); + }); + + it('should give failing result if lb does not have tags associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Load Balancer does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[1]], + ); + + lbHasTags.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query Load Balancers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Load Balancers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Load Balancers'} + ); + + lbHasTags.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/loadbalancer/lbHttpsOnly.js b/plugins/azure/loadbalancer/lbHttpsOnly.js index 3bc5ad967..dc02570ca 100644 --- a/plugins/azure/loadbalancer/lbHttpsOnly.js +++ b/plugins/azure/loadbalancer/lbHttpsOnly.js @@ -4,9 +4,11 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'LB HTTPS Only', category: 'Load Balancer', + domain: 'Availability', + severity: 'Medium', description: 'Ensures load balancers are configured to only accept connections on HTTPS ports', more_info: 'For maximum security, load balancers can be configured to only accept HTTPS connections. Standard HTTP connections will be blocked. This should only be done if the client application is configured to query HTTPS directly and not rely on a redirect from HTTP.', - link: 'https://docs.microsoft.com/en-us/azure/load-balancer/load-balancer-overview', + link: 'https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-overview', recommended_action: 'Ensure that each load balancer only accepts connections on port 443.', apis: ['loadBalancers:listAll'], compliance: { @@ -14,12 +16,27 @@ module.exports = { 'App Service HTTPS redirection should be used to ensure site visitors ' + 'are always connecting over a secure channel.', }, + settings: { + ignore_internal_lb_https: { + name: 'Ignore Internal Load Balancers', + description: 'When set to true, skips checking internal load balancers', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + var config = { + ignore_internal_lb_https: settings.ignore_internal_lb_https || this.settings.ignore_internal_lb_https.default + }; + + config.ignore_internal_lb_https = (config.ignore_internal_lb_https == 'true'); + async.each(locations.loadBalancers, function(location, rcb) { const loadBalancers = helpers.addSource(cache, source, @@ -42,6 +59,12 @@ module.exports = { var notHTTPSRules = 0; var isHTTPS = false; + if (config.ignore_internal_lb_https && loadBalancer.frontendIPConfigurations + && loadBalancer.frontendIPConfigurations.length && + loadBalancer.frontendIPConfigurations.some(ipconfig => + ipconfig.properties && ipconfig.properties.privateIPAddress) + ) return; + if (loadBalancer.inboundNatRules && loadBalancer.inboundNatRules.length > 0) { loadBalancer.inboundNatRules.forEach(inboundRule => { diff --git a/plugins/azure/loadbalancer/lbHttpsOnly.spec.js b/plugins/azure/loadbalancer/lbHttpsOnly.spec.js new file mode 100644 index 000000000..5fe809b70 --- /dev/null +++ b/plugins/azure/loadbalancer/lbHttpsOnly.spec.js @@ -0,0 +1,163 @@ +var expect = require('chai').expect; +var lbHttpsOnly = require('./lbHttpsOnly'); + +const loadBalancers = [ + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "inboundNatRules": [ + { + "name": 'testHTTPSRule', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb/inboundNatRules/testRule1', + "type": 'Microsoft.Network/loadBalancers/inboundNatRules', + "properties": { + "frontendPort": 443, + "backendPort": 443, + } + } + ], + }, + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "inboundNatRules": [ + { + "name": 'testHTTPSRule', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb/inboundNatRules/testRule1', + "type": 'Microsoft.Network/loadBalancers/inboundNatRules', + "properties": { + "frontendPort": 443, + "backendPort": 443, + } + }, + { + "name": 'testHTTPSRule', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb/inboundNatRules/testRule2', + "type": 'Microsoft.Network/loadBalancers/inboundNatRules', + "properties": { + "frontendPort": 444, + "backendPort": 444, + } + } + ], + }, + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "inboundNatRules": [ + { + "name": 'testHTTPSRule', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb/inboundNatRules/testRule2', + "type": 'Microsoft.Network/loadBalancers/inboundNatRules', + "properties": { + "frontendPort": 444, + "backendPort": 444, + } + } + ], + } +]; + +const createCache = (lbs, err) => { + return { + loadBalancers: { + listAll: { + 'eastus': { + err: err, + data: lbs + } + } + } + } +}; + +describe('lbHttpsOnly', function() { + describe('run', function() { + it('should give passing result if no existing Load Balancers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Load Balancers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + lbHttpsOnly.run(cache, {}, callback); + }); + + it('should give passing result if only HTTPS is configured', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Only HTTPS is configured'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[0]] + ); + + lbHttpsOnly.run(cache, {}, callback); + }); + + it('should give failing result if HTTPS is configured but other ports are open', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('HTTPS is configured but other ports are open'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[1]], + ); + + lbHttpsOnly.run(cache, {}, callback); + }); + + it('should give failing result if HTTPS is not configured and other ports are open', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('HTTPS is not configured and other ports are open'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[2]], + ); + + lbHttpsOnly.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query Load Balancers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Load Balancers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Load Balancers'} + ); + + lbHttpsOnly.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/monitor/lbLogAnalyticsEnabled.js b/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.js similarity index 69% rename from plugins/azure/monitor/lbLogAnalyticsEnabled.js rename to plugins/azure/loadbalancer/lbLogAnalyticsEnabled.js index 4f4b4c156..15877e9d9 100644 --- a/plugins/azure/monitor/lbLogAnalyticsEnabled.js +++ b/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.js @@ -3,23 +3,40 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Load Balancer Log Analytics Enabled', - category: 'Monitor', + category: 'Load Balancer', + domain: 'Availability', + severity: 'Medium', description: 'Ensures Load Balancers Log Analytics logs are being properly delivered to Azure Monitor', more_info: 'Enabling Send to Log Analytics ensures that all Load Balancer logs are being properly monitored and managed.', recommended_action: 'Send all diagnostic logs for Load Balancers from the Azure Monitor service to Log Analytics.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', apis: ['loadBalancers:listAll', 'diagnosticSettings:listByLoadBalancer'], compliance: { hipaa: 'HIPAA requires that a secure audit log record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + settings: { + ignore_internal_lb_log_analystics: { + name: 'Ignore Internal Load Balancers', + description: 'When set to true, skips checking internal load balancers', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + var config = { + ignore_internal_lb_log_analystics: settings.ignore_internal_lb_log_analystics || this.settings.ignore_internal_lb_log_analystics.default + }; + + config.ignore_internal_lb_log_analystics = (config.ignore_internal_lb_log_analystics == 'true'); + async.each(locations.loadBalancers, (location, rcb) => { const loadBalancers = helpers.addSource(cache, source, ['loadBalancers', 'listAll', location]); @@ -38,6 +55,13 @@ module.exports = { } loadBalancers.data.forEach(function(loadBalancer) { + + if (config.ignore_internal_lb_log_analystics && loadBalancer.frontendIPConfigurations + && loadBalancer.frontendIPConfigurations.length && + loadBalancer.frontendIPConfigurations.some(ipconfig => + ipconfig.properties && ipconfig.properties.privateIPAddress) + ) return; + const diagnosticSettings = helpers.addSource(cache, source, ['diagnosticSettings', 'listByLoadBalancer', location, loadBalancer.id]); diff --git a/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.spec.js b/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.spec.js new file mode 100644 index 000000000..aa87d15ce --- /dev/null +++ b/plugins/azure/loadbalancer/lbLogAnalyticsEnabled.spec.js @@ -0,0 +1,318 @@ +var expect = require('chai').expect; +var lbLogAnalyticsEnabled = require('./lbLogAnalyticsEnabled'); + +const loadBalancers = [ + { + "name": "kubernetes", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/loadBalancers", + "location": "eastus", + "sku": { + "name": "Standard" + }, + "provisioningState": "Succeeded", + "resourceGuid": "e88ed351-f991-4268-94f5-57334c1443af", + "frontendIPConfigurations": [ + { + "name": "3859f556-a02d-42d9-8bd3-42301f41f8be", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/loadBalancers/frontendIPConfigurations", + "properties": { + "provisioningState": "Succeeded", + "privateIPAllocationMethod": "Dynamic", + "inboundNatRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/inboundNatRules/jbs" + } + ], + "outboundRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/outboundRules/aksOutboundRule" + } + ] + } + } + ], + "backendAddressPools": [ + { + "name": "aksOutboundBackendPool", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/backendAddressPools/aksOutboundBackendPool", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "properties": { + "provisioningState": "Succeeded", + "outboundRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/outboundRules/aksOutboundRule" + } + ], + "backendIPConfigurations": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool-30757528-vmss/virtualMachines/1/networkInterfaces/aks-agentpool-30757528-vmss/ipConfigurations/ipconfig1" + } + ] + }, + "type": "Microsoft.Network/loadBalancers/backendAddressPools" + }, + { + "name": "kubernetes", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/backendAddressPools/kubernetes", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "properties": { + "provisioningState": "Succeeded", + "backendIPConfigurations": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool-30757528-vmss/virtualMachines/1/networkInterfaces/aks-agentpool-30757528-vmss/ipConfigurations/ipconfig1" + } + ] + }, + "type": "Microsoft.Network/loadBalancers/backendAddressPools" + } + ], + "loadBalancingRules": [], + "probes": [], + "inboundNatRules": [ + { + "name": "jbs", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/inboundNatRules/jbs", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/loadBalancers/inboundNatRules", + "properties": { + "provisioningState": "Succeeded", + "frontendIPConfiguration": { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be" + }, + "frontendPort": 22, + "backendPort": 22, + "enableFloatingIP": false, + "idleTimeoutInMinutes": 4, + "protocol": "Tcp", + "enableDestinationServiceEndpoint": false, + "enableTcpReset": false, + "allowBackendPortConflict": false + } + } + ], + "outboundRules": [ + { + "name": "aksOutboundRule", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/outboundRules/aksOutboundRule", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/loadBalancers/outboundRules", + "properties": { + "provisioningState": "Succeeded", + "allocatedOutboundPorts": 0, + "protocol": "All", + "enableTcpReset": true, + "idleTimeoutInMinutes": 30, + "backendAddressPool": { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/backendAddressPools/aksOutboundBackendPool" + }, + "frontendIPConfigurations": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be" + } + ] + } + } + ], + "inboundNatPools": [] + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/mc_ali-resource-group_test-ali_eastus/providers/microsoft.network/loadbalancers/kubernetes/providers/microsoft.insights/diagnosticSettings/monitor-lb", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "monitor-lb", + "location": "eastus", + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Storage/storageAccounts/akhtarrgdiag", + "serviceBusRuleId": null, + "workspaceId": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus", + "eventHubAuthorizationRuleId": null, + "eventHubName": null, + "metrics": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": { + "enabled": true, + "days": 7 + } + } + ], + "logs": [ + { + "category": "LoadBalancerAlertEvent", + "categoryGroup": null, + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": "LoadBalancerProbeHealthStatus", + "categoryGroup": null, + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + "logAnalyticsDestinationType": null + }, + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/mc_ali-resource-group_test-ali_eastus/providers/microsoft.network/loadbalancers/kubernetes/providers/microsoft.insights/diagnosticSettings/monitor-lb", + "type": "Microsoft.Insights/diagnosticSettings", + "name": "monitor-lb", + "location": "eastus", + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Storage/storageAccounts/akhtarrgdiag", + "serviceBusRuleId": null, + "workspaceId": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus", + "eventHubAuthorizationRuleId": null, + "eventHubName": null, + "metrics": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": { + "enabled": true, + "days": 7 + } + } + ], + "logs": [], + "logAnalyticsDestinationType": null + } +]; + + +const createCache = (loadBalancer, diagnosticSetting) => { + let logs = {}; + if (loadBalancer.length > 0) { + logs[loadBalancer[0].id] = { + data: diagnosticSetting + }; + } + + return { + loadBalancers: { + listAll: { + 'eastus': { + data: loadBalancer + } + } + }, + diagnosticSettings: { + listByLoadBalancer: { + 'eastus': logs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'loadbalancer') { + return { + loadBalancers: { + listAll: { + 'eastus': {} + } + } + }; + } else { + return { + loadBalancers: { + listAll: { + 'eastus': { + data: [loadBalancers[0]] + } + } + }, + diagnosticSettings: { + listByLoadBalancer: { + 'eastus': {} + } + } + }; + } +}; + +describe('lbLogAnalyticsEnabled', function() { + describe('run', function() { + it('should give passing result if No existing Load Balancers found', function(done) { + const cache = createCache([], []); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Load Balancers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give Failing result if No existing diagnostics settings', function(done) { + const cache = createCache([loadBalancers[0]],[]); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing diagnostics settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Load Balancers', function(done) { + const cache = createErrorCache('loadbalancer'); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Load Balancers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function(done) { + const cache = createErrorCache('flowLog'); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query diagnostics settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Log analytics is enabled for load balancer', function(done) { + const cache = createCache([loadBalancers[0]], [diagnosticSettings[0]]); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Log analytics is enabled for load balancer'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Log analytics is not enabled for load balancer', function(done) { + const cache = createCache([loadBalancers[0]], [diagnosticSettings[1]]); + lbLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log analytics is not enabled for load balancer'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/loadbalancer/lbNoInstances.js b/plugins/azure/loadbalancer/lbNoInstances.js index 37355a4d1..3c4a41fc7 100644 --- a/plugins/azure/loadbalancer/lbNoInstances.js +++ b/plugins/azure/loadbalancer/lbNoInstances.js @@ -4,17 +4,34 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'LB No Instances', category: 'Load Balancer', + domain: 'Availability', + severity: 'Low', description: 'Detects load balancers that have no backend instances attached', more_info: 'All load balancers should have backend server resources. Those without any are consuming costs without providing any functionality. Additionally, old load balancers with no instances pose a security concern if new instances are accidentally attached.', - link: 'https://docs.microsoft.com/en-us/azure/load-balancer/load-balancer-overview', + link: 'https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-overview', recommended_action: 'Delete old load balancers that no longer have backend resources.', apis: ['loadBalancers:listAll'], + settings: { + ignore_internal_lb_instances: { + name: 'Ignore Internal Load Balancers', + description: 'When set to true, skips checking internal load balancers', + regex: '^(true|false)$', + default: 'false', + } + }, + realtime_triggers: ['microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + var config = { + ignore_internal_lb_instances: settings.ignore_internal_lb_instances || this.settings.ignore_internal_lb_instances.default + }; + + config.ignore_internal_lb_instances = (config.ignore_internal_lb_instances == 'true'); + async.each(locations.loadBalancers, function(location, rcb) { const loadBalancers = helpers.addSource(cache, source, @@ -35,6 +52,12 @@ module.exports = { loadBalancers.data.forEach(loadBalancer => { var backendAmt = 0; + + if (config.ignore_internal_lb_instances && loadBalancer.frontendIPConfigurations + && loadBalancer.frontendIPConfigurations.length && + loadBalancer.frontendIPConfigurations.some(ipconfig => + ipconfig.properties && ipconfig.properties.privateIPAddress) + ) return; if (!loadBalancer.backendAddressPools || (loadBalancer.backendAddressPools && diff --git a/plugins/azure/loadbalancer/lbNoInstances.spec.js b/plugins/azure/loadbalancer/lbNoInstances.spec.js new file mode 100644 index 000000000..4019f25c9 --- /dev/null +++ b/plugins/azure/loadbalancer/lbNoInstances.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +var lbNoInstances = require('./lbNoInstances'); + +const loadBalancers = [ + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "backendAddressPools": [ + { + "name": 'khulnasoft-bp', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb/backendAddressPools/khulnasoft-bp', + "properties": { + "provisioningState": 'Succeeded', + "backendIPConfigurations": [ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/networkInterfaces/khulnasoft-instance/ipConfigurations/ipconfig1' + } + ] + }, + "type": 'Microsoft.Network/loadBalancers/backendAddressPools' + } + ], + }, + { + "name": 'khulnasoft-lb', + "id": '/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/loadBalancers/khulnasoft-lb', + "type": 'Microsoft.Network/loadBalancers', + "location": 'eastus', + "backendAddressPools": [] + } +]; + +const createCache = (lbs, err) => { + return { + loadBalancers: { + listAll: { + 'eastus': { + err: err, + data: lbs + } + } + } + } +}; + +describe('lbNoInstances', function() { + describe('run', function() { + it('should give passing result if no existing Load Balancers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Load Balancers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + lbNoInstances.run(cache, {}, callback); + }); + + it('should give passing result if Load Balancer has', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Load Balancer has 1 backend instance or address'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[0]] + ); + + lbNoInstances.run(cache, {}, callback); + }); + + it('should give failing result if Load Balancer does not have any backend instances', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Load Balancer does not have any backend instances'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[1]], + ); + + lbNoInstances.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query Load Balancers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Load Balancers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Load Balancers'} + ); + + lbNoInstances.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/loadbalancer/lbPublicIp.js b/plugins/azure/loadbalancer/lbPublicIp.js new file mode 100644 index 000000000..823080183 --- /dev/null +++ b/plugins/azure/loadbalancer/lbPublicIp.js @@ -0,0 +1,55 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Public Load Balancer', + category: 'Load Balancer', + domain: 'Availability', + description: 'Ensures that Azure Load Balancers are configured as public.', + severity: 'Medium', + more_info: 'To meet your organization\'s security compliance, ensure that load balancers are public to facilitate efficient egress to the Internet for backend pool members through the assigned frontend IP, ensuring streamlined connectivity and reliable resource availability.', + link: 'https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-overview', + recommended_action: 'Create the Load Balancer with Ip associations as per your organization\'s requirements.', + apis: ['loadBalancers:listAll'], + realtime_triggers: ['microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.loadBalancers, function(location, rcb) { + + const loadBalancers = helpers.addSource(cache, source, + ['loadBalancers', 'listAll', location]); + + if (!loadBalancers) return rcb(); + + if (loadBalancers.err || !loadBalancers.data) { + helpers.addResult(results, 3, + 'Unable to query Load Balancers: ' + helpers.addError(loadBalancers), location); + return rcb(); + } + + if (!loadBalancers.data.length) { + helpers.addResult(results, 0, 'No existing Load Balancers found', location); + return rcb(); + } + + for (let lb of loadBalancers.data) { + if (!lb.id) continue; + + if (lb.frontendIPConfigurations && lb.frontendIPConfigurations.length && + lb.frontendIPConfigurations.some(ipconfig => + ipconfig.properties && ipconfig.properties.publicIPAddress)) { + helpers.addResult(results, 0, 'Load Balancer is configured as public', location, lb.id); + } else { + helpers.addResult(results, 2, 'Load Balancer is not configured as public', location, lb.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/loadbalancer/lbPublicIp.spec.js b/plugins/azure/loadbalancer/lbPublicIp.spec.js new file mode 100644 index 000000000..4bc7f5f75 --- /dev/null +++ b/plugins/azure/loadbalancer/lbPublicIp.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var lbPublicIp = require('./lbPublicIp'); + +const loadBalancers = [ + { + "name": "test", + "id": "/subscriptions/123456/resourceGroups/test/providers/Microsoft.Network/loadBalancers/test", + "etag": "W/\"1234\"", + "type": "Microsoft.Network/loadBalancers", + "location": "eastus", + "sku": { + "name": "Standard" + }, + "provisioningState": "Succeeded", + "resourceGuid": "123456", + "frontendIPConfigurations": [ + { + "name": "3859f556-a02d-42d9-8bd3-42301f41f8be", + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be", + "etag": "W/\"1234\"", + "type": "Microsoft.Network/loadBalancers/frontendIPConfigurations", + "properties": { + "provisioningState": "Succeeded", + "privateIPAllocationMethod": "Dynamic", + "publicIPAddress": { + "id": "/subscriptions/123456/resourceGroups/test/providers/Microsoft.Network/publicIPAddresses/3859f556-a02d-42d9-8bd3-42301f41f8be" + }, + "inboundNatRules": [ + { + "id": "/subscriptions/123456/resourceGroups/test/providers/Microsoft.Network/loadBalancers/kubernetes/inboundNatRules/jbs" + } + ], + "outboundRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/loadBalancers/kubernetes/outboundRules/aksOutboundRule" + } + ] + } + } + ], + + }, + { + "name": "test2", + "id": "/subscriptions/123456/resourceGroups/testresource/providers/Microsoft.Network/loadBalancers/test2", + "etag": "W/\"123456\"", + "type": "Microsoft.Network/loadBalancers", + "location": "eastus", + "sku": { + "name": "Standard" + }, + "provisioningState": "Succeeded", + "resourceGuid": "123456", + "frontendIPConfigurations": [ + { + "name": "3859f556-a02d-42d9-8bd3-42301f41f8be", + "id": "/subscriptions/123456/resourceGroups/test/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be", + "etag": "W/\"123456\"", + "type": "Microsoft.Network/loadBalancers/frontendIPConfigurations", + "properties": { + "privateIPAddress":'10.0.0.4', + "privateIPAddressVersion":'IPv4', + "privateIPAllocationMethod":'Dynamic', + "provisioningState": 'Succeeded' + } + } + ], + + } +]; + +const createCache = (lbs, err) => { + return { + loadBalancers: { + listAll: { + 'eastus': { + err: err, + data: lbs + } + } + } + } +}; + +describe('lbPublicIp', function() { + describe('run', function() { + it('should give passing result if no existing Load Balancers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Load Balancers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + lbPublicIp.run(cache, {}, callback); + }); + + it('should give passing result if lb has Public IP associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Load Balancer is configured as public'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[0]] + ); + + lbPublicIp.run(cache, {}, callback); + }); + + it('should give failing result if lb does not have Public IP associated', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Load Balancer is not configured as public'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [loadBalancers[1]], + ); + + lbPublicIp.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query Load Balancers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Load Balancers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: 'Unable to query Load Balancers'} + ); + + lbPublicIp.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/logalerts/flexibleServerLoggingEnabled.js b/plugins/azure/logalerts/flexibleServerLoggingEnabled.js new file mode 100644 index 000000000..13b49f398 --- /dev/null +++ b/plugins/azure/logalerts/flexibleServerLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for create/update and delete PostgreSQL Flexible Server events are enabled.', + more_info: 'Monitoring for create/update and delete PostgreSQL Flexible Server events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for PostgreSQL Flexible Server create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.dbforpostgresql/flexibleservers'; + var text = 'PostgreSql Flexible Server'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/flexibleServerLoggingEnabled.spec.js b/plugins/azure/logalerts/flexibleServerLoggingEnabled.spec.js new file mode 100644 index 000000000..7f033d890 --- /dev/null +++ b/plugins/azure/logalerts/flexibleServerLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var flexibleServerLoggingEnabled = require("./flexibleServerLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/flexibleservers/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/flexibleservers/write" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/flexibleservers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/flexibleservers/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/flexibleservers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("flexibleServerLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if PostgreSQL Flexible Server write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for PostgreSql Flexible Server write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if PostgreSQL Flexible Server delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for PostgreSql Flexible Server write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for PostgreSql Flexible Server delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if PostgreSQL Flexible Server write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for PostgreSql Flexible Server delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for PostgreSql Flexible Server write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if PostgreSQL Flexible Server Database write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + flexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for PostgreSql Flexible Server write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/keyVaultsLoggingEnabled.js b/plugins/azure/logalerts/keyVaultsLoggingEnabled.js new file mode 100644 index 000000000..04c6819f8 --- /dev/null +++ b/plugins/azure/logalerts/keyVaultsLoggingEnabled.js @@ -0,0 +1,35 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Key Vault Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create/update and delete Key Vault events are enabled', + more_info: 'Monitoring for create/update and delete Key Vault events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Key Vault create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.keyvault/vaults'; + var text = 'Key Vaults'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/keyVaultsLoggingEnabled.spec.js b/plugins/azure/logalerts/keyVaultsLoggingEnabled.spec.js new file mode 100644 index 000000000..8a5c778d4 --- /dev/null +++ b/plugins/azure/logalerts/keyVaultsLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var keyVaultsLoggingEnabled = require("./keyVaultsLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.KeyVault/vaults/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.KeyVault/vaults/write" + }, + { + "field": "operationName", + "equals": "Microsoft.KeyVault/vaults/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.KeyVault/vaults/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.KeyVault/vaults/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("keyVaultsLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Key Vaults create/update and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Key Vaults write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Key Vaults delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Key Vaults write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Key Vaults delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Key Vaults write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Key Vaults delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Key Vaults write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Key Vaults create/update and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + keyVaultsLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for Key Vaults write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/loadBalancerLoggingEnabled.js b/plugins/azure/logalerts/loadBalancerLoggingEnabled.js new file mode 100644 index 000000000..242b90fbe --- /dev/null +++ b/plugins/azure/logalerts/loadBalancerLoggingEnabled.js @@ -0,0 +1,35 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Load Balancers Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create or update and delete Load Balancers events are enabled', + more_info: 'Monitoring for create or update and delete Load Balancers events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Load Balancers create or update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.network/loadbalancers'; + var text = 'Load Balancers'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/loadBalancerLoggingEnabled.spec.js b/plugins/azure/logalerts/loadBalancerLoggingEnabled.spec.js new file mode 100644 index 000000000..12173fc38 --- /dev/null +++ b/plugins/azure/logalerts/loadBalancerLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var loadBalancerLoggingEnabled = require("./loadBalancerLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/loadbalancers/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/loadbalancers/write" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/loadbalancers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/loadbalancers/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/loadbalancers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("loadBalancerLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Load Balancers write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Load Balancers write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Load Balancers delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Load Balancers write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Load Balancers delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Load Balancers write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Load Balancers delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Load Balancers write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Load Balancers write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + loadBalancerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for Load Balancers write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.js b/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.js new file mode 100644 index 000000000..2d73fd945 --- /dev/null +++ b/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for create/update and delete MySQL Flexible Server events are enabled.', + more_info: 'Monitoring for create/update and delete MySQL Flexible Server events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for MySQL Flexible Server create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.dbformysql/flexibleservers'; + var text = 'MySQL Flexible Server'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.spec.js b/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.spec.js new file mode 100644 index 000000000..85b1f9455 --- /dev/null +++ b/plugins/azure/logalerts/mysqlFlexibleServerLoggingEnabled.spec.js @@ -0,0 +1,235 @@ +var expect = require("chai").expect; +var mysqlFlexibleServerLoggingEnabled = require("./mysqlFlexibleServerLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/12345/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/123456" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforMySQL/flexibleservers/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/123456/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/123456/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/123456" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforMySQL/flexibleServers/write" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforMySQL/flexibleServers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/123456/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + + } + ] + } + }, + { + "id": "/subscriptions/123456/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/123456" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforMySQL/flexibleServers/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/123456/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/123456/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/123456" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforMySQL/flexibleServers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/123456/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("mysqlFlexibleServerLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if MySQL Flexible Server write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for MySQL Flexible Server write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if MySQL Flexible Server delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for MySQL Flexible Server write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for MySQL Flexible Server delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if MySQL Flexible Server write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for MySQL Flexible Server delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for MySQL Flexible Server write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if MySQL Flexible Server Database write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + mysqlFlexibleServerLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for MySQL Flexible Server write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/nsgLoggingEnabled.js b/plugins/azure/logalerts/nsgLoggingEnabled.js index c33e71755..032c2b071 100644 --- a/plugins/azure/logalerts/nsgLoggingEnabled.js +++ b/plugins/azure/logalerts/nsgLoggingEnabled.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Network Security Groups Logging Enabled', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log alerts for the create or update and delete Network Security Group events are enabled', more_info: 'Monitoring for create or update and delete Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Network Security Group create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', apis: ['activityLogAlerts:listBySubscriptionId'], + remediation_min_version: '202011191613', + remediation_description: 'A Network Security Group log alert will be created to monitor create, update and delete actions', + apis_remediate: ['activityLogAlerts:listBySubscriptionId'], + remediation_inputs: { + nsgResourceGroup: { + name: '(Mandatory) Resource Group Name', + description: 'Name of the Resource Group log alerts will be created in', + regex: '^[-_.a-zA-Z0-9]{1,120}$', + required: true + }, + nsgActionGroup: { + name: '(Mandatory) Action Group Name', + description: 'Name of the Action Group to send alerts to', + regex: '^[-_.a-zA-Z0-9]{1,120}$', + required: true + } + }, + actions: {remediate:['activityLogAlerts:write'], rollback:['activityLogAlerts:write']}, + permissions: {remediate: ['activityLogAlerts:write'], rollback: ['activityLogAlerts:write']}, + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { var results = []; @@ -30,5 +52,79 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + // inputs specific to the plugin + var pluginName = 'nsgLoggingEnabled'; + var method = 'PUT'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2017-04-01'; + + if (settings.input && settings.input.nsgResourceGroup && settings.input.nsgActionGroup) { + var thisResource = resource + `/resourceGroups/${settings.input.nsgResourceGroup}/providers/microsoft.insights/activityLogAlerts/KhulnasoftWaveNSGLogAlert`; + + var scopedSubscriptionArr = resource.split(''); + scopedSubscriptionArr.shift(); + var scopedSubscription = scopedSubscriptionArr.join(''); + // create the params necessary for the remediation + var body = { + 'location': 'global', + 'properties': { + 'scopes': [scopedSubscription], + 'enabled': true, + 'condition': { + 'allOf': [ + { + 'field': 'category', + 'equals': 'Administrative' + }, + { + 'field': 'level', + 'equals': 'Warning' + }, + { + 'field': 'resourceType', + 'equals': 'Microsoft.Network/networkSecurityGroups' + } + ] + }, + 'actions': { + 'actionGroups': [ + { + 'actionGroupId': `${resource}/resourceGroups/${settings.input.nsgResourceGroup}/providers/microsoft.insights/actionGroups/${settings.input.nsgActionGroup}`, + + } + ] + }, + 'description': 'Log alert created by Khulnasoft Wave for Network Security Group create, update and delete actions', + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + + }; + + + helpers.remediatePlugin(config, method, body, baseUrl, thisResource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Created', + 'Name': 'KhulnasoftWaveNSGLogAlert' + }; + + callback(null, action); + }); + } else { + callback('Required inputs missing'); + } + }, + rollback: function(config, cache, settings, resource, callback) { + return callback('Rollback is not available'); } }; diff --git a/plugins/azure/logalerts/nsgLoggingEnabled.spec.js b/plugins/azure/logalerts/nsgLoggingEnabled.spec.js new file mode 100644 index 000000000..cd04595d3 --- /dev/null +++ b/plugins/azure/logalerts/nsgLoggingEnabled.spec.js @@ -0,0 +1,158 @@ +var expect = require("chai").expect; +var nsgLoggingEnabled = require("./nsgLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/networkSecurityGroups/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/networkSecurityGroups/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("nsgLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if nsg write is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Network Security Groups write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if nsg delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Network Security Groups delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if nsg write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Network Security Groups write is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if nsg delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + nsgLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Network Security Groups delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/nsgRuleLoggingEnabled.js b/plugins/azure/logalerts/nsgRuleLoggingEnabled.js index 08aa96ea8..5042d81b0 100644 --- a/plugins/azure/logalerts/nsgRuleLoggingEnabled.js +++ b/plugins/azure/logalerts/nsgRuleLoggingEnabled.js @@ -4,11 +4,15 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Network Security Groups Rule Logging Enabled', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log alerts for the create or update and delete Network Security Group rule events are enabled', more_info: 'Monitoring for create or update and delete Network Security Group rule events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Network Security Group rule create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/logalerts/policyAssignmentLogging.js b/plugins/azure/logalerts/policyAssignmentLogging.js index 7163d0abf..f18628485 100644 --- a/plugins/azure/logalerts/policyAssignmentLogging.js +++ b/plugins/azure/logalerts/policyAssignmentLogging.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Policy Assignment Alerts Enabled', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log alerts for create or update and delete Policy Assignment events are enabled', more_info: 'Monitoring for create or update and delete Policy Assignment events gives insight into policy changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Policy Assignment create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.js b/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.js new file mode 100644 index 000000000..a8485f388 --- /dev/null +++ b/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Database Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for create/update and delete PostgreSQL Server Database events are enabled.', + more_info: 'Monitoring for create/update and delete PostgreSQL Server Database events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for PostgreSQL Server Database create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.dbforpostgresql/servers/databases'; + var text = 'PostgreSql Server Database'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.spec.js b/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.spec.js new file mode 100644 index 000000000..117927fe6 --- /dev/null +++ b/plugins/azure/logalerts/postgreSqlDBLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var postgreSqlDBLoggingEnabled = require("./postgreSqlDBLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/servers/databases/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/servers/databases/write" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/servers/databases/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/servers/databases/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.DBforPostgreSQL/servers/databases/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("postgreSqlDBLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if PostgresQL Server Database write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for PostgreSql Server Database write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if PostgresQL Server Database delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for PostgreSql Server Database write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for PostgreSql Server Database delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if PostgresQL Server Database write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for PostgreSql Server Database delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for PostgreSql Server Database write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if PostgresQL Server Database write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + postgreSqlDBLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for PostgreSql Server Database write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/publicIpAddressLoggingEnabled.js b/plugins/azure/logalerts/publicIpAddressLoggingEnabled.js new file mode 100644 index 000000000..9f111e923 --- /dev/null +++ b/plugins/azure/logalerts/publicIpAddressLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Public Ip Address Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create/update and delete Public Ip Address events are enabled', + more_info: 'Monitoring for Create or Update Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Public Ip Address create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.network/publicipaddresses'; + var text = 'Public IP Addresses'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/publicIpAddressLoggingEnabled.spec.js b/plugins/azure/logalerts/publicIpAddressLoggingEnabled.spec.js new file mode 100644 index 000000000..5adbd1315 --- /dev/null +++ b/plugins/azure/logalerts/publicIpAddressLoggingEnabled.spec.js @@ -0,0 +1,222 @@ +var expect = require("chai").expect; +var publicIpAddressLoggingEnabled = require("./publicIpAddressLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/publicIPAddresses/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/publicIPAddresses/write" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/publicIPAddresses/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/publicIPAddresses/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/publicIPAddresses/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("publicIpAddressLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Public Ip Address create/update and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("Log Alert for Public IP Addresses write and delete is not enabled"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Public IP Addresses delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Log alert for Public IP Addresses write is enabled"); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include("Log Alert for Public IP Addresses delete is not enabled"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Public IP Addresses write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Log alert for Public IP Addresses delete is enabled"); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include("Log alert for Public IP Addresses write is not enabled"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Public IP Addresses create/update and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + publicIpAddressLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include("Log Alert for Public IP Addresses write and delete is enabled"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/securityPolicyAlertsEnabled.js b/plugins/azure/logalerts/securityPolicyAlertsEnabled.js index d3fd44706..3531e8566 100644 --- a/plugins/azure/logalerts/securityPolicyAlertsEnabled.js +++ b/plugins/azure/logalerts/securityPolicyAlertsEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Security Policy Alerts Enabled', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log alerts for create or update Security Policy Rule events are enabled', more_info: 'Monitoring for create or update Security Policy Rule events gives insight into policy changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Security Policy Rule create or update events.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', - apis: ['activityLogAlerts:listBySubscriptionId'], + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { var results = []; @@ -24,7 +27,7 @@ module.exports = { var activityLogAlerts = helpers.addSource(cache, source, ['activityLogAlerts', 'listBySubscriptionId', location]); - helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location, 'security'); rcb(); }, function() { diff --git a/plugins/azure/logalerts/securityPolicyAlertsEnabled.spec.js b/plugins/azure/logalerts/securityPolicyAlertsEnabled.spec.js new file mode 100644 index 000000000..f8e5d3f61 --- /dev/null +++ b/plugins/azure/logalerts/securityPolicyAlertsEnabled.spec.js @@ -0,0 +1,132 @@ +var expect = require("chai").expect; +var securityPolicyAlertsEnabled = require("./securityPolicyAlertsEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Security/policies/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Security/policies/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("securityPolicyAlertsEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + securityPolicyAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + securityPolicyAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Security Policy write/update is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + securityPolicyAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Security Policy write/update is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Security Policy write/update is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + securityPolicyAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for Security Policy write/update is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/securitySolutionLogging.js b/plugins/azure/logalerts/securitySolutionLogging.js index 67a0d3d6e..c42dae360 100644 --- a/plugins/azure/logalerts/securitySolutionLogging.js +++ b/plugins/azure/logalerts/securitySolutionLogging.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Security Solution Logging', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log Alerts for the create or update and delete Security Solution events are enabled', more_info: 'Monitoring for create or update and delete Security Solution events gives insight into event changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Security Solution create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/security/azure-log-audit', + link: 'https://learn.microsoft.com/en-us/azure/security/azure-log-audit', apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/logalerts/securitySolutionLogging.spec.js b/plugins/azure/logalerts/securitySolutionLogging.spec.js new file mode 100644 index 000000000..515610451 --- /dev/null +++ b/plugins/azure/logalerts/securitySolutionLogging.spec.js @@ -0,0 +1,176 @@ +var expect = require("chai").expect; +var securitySolutionLogging = require("./securitySolutionLogging"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Security/securitySolutions/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Security/securitySolutions/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Security/securitySolutions/update" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("securitySolutionLogging", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + securitySolutionLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + securitySolutionLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Security Solutions delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + securitySolutionLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Security Solution delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Security Solutions write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + securitySolutionLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Security Solution write is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Security Solutions write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + securitySolutionLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Security Solution write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.js b/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.js new file mode 100644 index 000000000..f1fae3a34 --- /dev/null +++ b/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Database Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create or update and delete SQL Server Database events are enabled', + more_info: 'Monitoring for create or update and delete SQL Server Database events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for SQL Server Database create or update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.sql/servers/databases'; + var text = 'SQL Server Database'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.spec.js b/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.spec.js new file mode 100644 index 000000000..48f098524 --- /dev/null +++ b/plugins/azure/logalerts/sqlServerDatabaseLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var sqlServerDatabaseLoggingEnabled = require("./sqlServerDatabaseLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/write" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("sqlServerDatabaseLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if SQL Server Database write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for SQL Server Database write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if SQL Server Database delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for SQL Server Database write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for SQL Server Database delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if SQL Server Database write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for SQL Server Database delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for SQL Server Database write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if SQL Server Database write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + sqlServerDatabaseLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for SQL Server Database write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.js b/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.js new file mode 100644 index 000000000..8cdd628ae --- /dev/null +++ b/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.js @@ -0,0 +1,80 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Database Rename Alert Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the rename SQL Server Database events are enabled', + more_info: 'Monitoring for rename SQL Server Database events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for SQL Server Database rename events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.sql/servers/databases'; + var text = 'SQL Server Database'; + + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + if (!activityLogAlerts) return rcb(); + + if (activityLogAlerts.err || !activityLogAlerts.data) { + helpers.addResult(results, 3, + 'Unable to query for Activity Alerts: ' + helpers.addError(activityLogAlerts), location); + return rcb(); + } + + if (!activityLogAlerts.data.length) { + helpers.addResult(results, 2, 'No existing Activity Alerts found', location); + return rcb(); + } + + let alertRenameEnabled = false; + let subscriptionId; + + for (let res in activityLogAlerts.data) { + const activityLogAlertResource = activityLogAlerts.data[res]; + subscriptionId = '/subscriptions/' + activityLogAlertResource.id.split('/')[2]; + + if (activityLogAlertResource.type && + activityLogAlertResource.type.toLowerCase() !== 'Microsoft.Insights/ActivityLogAlerts'.toLowerCase()) continue; + + const allConditions = activityLogAlertResource.condition; + + if (!allConditions || !allConditions.allOf || !allConditions.allOf.length) continue; + + var conditionOperation = allConditions.allOf.filter((d) => { + return (d.equals && d.equals.toLowerCase().indexOf(conditionResource) > -1); + }); + if (conditionOperation && conditionOperation.length) { + allConditions.allOf.forEach(condition => { + if (condition.equals && condition.equals.toLowerCase().indexOf(conditionResource + '/move') > -1) { + alertRenameEnabled = (!alertRenameEnabled && activityLogAlertResource.enabled ? true : alertRenameEnabled); + } + }); + } + } + + if (!alertRenameEnabled) { + helpers.addResult(results, 2, + `Log alert for ${text} rename is not enabled`, location, subscriptionId); + } else { + helpers.addResult(results, 0, + `Log alert for ${text} rename is enabled`, location, subscriptionId); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.spec.js b/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.spec.js new file mode 100644 index 000000000..c11a453d0 --- /dev/null +++ b/plugins/azure/logalerts/sqlServerDatabaseRenameAlert.spec.js @@ -0,0 +1,132 @@ +var expect = require("chai").expect; +var sqlServerDatabaseRenameAlert = require("./sqlServerDatabaseRenameAlert"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/databases/move/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("sqlServerDatabaseRenameAlert", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + sqlServerDatabaseRenameAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + sqlServerDatabaseRenameAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if SQL Server Database rename alert is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + sqlServerDatabaseRenameAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log alert for SQL Server Database rename is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if SQL Server Database rename alert is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + sqlServerDatabaseRenameAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for SQL Server Database rename is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.js b/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.js index 676faeb86..6af3fc1ef 100644 --- a/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.js +++ b/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'SQL Server Firewall Rule Alerts Monitor', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log Alerts for the create or update and delete SQL Server Firewall Rules events are enabled', more_info: 'Monitoring for create or update and delete SQL Server Firewall Rules events gives insight into event changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for SQL Server Firewall Rules create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-firewall-configure', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-firewall-configure', apis: ['activityLogAlerts:listBySubscriptionId'], compliance: { hipaa: 'HIPAA requires the auditing of changes to access controls for network ' + @@ -16,6 +18,7 @@ module.exports = { 'a monitor for SQL Server firewall rules ensures the integrity of those ' + 'firewalls.' }, + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.spec.js b/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.spec.js new file mode 100644 index 000000000..2d8708f84 --- /dev/null +++ b/plugins/azure/logalerts/sqlServerFirewallRuleEnabled.spec.js @@ -0,0 +1,176 @@ +var expect = require("chai").expect; +var sqlServerFirewallRuleEnabled = require("./sqlServerFirewallRuleEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Sql/servers/update" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("sqlServerFirewallRuleEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + sqlServerFirewallRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + sqlServerFirewallRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if SQL Server Firewall Rule delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + sqlServerFirewallRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for SQL Server Firewall Rule delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if SQL Server Firewall Rule write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + sqlServerFirewallRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for SQL Server Firewall Rule write is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if SQL Server Firewall Rule write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + sqlServerFirewallRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for SQL Server Firewall Rule write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/storageAccountLoggingEnabled.js b/plugins/azure/logalerts/storageAccountLoggingEnabled.js new file mode 100644 index 000000000..5bffb5d6b --- /dev/null +++ b/plugins/azure/logalerts/storageAccountLoggingEnabled.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Storage Account Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create/update and delete Storage Account events are enabled', + more_info: 'Monitoring for create/update and delete Storage Account events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Storage Account create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.storage/storageaccounts'; + var text = 'Storage Accounts'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/storageAccountLoggingEnabled.spec.js b/plugins/azure/logalerts/storageAccountLoggingEnabled.spec.js new file mode 100644 index 000000000..79cbdc22c --- /dev/null +++ b/plugins/azure/logalerts/storageAccountLoggingEnabled.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var storageAccountLoggingEnabled = require("./storageAccountLoggingEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Storage/storageAccounts/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Storage/storageAccounts/write" + }, + { + "field": "operationName", + "equals": "Microsoft.Storage/storageAccounts/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Storage/storageAccounts/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Storage/storageAccounts/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("storageAccountLoggingEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Storage Accounts write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Storage Accounts write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Storage Accounts delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Storage Accounts write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Storage Accounts delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Storage Accounts write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Storage Accounts delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Storage Accounts write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Storage Accounts write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + storageAccountLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for Storage Accounts write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/virtualMachineLogging.js b/plugins/azure/logalerts/virtualMachineLogging.js new file mode 100644 index 000000000..e96b6f027 --- /dev/null +++ b/plugins/azure/logalerts/virtualMachineLogging.js @@ -0,0 +1,34 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Virtual Machine Logging Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the create/update and delete Virtual Machine events are enabled', + more_info: 'Monitoring for create/update and delete Virtual Machine events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Virtual Machine create/update and delete events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.compute/virtualmachines'; + var text = 'Virtual Machines'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + helpers.checkLogAlerts(activityLogAlerts, conditionResource, text, results, location); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/virtualMachineLogging.spec.js b/plugins/azure/logalerts/virtualMachineLogging.spec.js new file mode 100644 index 000000000..b87a024f7 --- /dev/null +++ b/plugins/azure/logalerts/virtualMachineLogging.spec.js @@ -0,0 +1,234 @@ +var expect = require("chai").expect; +var virtualMachineLogging = require("./virtualMachineLogging"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/write" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("virtualMachineLogging", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Virtual Machines write and delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log Alert for Virtual Machines write and delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Virtual Machines delete is not enaled but write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[2]]); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Machines write is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Virtual Machines delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing and passing results if Virtual Machines write is not enaled but delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[3]]); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Machines delete is enabled" + ); + expect(results[0].region).to.equal("global"); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Virtual Machines write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Virtual Machines write and delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + virtualMachineLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log Alert for Virtual Machines write and delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/virtualMachinesDeallocateAlert.js b/plugins/azure/logalerts/virtualMachinesDeallocateAlert.js new file mode 100644 index 000000000..c5d6b9576 --- /dev/null +++ b/plugins/azure/logalerts/virtualMachinesDeallocateAlert.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Virtual Machine Deallocate Alert Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Activity Log alerts for the deallocate Virtual Machine events are enabled.', + more_info: 'Monitoring for deallocate Virtual Machine events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Virtual Machine deallocate events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.compute/virtualmachines'; + var text = 'Virtual Machines'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + if (!activityLogAlerts) return rcb(); + + if (activityLogAlerts.err || !activityLogAlerts.data) { + helpers.addResult(results, 3, + 'Unable to query for Activity Alerts: ' + helpers.addError(activityLogAlerts), location); + return rcb(); + } + + if (!activityLogAlerts.data.length) { + helpers.addResult(results, 2, 'No existing Activity Alerts found', location); + return rcb(); + } + + let alertDeallocateEnabled = false; + let subscriptionId; + + for (let res in activityLogAlerts.data) { + const activityLogAlertResource = activityLogAlerts.data[res]; + subscriptionId = '/subscriptions/' + activityLogAlertResource.id.split('/')[2]; + + if (activityLogAlertResource.type && + activityLogAlertResource.type.toLowerCase() !== 'Microsoft.Insights/ActivityLogAlerts'.toLowerCase()) continue; + + const allConditions = activityLogAlertResource.condition; + + if (!allConditions || !allConditions.allOf || !allConditions.allOf.length) continue; + + var conditionOperation = allConditions.allOf.filter((d) => { + return (d.equals && d.equals.toLowerCase().indexOf(conditionResource) > -1); + }); + if (conditionOperation && conditionOperation.length) { + allConditions.allOf.forEach(condition => { + if (condition.equals && condition.equals.toLowerCase() && condition.equals.toLowerCase().indexOf(conditionResource + '/deallocate') > -1) { + alertDeallocateEnabled = (!alertDeallocateEnabled && activityLogAlertResource.enabled ? true : alertDeallocateEnabled); + } + }); + } + } + + if (!alertDeallocateEnabled) { + helpers.addResult(results, 2, + `Log alert for ${text} deallocate is not enabled`, location, subscriptionId); + } else { + helpers.addResult(results, 0, + `Log alert for ${text} deallocate is enabled`, location, subscriptionId); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/virtualMachinesDeallocateAlert.spec.js b/plugins/azure/logalerts/virtualMachinesDeallocateAlert.spec.js new file mode 100644 index 000000000..da44dc95c --- /dev/null +++ b/plugins/azure/logalerts/virtualMachinesDeallocateAlert.spec.js @@ -0,0 +1,132 @@ +var expect = require("chai").expect; +var virtualMachinesDeallocateAlert = require("./virtualMachinesDeallocateAlert"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/deallocate" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("virtualMachinesDeallocateAlert", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + virtualMachinesDeallocateAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + virtualMachinesDeallocateAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Virtual Machines deallocate is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + virtualMachinesDeallocateAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log alert for Virtual Machines deallocate is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Virtual Machines deallocate is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + virtualMachinesDeallocateAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Machines deallocate is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/virtualMachinesPowerOffAlert.js b/plugins/azure/logalerts/virtualMachinesPowerOffAlert.js new file mode 100644 index 000000000..5b83df47c --- /dev/null +++ b/plugins/azure/logalerts/virtualMachinesPowerOffAlert.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Virtual Machine Power Off Alert Enabled', + category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures Activity Log alerts for the power off Virtual Machine events are enabled', + more_info: 'Monitoring for power off Virtual Machine events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.', + recommended_action: 'Add a new log alert to the Alerts service that monitors for Virtual Machine power off events.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-alerts', + apis: ['activityLogAlerts:listBySubscriptionId'], + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.activityLogAlerts, function(location, rcb) { + var conditionResource = 'microsoft.compute/virtualmachines'; + var text = 'Virtual Machines'; + var activityLogAlerts = helpers.addSource(cache, source, + ['activityLogAlerts', 'listBySubscriptionId', location]); + + if (!activityLogAlerts) return rcb(); + + if (activityLogAlerts.err || !activityLogAlerts.data) { + helpers.addResult(results, 3, + 'Unable to query for Activity Alerts: ' + helpers.addError(activityLogAlerts), location); + return rcb(); + } + + if (!activityLogAlerts.data.length) { + helpers.addResult(results, 2, 'No existing Activity Alerts found', location); + return rcb(); + } + + let alertPowerOffEnabled = false; + let subscriptionId; + + for (let res in activityLogAlerts.data) { + const activityLogAlertResource = activityLogAlerts.data[res]; + subscriptionId = '/subscriptions/' + activityLogAlertResource.id.split('/')[2]; + + if (activityLogAlertResource.type && + activityLogAlertResource.type.toLowerCase() !== 'Microsoft.Insights/ActivityLogAlerts'.toLowerCase()) continue; + + const allConditions = activityLogAlertResource.condition; + + if (!allConditions || !allConditions.allOf || !allConditions.allOf.length) continue; + + var conditionOperation = allConditions.allOf.filter((d) => { + return (d.equals && d.equals.toLowerCase().indexOf(conditionResource) > -1); + }); + if (conditionOperation && conditionOperation.length) { + allConditions.allOf.forEach(condition => { + if (condition.equals && condition.equals.toLowerCase() && condition.equals.toLowerCase().indexOf(conditionResource + '/poweroff') > -1) { + alertPowerOffEnabled = (!alertPowerOffEnabled && activityLogAlertResource.enabled ? true : alertPowerOffEnabled); + } + }); + } + } + + if (!alertPowerOffEnabled) { + helpers.addResult(results, 2, + `Log alert for ${text} powerOff is not enabled`, location, subscriptionId); + } else { + helpers.addResult(results, 0, + `Log alert for ${text} powerOff is enabled`, location, subscriptionId); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/logalerts/virtualMachinesPowerOffAlert.spec.js b/plugins/azure/logalerts/virtualMachinesPowerOffAlert.spec.js new file mode 100644 index 000000000..162bf4a00 --- /dev/null +++ b/plugins/azure/logalerts/virtualMachinesPowerOffAlert.spec.js @@ -0,0 +1,132 @@ +var expect = require("chai").expect; +var virtualMachinesPowerOffAlert = require("./virtualMachinesPowerOffAlert"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Admninistrative" + }, + { + "field": "operationName", + "equals": "Microsoft.Compute/virtualMachines/powerOff" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("virtualMachinesPowerOffAlert", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + virtualMachinesPowerOffAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + virtualMachinesPowerOffAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Virtual Machines powerOff is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + virtualMachinesPowerOffAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "Log alert for Virtual Machines powerOff is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Virtual Machines powerOff is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + virtualMachinesPowerOffAlert.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Machines powerOff is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/logalerts/virtualNetworkRuleEnabled.js b/plugins/azure/logalerts/virtualNetworkRuleEnabled.js index 98c5632e0..24bf34f79 100644 --- a/plugins/azure/logalerts/virtualNetworkRuleEnabled.js +++ b/plugins/azure/logalerts/virtualNetworkRuleEnabled.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Virtual Network Alerts Monitor', category: 'Log Alerts', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Activity Log Alerts for the create or update and delete Virtual Networks events are enabled', more_info: 'Monitoring for create or update and delete Virtual Networks events gives insight into event changes and may reduce the time it takes to detect suspicious activity.', recommended_action: 'Add a new log alert to the Alerts service that monitors for Virtual Networks create or update and delete events.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/security-overview', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/security-overview', apis: ['activityLogAlerts:listBySubscriptionId'], compliance: { hipaa: 'HIPAA requires the auditing of changes to access controls for network ' + @@ -16,6 +18,7 @@ module.exports = { 'a monitor for changes to Virtual Networks ensures the integrity of those ' + 'firewalls.' }, + realtime_triggers: ['microsoftinsights:activitylogalerts:write', 'microsoftinsights:activitylogalerts:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/logalerts/virtualNetworkRuleEnabled.spec.js b/plugins/azure/logalerts/virtualNetworkRuleEnabled.spec.js new file mode 100644 index 000000000..071f93552 --- /dev/null +++ b/plugins/azure/logalerts/virtualNetworkRuleEnabled.spec.js @@ -0,0 +1,158 @@ +var expect = require("chai").expect; +var virtualNetworkRuleEnabled = require("./virtualNetworkRuleEnabled"); + +const activityLogAlerts = [ + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/virtualNetworks/delete" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + }, + { + "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourceGroups/Default-ActivityLogAlerts/providers/microsoft.insights/activityLogAlerts/NSG2", + "name": "NSG2", + "type": "Microsoft.Insights/ActivityLogAlerts", + "location": "global", + "tags": {}, + "scopes": [ + "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a" + ], + "enabled": true, + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Security" + }, + { + "field": "operationName", + "equals": "Microsoft.Network/virtualNetworks/write" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/resourcegroups/default-activitylogalerts/providers/microsoft.insights/actiongroups/testactiong", + "webhookProperties": {} + } + ] + } + } +]; + +const createCache = (err, data) => { + return { + activityLogAlerts: { + listBySubscriptionId: { + global: { + err: err, + data: data, + }, + }, + }, + }; +}; + +describe("virtualNetworkRuleEnabled", function () { + describe("run", function () { + it("should give failing result if no activity log alerts found", function (done) { + const cache = createCache(null, []); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("No existing Activity Alerts found"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give unknown result if unable to query for Activity alerts", function (done) { + const cache = createCache(null); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include("Unable to query for Activity Alerts"); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Virtual Networks Rule write is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log alert for Virtual Networks write is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give failing result if Virtual Networks Rule delete is not enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include( + "Log Alert for Virtual Networks delete is not enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Virtual Networks Rule write is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[1]]); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Networks write is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + + it("should give passing result if Virtual Networks Rule delete is enabled", function (done) { + const cache = createCache(null, [activityLogAlerts[0]]); + virtualNetworkRuleEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "Log alert for Virtual Networks delete is enabled" + ); + expect(results[0].region).to.equal("global"); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/machinelearning/mlRegistryHasTags.js b/plugins/azure/machinelearning/mlRegistryHasTags.js new file mode 100644 index 000000000..d8403564e --- /dev/null +++ b/plugins/azure/machinelearning/mlRegistryHasTags.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Registry Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Azure Machine Learning registries have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Machine Learning registry and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['machineLearning:listRegistries'], + realtime_triggers: ['microsoftmachinelearningservices:registries:write','microsoftmachinelearningservices:registries:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + var machineLearningRegistries = helpers.addSource(cache, source, + ['machineLearning', 'listRegistries', location]); + + if (!machineLearningRegistries) return rcb(); + + if (machineLearningRegistries.err || !machineLearningRegistries.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning registries: ' + helpers.addError(machineLearningRegistries), location); + return rcb(); + } + + if (!machineLearningRegistries.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning registries found', location); + return rcb(); + } + + for (let registry of machineLearningRegistries.data) { + if (!registry.id) continue; + + if (registry.tags && Object.entries(registry.tags).length > 0) { + helpers.addResult(results, 0, + 'Machine Learning registry has tags associated', location, registry.id); + } else { + helpers.addResult(results, 2, + 'Machine Learning registry does not have tags associated', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlRegistryHasTags.spec.js b/plugins/azure/machinelearning/mlRegistryHasTags.spec.js new file mode 100644 index 000000000..b7ecc79e6 --- /dev/null +++ b/plugins/azure/machinelearning/mlRegistryHasTags.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var mlRegistryHasTags = require('./mlRegistryHasTags'); + +const registry = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/registries/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/registries", + "tags": { + "test": "test" + }, + "publicNetworkAccess" : "Disabled" + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/registries/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/registries", + "publicNetworkAccess" : "Enabled" + }, + +]; + +const createCache = (registries) => { + return { + machineLearning: { + listRegistries: { + 'eastus': { + data: registries + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listRegistries: { + 'eastus': {} + } + } + }; +}; + +describe('mlRegistryHasTags', function() { + describe('run', function() { + it('should give passing result if no Machine Learning registry found', function(done) { + const cache = createCache([]); + mlRegistryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning registries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning registry', function(done) { + const cache = createErrorCache(); + mlRegistryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning registries: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning registry has tags associated', function(done) { + const cache = createCache([registry[0]]); + mlRegistryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning registry has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning registry does not have tags associated', function(done) { + const cache = createCache([registry[1]]); + mlRegistryHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning registry does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlRegistryPublicAccess.js b/plugins/azure/machinelearning/mlRegistryPublicAccess.js new file mode 100644 index 000000000..db0892073 --- /dev/null +++ b/plugins/azure/machinelearning/mlRegistryPublicAccess.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Registry Public Access Disabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Machine Learning registries are not publicly accessible.', + more_info: 'Disabling public network access for Azure Machine Learning registries helps prevent data leakage risks by ensuring that your registries are not accessible over the public internet. Configuring network isolation with private endpoints prevents the network traffic from going over the public internet and brings Azure Machine Learning registry service to your Virtual network preventing exposure of sensitive data.', + recommended_action: 'Ensure that Azure Machine Learning registries have public network access disabled.', + link: 'https://learn.microsoft.com/en-us/azure/machine-learning/how-to-registry-network-isolation', + apis: ['machineLearning:listRegistries'], + realtime_triggers: ['microsoftmachinelearningservices:registries:write','microsoftmachinelearningservices:registries:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + var machineLearningRegistries = helpers.addSource(cache, source, + ['machineLearning', 'listRegistries', location]); + + if (!machineLearningRegistries) return rcb(); + + if (machineLearningRegistries.err || !machineLearningRegistries.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning registries: ' + helpers.addError(machineLearningRegistries), location); + return rcb(); + } + + if (!machineLearningRegistries.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning registries found', location); + return rcb(); + } + + for (let registry of machineLearningRegistries.data) { + if (!registry.id) continue; + + if (registry.publicNetworkAccess && registry.publicNetworkAccess.toLowerCase()=='disabled') { + helpers.addResult(results, 0, + 'Machine Learning registry has public network access disabled', location, registry.id); + } else { + helpers.addResult(results, 2, + 'Machine Learning registry has public network access enabled', location, registry.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlRegistryPublicAccess.spec.js b/plugins/azure/machinelearning/mlRegistryPublicAccess.spec.js new file mode 100644 index 000000000..a3c21ad04 --- /dev/null +++ b/plugins/azure/machinelearning/mlRegistryPublicAccess.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var mlRegistryPublicAccess = require('./mlRegistryPublicAccess'); + +const registry = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/registries/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/registries", + "tags": { + "test": "test" + }, + "publicNetworkAccess" : "Disabled" + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/registries/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/registries", + "publicNetworkAccess" : "Enabled" + }, + +]; + +const createCache = (registries) => { + return { + machineLearning: { + listRegistries: { + 'eastus': { + data: registries + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listRegistries: { + 'eastus': {} + } + } + }; +}; + +describe('mlRegistryPublicAccess', function() { + describe('run', function() { + it('should give passing result if no Machine Learning registry found', function(done) { + const cache = createCache([]); + mlRegistryPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning registries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning registry', function(done) { + const cache = createErrorCache(); + mlRegistryPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning registries: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning registry has public access disabled', function(done) { + const cache = createCache([registry[0]]); + mlRegistryPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning registry has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning registry does not have public access disabled', function(done) { + const cache = createCache([registry[1]]); + mlRegistryPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning registry has public network access enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.js b/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.js new file mode 100644 index 000000000..efc8dd25b --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Workspace CMK Encrypted', + category: 'MySQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that Machine Learning Workspaces are encrypted using CMK.', + more_info: 'Azure Machine Learning allows you to encrypt workspaces using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault.', + recommended_action: 'Ensure that Machine Learning Workspaces are encrypted using CMK.', + link: 'https://learn.microsoft.com/en-us/azure/machine-learning/concept-customer-managed-keys', + apis: ['machineLearning:listWorkspaces'], + realtime_triggers: ['microsoft:machinelearningservices:workspaces:write', 'microsoft:machinelearningservices:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + var machineLearningWorkspaces = helpers.addSource(cache, source, + ['machineLearning', 'listWorkspaces', location]); + + if (!machineLearningWorkspaces) return rcb(); + + if (machineLearningWorkspaces.err || !machineLearningWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning workspaces: ' + helpers.addError(machineLearningWorkspaces), location); + return rcb(); + } + + if (!machineLearningWorkspaces.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning workspaces found', location); + return rcb(); + } + + for (let workspace of machineLearningWorkspaces.data) { + if (!workspace.id) continue; + + if (workspace.encryption && workspace.encryption.keyVaultProperties && workspace.encryption.keyVaultProperties.keyIdentifier) { + helpers.addResult(results, 0, 'Machine Learning workspace is encrypted using CMK', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Machine Learning workspace is not encrypted using CMK', location, workspace.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.spec.js b/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.spec.js new file mode 100644 index 000000000..5427b8f47 --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceCMKEncrypted.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var mlWorkspaceCMKEncrypted = require('./mlWorkspaceCMKEncrypted'); + +const workspaces = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "encryption": { + "keyVaultProperties": { + "keyIdentifier": "https://dummy.vault.azure.net/keys/test2/9e34232342342343242343", + "identityClientId": null, + "keyVaultArmId": "/subscriptions/12345667/resourceGroups/test1223/providers/Microsoft.KeyVault/vaults/dummy" + }, + } + + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + + }, +]; + +const createCache = (workspaces) => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': {} + } + } + }; +}; + +describe('mlWorkspaceCMKEncrypted', function() { + describe('run', function() { + it('should give passing result if no Machine Learning workspace found', function(done) { + const cache = createCache([]); + mlWorkspaceCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning workspaces', function(done) { + const cache = createErrorCache(); + mlWorkspaceCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning workspaces: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning workspace is not encrypted using CMK', function(done) { + const cache = createCache([workspaces[0]]); + mlWorkspaceCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning workspace is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning workspace is not CMK encrypted', function(done) { + const cache = createCache([workspaces[1]]); + mlWorkspaceCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning workspace is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlWorkspaceHBI.js b/plugins/azure/machinelearning/mlWorkspaceHBI.js new file mode 100644 index 000000000..de1135ed5 --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceHBI.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Workspace High Business Impact Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Machine Learning workspaces have High Business Impact (HBI) feature enabled.', + more_info: 'Enabling the High Business Impact (HBI) feature in Machine Learning workspaces controls the data Microsoft collects for diagnostics, prevents the transmission of confidential telemetry, and enhances encryption to protect sensitive business information while ensuring compliance with security protocols.', + recommended_action: 'Ensures that High Business Impact (HBI) feature enabled for Machine Learning workspace.', + link: 'https://learn.microsoft.com/en-us/azure/machine-learning/concept-data-encryption', + apis: ['machineLearning:listWorkspaces'], + realtime_triggers: ['microsoft:machinelearningservices:workspaces:write', 'microsoft:machinelearningservices:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + var machineLearningWorkspaces = helpers.addSource(cache, source, + ['machineLearning', 'listWorkspaces', location]); + + if (!machineLearningWorkspaces) return rcb(); + + if (machineLearningWorkspaces.err || !machineLearningWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning workspaces: ' + helpers.addError(machineLearningWorkspaces), location); + return rcb(); + } + + if (!machineLearningWorkspaces.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning workspaces found', location); + return rcb(); + } + + for (let workspace of machineLearningWorkspaces.data) { + if (!workspace.id) continue; + + if (workspace.hbiWorkspace) { + helpers.addResult(results, 0, + 'Machine Learning workspace has high business impact (HBI) feature enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, + 'Machine Learning workspace does not have high business impact (HBI) feature enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/machinelearning/mlWorkspaceHBI.spec.js b/plugins/azure/machinelearning/mlWorkspaceHBI.spec.js new file mode 100644 index 000000000..ba44032e6 --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceHBI.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +var mlWorkspaceHBI = require('./mlWorkspaceHBI'); + +const workspaces = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "hbiWorkspace": true + + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "hbiWorkspace": false + + }, +]; + +const createCache = (workspaces) => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': {} + } + } + }; +}; + +describe('mlWorkspaceHBI', function() { + describe('run', function() { + it('should give passing result if no Machine Learning workspace found', function(done) { + const cache = createCache([]); + mlWorkspaceHBI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning workspaces', function(done) { + const cache = createErrorCache(); + mlWorkspaceHBI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning workspaces: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning workspace has high business impact (HBI) feature enabled', function(done) { + const cache = createCache([workspaces[0]]); + mlWorkspaceHBI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning workspace has high business impact (HBI) feature enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning workspace does not have high business impact (HBI) feature enabled', function(done) { + const cache = createCache([workspaces[1]]); + mlWorkspaceHBI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning workspace does not have high business impact (HBI) feature enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/machinelearning/mlWorkspaceHasTags.js b/plugins/azure/machinelearning/mlWorkspaceHasTags.js new file mode 100644 index 000000000..e1dff9195 --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceHasTags.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Workspace Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Machine Learning workspaces have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Machine Learning workspace and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['machineLearning:listWorkspaces'], + realtime_triggers: ['microsoft:machinelearningservices:workspaces:write', 'microsoft:machinelearningservices:workspaces:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + + var machineLearningWorkspaces = helpers.addSource(cache, source, + ['machineLearning', 'listWorkspaces', location]); + + if (!machineLearningWorkspaces) return rcb(); + + if (machineLearningWorkspaces.err || !machineLearningWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning workspaces: ' + helpers.addError(machineLearningWorkspaces), location); + return rcb(); + } + + if (!machineLearningWorkspaces.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning workspaces found', location); + return rcb(); + } + + for (let workspace of machineLearningWorkspaces.data) { + if (!workspace.id) continue; + + if (workspace.tags && Object.entries(workspace.tags).length > 0) { + helpers.addResult(results, 0, + 'Machine Learning workspace has tags associated', location, workspace.id); + } else { + helpers.addResult(results, 2, + 'Machine Learning workspace does not have tags associated', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/machinelearning/mlWorkspaceHasTags.spec.js b/plugins/azure/machinelearning/mlWorkspaceHasTags.spec.js new file mode 100644 index 000000000..a5e70fe38 --- /dev/null +++ b/plugins/azure/machinelearning/mlWorkspaceHasTags.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var workspaceHasTags = require('./mlWorkspaceHasTags'); + +const workspaces = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "identity": { + "type": "SystemAssigned" + }, + "tags": { + "test": "test" + } + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + + }, +]; + +const createCache = (workspaces) => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': {} + } + } + }; +}; + +describe('workspaceHasTags', function() { + describe('run', function() { + it('should give passing result if no Machine Learning workspace found', function(done) { + const cache = createCache([]); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning workspaces', function(done) { + const cache = createErrorCache(); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning workspaces: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning workspace has tags associated', function(done) { + const cache = createCache([workspaces[0]]); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning workspace has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning workspace does not have tags associated', function(done) { + const cache = createCache([workspaces[1]]); + workspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning workspace does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/machinelearning/workspaceLoggingEnabled.js b/plugins/azure/machinelearning/workspaceLoggingEnabled.js new file mode 100644 index 000000000..dd3932f0c --- /dev/null +++ b/plugins/azure/machinelearning/workspaceLoggingEnabled.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Workspace Diagnostic Logs', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensure that diagnostic logging is enabled for Machine Learning workspaces.', + more_info: 'Enabling diagnostic logs for Machine Learning workspaces helps to collect resource logs, which provide detailed data about resource operations. It helps to gain valuable insights into resource activity, assisting in monitoring, diagnosing issues, and optimizing the performance.', + recommended_action: 'Enable diagnostic logging for all Machine Learning workspaces.', + link: 'https://learn.microsoft.com/en-us/azure/machine-learning/monitor-azure-machine-learning', + apis: ['machineLearning:listWorkspaces', 'diagnosticSettings:listByMachineLearningWorkspce'], + realtime_triggers: ['microsoft:machinelearningservices:workspaces:write', 'microsoft:machinelearningservices:workspaces:delete', 'microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + + var machineLearningWorkspaces = helpers.addSource(cache, source, + ['machineLearning', 'listWorkspaces', location]); + + if (!machineLearningWorkspaces) return rcb(); + + if (machineLearningWorkspaces.err || !machineLearningWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning workspaces: ' + helpers.addError(machineLearningWorkspaces), location); + return rcb(); + } + + if (!machineLearningWorkspaces.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning workspaces found', location); + return rcb(); + } + + for (let workspace of machineLearningWorkspaces.data) { + if (!workspace.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByMachineLearningWorkspce', location, workspace.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query Machine Learning workspace diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, workspace.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + if (found) { + helpers.addResult(results, 0, 'Machine Learning workspace has diagnostic logs enabled', location, workspace.id); + + } else { + helpers.addResult(results, 2, 'Machine Learning workspace does not have diagnostic logs enabled' , location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/machinelearning/workspaceLoggingEnabled.spec.js b/plugins/azure/machinelearning/workspaceLoggingEnabled.spec.js new file mode 100644 index 000000000..0254ee121 --- /dev/null +++ b/plugins/azure/machinelearning/workspaceLoggingEnabled.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var workspaceLoggingEnabled = require('./workspaceLoggingEnabled'); + +const workspaces = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "identity": { + "type": "SystemAssigned" + } + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "identity": { + "type": "None" + } + }, +]; +const diagnosticSettings = [ + { + id: 'subscriptions/12424/resourceGroups/tets-rg/providers/Microsoft.MachineLearningServices/workspaces/test1/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'openai-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (workspaces, ds) => { + const id = workspaces && workspaces.length ? workspaces[0].id : null; + return { + machineLearning: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + }, + diagnosticSettings: { + listByMachineLearningWorkspce: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('workspaceLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if no Machine Learning workspace found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning workspaces found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [],null + ); + + workspaceLoggingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if diagnostic logs is enabled forMachine Learning workspace', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning workspace has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [workspaces[0]], [diagnosticSettings[0]] + ); + + workspaceLoggingEnabled.run(cache, {}, callback); + }); + + it('should give failing result if diagnostic logs is not enabled for Machine Learning workspace', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning workspace does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [workspaces[0]], [] + ); + + workspaceLoggingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Machine Learning workspaces', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning workspaces: '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache(null, ['error']); + + workspaceLoggingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Machine Learning workspace diagnostic settings: '); + expect(results[0].region).to.equal('eastus'); + done(); + }; + const cache = createCache([workspaces[0]], null); + workspaceLoggingEnabled.run(cache, {}, callback); + + }); + }); +}); diff --git a/plugins/azure/machinelearning/workspacePublicAccessDisabled.js b/plugins/azure/machinelearning/workspacePublicAccessDisabled.js new file mode 100644 index 000000000..81ddbbb61 --- /dev/null +++ b/plugins/azure/machinelearning/workspacePublicAccessDisabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Machine Learning Workspace Public Access Disabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensures that Azure Machine Learning workspaces are not publicly accessible.', + more_info: 'Disabling public network access for Azure Machine Learning workspaces helps ensure so that your workspaces are not accessible over the public internet. This helps protect the workspaces against data leakage risks. Limit exposure of your resources by creating private endpoints instead.', + recommended_action: 'Ensure that Azure Machine Learning workspaces have public network access disabled.', + link: 'https://learn.microsoft.com/en-us/azure/machine-learning/how-to-secure-workspace-vnet', + apis: ['machineLearning:listWorkspaces'], + realtime_triggers: ['microsoft:machinelearningservices:workspaces:write', 'microsoft:machinelearningservices:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.machineLearning, function(location, rcb) { + var machineLearningWorkspaces = helpers.addSource(cache, source, + ['machineLearning', 'listWorkspaces', location]); + + if (!machineLearningWorkspaces) return rcb(); + + if (machineLearningWorkspaces.err || !machineLearningWorkspaces.data) { + helpers.addResult(results, 3, + 'Unable to query for Machine Learning workspaces: ' + helpers.addError(machineLearningWorkspaces), location); + return rcb(); + } + + if (!machineLearningWorkspaces.data.length) { + helpers.addResult(results, 0, 'No existing Machine Learning workspaces found', location); + return rcb(); + } + + for (let workspace of machineLearningWorkspaces.data) { + if (!workspace.id) continue; + + if (workspace.publicNetworkAccess && workspace.publicNetworkAccess.toLowerCase()=='disabled') { + helpers.addResult(results, 0, + 'Machine Learning workspace has public network access disabled', location, workspace.id); + } else { + helpers.addResult(results, 2, + 'Machine Learning workspace has public network access enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/machinelearning/workspacePublicAccessDisabled.spec.js b/plugins/azure/machinelearning/workspacePublicAccessDisabled.spec.js new file mode 100644 index 000000000..1108bff0d --- /dev/null +++ b/plugins/azure/machinelearning/workspacePublicAccessDisabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var workspacePublicAccessDisabled = require('./workspacePublicAccessDisabled'); + +const workspaces = [ + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "identity": { + "type": "SystemAssigned" + }, + "tags": { + "test": "test" + }, + "publicNetworkAccess" : "Disabled" + + }, + { + "id": "/subscriptions/12345667/resourceGroups/test/providers/Microsoft.MachineLearningServices/workspaces/test1", + "name": "test", + "type": "Microsoft.MachineLearningServices/workspaces", + "publicNetworkAccess" : "Enabled" + }, + +]; + +const createCache = (workspaces) => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + } + }; +}; + +const createErrorCache = () => { + return { + machineLearning: { + listWorkspaces: { + 'eastus': {} + } + } + }; +}; + +describe('workspacePublicAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no Machine Learning workspace found', function(done) { + const cache = createCache([]); + workspacePublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Machine Learning workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Machine Learning workspaces', function(done) { + const cache = createErrorCache(); + workspacePublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Machine Learning workspaces: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Machine Learning workspace has public access disabled', function(done) { + const cache = createCache([workspaces[0]]); + workspacePublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Machine Learning workspace has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Machine Learning workspace does not have public access disabled', function(done) { + const cache = createCache([workspaces[1]]); + workspacePublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Machine Learning workspace has public network access enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsClassicApiDisabled.js b/plugins/azure/mediaServices/amsClassicApiDisabled.js new file mode 100644 index 000000000..626af7ea7 --- /dev/null +++ b/plugins/azure/mediaServices/amsClassicApiDisabled.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Classic API Disabled', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Microsoft Azure Media Services do not have the Classic API enabled.', + more_info: 'Disabling the Classic API for Azure Media Services is recommended to utilize modern APIs and features. Enabling classic features can enable the use of classic V2 APIs but might disable advanced security features like managed identities.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/migrate-v-2-v-3-differences-api-access', + recommended_action: 'Remove Azure Media Services accounts with Classic API enabled and create new accounts without enabling the Classic API.', + apis: ['mediaServices:listAll', 'mediaServices:get'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + var getMediaService = helpers.addSource(cache, source, + ['mediaServices', 'get', location, mediaService.id]); + + if (!getMediaService || getMediaService.err || !getMediaService.data) { + helpers.addResult(results, 3, `Unable to query for Media Service data: ${helpers.addError(getMediaService)}`, + location, mediaService.id); + continue; + } + + if (getMediaService.data.identity) { + helpers.addResult(results, 0, 'Media Service account is not using classic v2 APIs', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service account is using classic v2 APIs', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsClassicApiDisabled.spec.js b/plugins/azure/mediaServices/amsClassicApiDisabled.spec.js new file mode 100644 index 000000000..bb5a2c120 --- /dev/null +++ b/plugins/azure/mediaServices/amsClassicApiDisabled.spec.js @@ -0,0 +1,120 @@ +var expect = require('chai').expect; +var amsClassicApiDisabled = require('./amsClassicApiDisabled'); + +const mediaServices = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + } +]; + +const getMediaService = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + }, + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12', + "identity": { + "type": "UserAssigned", + } + } +]; + +const createCache = (ams, ds) => { + const id = (ams && ams.length) ? ams[0].id : null; + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + }, + get: { + 'eastus': { + [id]: { + data: ds + } + } + } + }, + }; +}; + +describe('amsClassicApiDisabled', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([], null); + amsClassicApiDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null, null); + amsClassicApiDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get media service', function(done) { + const cache = createCache([mediaServices[0]], null); + amsClassicApiDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Service data'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if classic API is not enabled', function(done) { + const cache = createCache([mediaServices[0]], getMediaService[1]); + amsClassicApiDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service account is not using classic v2 APIs'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if classic API enabled', function(done) { + const cache = createCache([mediaServices[0]], getMediaService[0]); + amsClassicApiDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service account is using classic v2 APIs'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsContentKeyPolicy.js b/plugins/azure/mediaServices/amsContentKeyPolicy.js new file mode 100644 index 000000000..42c55658c --- /dev/null +++ b/plugins/azure/mediaServices/amsContentKeyPolicy.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Content Key Policy', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure that Azure Media Services have Content Key Policy configured.', + more_info: 'A Content Key Policy in Azure Media Services dictates how content keys, ensuring secure asset access, are delivered to end clients. It allows setting requirements or restrictions that keys with specific configurations must meet before being delivered to clients.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/drm-content-key-policy-concept', + recommended_action: 'Modify Media Service account and add content key policy.', + apis: ['mediaServices:listAll', 'mediaServices:listContentKeyPolicies'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete','microsoftmedia:mediaservices:contentkeypolicies:write','microsoftmedia:mediaservices:contentkeypolicies:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + var listContentKeyPolicies = helpers.addSource(cache, source, + ['mediaServices', 'listContentKeyPolicies', location, mediaService.id]); + + if (!listContentKeyPolicies || listContentKeyPolicies.err || !listContentKeyPolicies.data) { + helpers.addResult(results, 3, `Unable to query Content Key Policy for Media service account: ${helpers.addError(listContentKeyPolicies)}`, + location, mediaService.id); + continue; + } + if (listContentKeyPolicies.data.length) { + helpers.addResult(results, 0, 'Media Service account has content key policy configured', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service account does not have content key policy configured', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsContentKeyPolicy.spec.js b/plugins/azure/mediaServices/amsContentKeyPolicy.spec.js new file mode 100644 index 000000000..76e5f3c4f --- /dev/null +++ b/plugins/azure/mediaServices/amsContentKeyPolicy.spec.js @@ -0,0 +1,108 @@ +var expect = require('chai').expect; +var amsContentKeyPolicy = require('./amsContentKeyPolicy'); + +const mediaServices = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + } +]; + +const listContentKeyPolicies = [ + { + "name": "PolicyWithClearKeyOptionAndTokenRestriction", + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test/contentKeyPolicies/PolicyWithClearKeyOptionAndTokenRestriction", + "type": "Microsoft.Media/mediaservices/contentKeyPolicies", + "properties": { + "policyId": "8352435b-ebea-4681-aae7-e19277771f64", + "created": "2017-12-01T00:00:00Z", + "lastModified": "2017-11-01T00:00:00Z", + "description": "A policy with one ClearKey option and Open Restriction." + } + } +]; + +const createCache = (ams, cp) => { + const id = (ams && ams.length) ? ams[0].id : null; + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + }, + listContentKeyPolicies: { + 'eastus': { + [id]: { + data: cp + } + } + } + }, + }; +}; + +describe('amsContentKeyPolicy', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([], null); + amsContentKeyPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null, null); + amsContentKeyPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get media service', function(done) { + const cache = createCache([mediaServices[0]], null); + amsContentKeyPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Content Key Policy for Media service account:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if content key policy exist', function(done) { + const cache = createCache([mediaServices[0]], [listContentKeyPolicies[1]]); + amsContentKeyPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service account has content key policy configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if content key policy not exist', function(done) { + const cache = createCache([mediaServices[0]], []); + amsContentKeyPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service account does not have content key policy configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.js b/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.js new file mode 100644 index 000000000..f69ef4ca5 --- /dev/null +++ b/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Diagnostic Logs Enabled', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Media Services have diagnostic logs enabled.', + more_info: 'Diagnostic logs provide valuable insights into the operation and health of Media Services. By enabling diagnostic logs, you can gather diagnostic data that could be useful to create notification alerts.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/monitoring/monitor-media-services', + recommended_action: 'Modify Media Service settings and enable diagnostic logs.', + apis: ['mediaServices:listAll', 'diagnosticSettings:listByMediaService'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByMediaService', location, mediaService.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Media Service diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, mediaService.id); + continue; + } + + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Media Service has diagnostic logs enabled', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service does not have diagnostic logs enabled', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.spec.js b/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.spec.js new file mode 100644 index 000000000..16b0f0182 --- /dev/null +++ b/plugins/azure/mediaServices/amsDiagnosticLogsEnabled.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +var amsDiagnosticLogsEnabled = require('./amsDiagnosticLogsEnabled'); + +const mediaServices = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/microsoft.media/mediaservices/test/providers/microsoft.insights/diagnosticSettings/test2', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test2', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + category: null, + categoryGroup: 'audit', + enabled: false, + retentionPolicy: { enabled: false, days: 0 } + }, + { + category: null, + categoryGroup: 'allLogs', + enabled: true, + retentionPolicy: { enabled: false, days: 0 } + } + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/microsoft.media/mediaservices/test/providers/microsoft.insights/diagnosticSettings/test2', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test2', + location: 'eastus', + kind: null, + tags: null, + identity: null, + metrics: [], + logs: [], + logAnalyticsDestinationType: null + }, +]; + +const createCache = (ams, ds) => { + const id = (ams && ams.length) ? ams[0].id : null; + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + } + }, + diagnosticSettings: { + listByMediaService: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('amsDiagnosticLogsEnabled', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([], null); + amsDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null, null); + amsDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([mediaServices[0]], null); + amsDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Service diagnostic settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([mediaServices[0]], [diagnosticSettings[0]]); + amsDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([mediaServices[0]], [diagnosticSettings[1]]); + amsDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/mediaServices/amsManagedIdentityEnabled.js b/plugins/azure/mediaServices/amsManagedIdentityEnabled.js new file mode 100644 index 000000000..8babd67ed --- /dev/null +++ b/plugins/azure/mediaServices/amsManagedIdentityEnabled.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Managed Identity Enabled', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Media Service accounts have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/concept-managed-identities', + recommended_action: 'Create a new Media service account with managed identity for storage account enabled.', + apis: ['mediaServices:listAll', 'mediaServices:get'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + var getMediaService = helpers.addSource(cache, source, + ['mediaServices', 'get', location, mediaService.id]); + + if (!getMediaService || getMediaService.err || !getMediaService.data) { + helpers.addResult(results, 3, `Unable to query for Media Service data: ${helpers.addError(getMediaService)}`, + location, mediaService.id); + continue; + } + + if (getMediaService.data.identity && getMediaService.data.identity.type + && (getMediaService.data.identity.type.toLowerCase() === 'userassigned' || + getMediaService.data.identity.type.toLowerCase() === 'systemassigned')) { + + helpers.addResult(results, 0, 'Media Service account has managed identity enabled', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service account does not have managed identity enabled', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsManagedIdentityEnabled.spec.js b/plugins/azure/mediaServices/amsManagedIdentityEnabled.spec.js new file mode 100644 index 000000000..b7bba260b --- /dev/null +++ b/plugins/azure/mediaServices/amsManagedIdentityEnabled.spec.js @@ -0,0 +1,120 @@ +var expect = require('chai').expect; +var amsManagedIdentityEnabled = require('./amsManagedIdentityEnabled'); + +const mediaServices = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + } +]; + +const getMediaService = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + }, + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12', + "identity": { + "type": "UserAssigned", + } + } +]; + +const createCache = (ams, ds) => { + const id = (ams && ams.length) ? ams[0].id : null; + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + }, + get: { + 'eastus': { + [id]: { + data: ds + } + } + } + }, + }; +}; + +describe('amsManagedIdentityEnabled', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([], null); + amsManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null, null); + amsManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get media service', function(done) { + const cache = createCache([mediaServices[0]], null); + amsManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if managed identity is not enabled', function(done) { + const cache = createCache([mediaServices[0]], getMediaService[0]); + amsManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service account does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if classic API enabled', function(done) { + const cache = createCache([mediaServices[0]], getMediaService[1]); + amsManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service account has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsPublicAccessDisabled.js b/plugins/azure/mediaServices/amsPublicAccessDisabled.js new file mode 100644 index 000000000..344cce9cf --- /dev/null +++ b/plugins/azure/mediaServices/amsPublicAccessDisabled.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Public Access Disabled', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Media Services have public access disabled.', + more_info: 'Disabling public network access improves security by ensuring that Media Services resources are not exposed on the public internet.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/security-azure-policy#azure-policies-private-endpoints-and-media-services', + recommended_action: 'Modify Media Service network settings and enable private access.', + apis: ['mediaServices:listAll'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + if (mediaService.publicNetworkAccess && mediaService.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'Media Service has public access disabled', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service does not have public access disabled', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsPublicAccessDisabled.spec.js b/plugins/azure/mediaServices/amsPublicAccessDisabled.spec.js new file mode 100644 index 000000000..04ae0e2ca --- /dev/null +++ b/plugins/azure/mediaServices/amsPublicAccessDisabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var amsPublicAccessDisabled = require('./amsPublicAccessDisabled'); + +const mediaServices = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + }, + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Disabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12' + } +]; + +const createCache = (ams, ds) => { + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + } + } + }; +}; + +describe('amsPublicAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([]); + amsPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null); + amsPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public access disabled', function(done) { + const cache = createCache([mediaServices[1]]); + amsPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service has public access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public access enabled', function(done) { + const cache = createCache([mediaServices[0]]); + amsPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service does not have public access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsStorageAccountIdentity.js b/plugins/azure/mediaServices/amsStorageAccountIdentity.js new file mode 100644 index 000000000..55fb51aff --- /dev/null +++ b/plugins/azure/mediaServices/amsStorageAccountIdentity.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Media Services Storage Account Managed Identity', + category: 'Media Services', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensures that Azure Media Service accounts have managed identity enabled for Storage Account authentication.', + more_info: 'Enabling managed identity for storage authentication allows secure access to Azure Storage without explicit credentials, enhancing security and simplifying access management for Azure Media Services.', + link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/concept-managed-identities#media-services-managed-identity-scenarios', + recommended_action: 'Modify Media Service storage account settings and enable managed identity.', + apis: ['mediaServices:listAll'], + realtime_triggers: ['microsoftmedia:mediaservices:write', 'microsoftmedia:mediaservices:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.mediaServices, function(location, rcb){ + var mediaServices = helpers.addSource(cache, source, + ['mediaServices', 'listAll', location]); + + if (!mediaServices) return rcb(); + + if (mediaServices.err || !mediaServices.data) { + helpers.addResult(results, 3, 'Unable to query for Media Services: ' + helpers.addError(mediaServices), location); + return rcb(); + } + + if (!mediaServices.data.length) { + helpers.addResult(results, 0, 'No existing Media Services found', location); + return rcb(); + } + + for (let mediaService of mediaServices.data) { + if (!mediaService.id) continue; + + if (mediaService.storageAuthentication && mediaService.storageAuthentication.toLowerCase() === 'managedidentity') { + helpers.addResult(results, 0, 'Media Service account has managed identity enabled for storage account authentication', location, mediaService.id); + } else { + helpers.addResult(results, 2, 'Media Service account has managed identity disabled for storage account authentication', location, mediaService.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mediaServices/amsStorageAccountIdentity.spec.js b/plugins/azure/mediaServices/amsStorageAccountIdentity.spec.js new file mode 100644 index 000000000..797ad5bd8 --- /dev/null +++ b/plugins/azure/mediaServices/amsStorageAccountIdentity.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var amsStorageAccountIdentity = require('./amsStorageAccountIdentity'); + +const mediaServices = [ + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Enabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12', + "storageAuthentication": "system" + }, + { + "name": 'test', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Media/mediaservices/test', + "type": 'Microsoft.Media/mediaservices', + "location": 'eastus', + "publicNetworkAccess": 'Disabled', + "provisioningState": 'Succeeded', + "privateEndpointConnections": [], + "minimumTlsVersion": 'Tls12', + "storageAuthentication": "ManagedIdentity" + } +]; + +const createCache = (ams, ds) => { + return { + mediaServices: { + listAll: { + 'eastus': { + data: ams + } + } + } + }; +}; + +describe('amsStorageAccountIdentity', function() { + describe('run', function() { + it('should give passing result if no media services found', function(done) { + const cache = createCache([]); + amsStorageAccountIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Media Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for media services', function(done) { + const cache = createCache(null); + amsStorageAccountIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Media Services:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if storage account managed identity enabled for authentication', function(done) { + const cache = createCache([mediaServices[1]]); + amsStorageAccountIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Media Service account has managed identity enabled for storage account authentication'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if system authentication enabled', function(done) { + const cache = createCache([mediaServices[0]]); + amsStorageAccountIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Media Service account has managed identity disabled for storage account authentication'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/monitor/diagnosticsCapturedCategories.js b/plugins/azure/monitor/diagnosticsCapturedCategories.js new file mode 100644 index 000000000..c807273e4 --- /dev/null +++ b/plugins/azure/monitor/diagnosticsCapturedCategories.js @@ -0,0 +1,67 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Diagnostics Captured Categories', + category: 'Monitor', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Diagnostics Settings is configured to log activities for all appropriate categories.', + more_info: 'Monitor diagnostic setting in Azure controls how the diagnostic logs are exported. When a diagnostic setting is created, ' + + 'by default no log categories are selected. Capturing the appropriate log categories (Administrative, Security, Alert, and Policy) ' + + 'for the activities performed within your Azure subscriptions provides proper alerting.', + recommended_action: 'Ensure the categories Administrative, Alert, Policy, and Security are set to Enabled for all diagnostic settings.', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/samples/resource-manager-diagnostic-settings', + apis: ['diagnosticSettingsOperations:list'], + realtime_triggers: ['microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + const appropriateCategories = ['Administrative', 'Alert', 'Policy', 'Security']; + + async.each(locations.diagnosticSettingsOperations, (location, rcb) => { + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettingsOperations', 'list', location]); + + if (!diagnosticSettings) return rcb(); + + if (diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query for Diagnostic Settings : ' + helpers.addError(diagnosticSettings), location); + return rcb(); + } + + if (!diagnosticSettings.data.length) { + helpers.addResult(results, 0, 'No existing Diagnostic Settings found', location); + return rcb(); + } + + diagnosticSettings.data.forEach(settings => { + let enabledCategories = []; + + if (settings.logs && settings.logs.length) { + settings.logs.forEach(log => { + if (log.enabled && appropriateCategories.indexOf(log.category) > -1) { + if (enabledCategories.indexOf(log.category) === -1) { + enabledCategories.push(log.category); + } + } + }); + + if (appropriateCategories.length == enabledCategories.length) { + helpers.addResult(results, 0, 'Diagnostic Setting is configured to log required categories', location, settings.id); + } else { + helpers.addResult(results, 2, 'Diagnostic Setting is not configured to log required categories', location, settings.id); + } + } else { + helpers.addResult(results, 2, 'Diagnostic Setting does not have any logs configured', location, settings.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/monitor/diagnosticsCapturedCategories.spec.js b/plugins/azure/monitor/diagnosticsCapturedCategories.spec.js new file mode 100644 index 000000000..8fc0b2da8 --- /dev/null +++ b/plugins/azure/monitor/diagnosticsCapturedCategories.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var diagnosticsCapturedCategories = require('./diagnosticsCapturedCategories'); + +const diagnosticSettings = [ + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': true + } + ] + }, + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': false + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': false + } + ] + } +]; + +const createCache = (diagnosticSettings) => { + let settings = {}; + if (diagnosticSettings) { + settings['data'] = diagnosticSettings; + } + return { + diagnosticSettingsOperations: { + list: { + 'global': settings + } + } + }; +}; + +describe('diagnosticsCapturedCategories', function() { + describe('run', function() { + it('should give passing result if no diagnostic settings found', function(done) { + const cache = createCache([]); + diagnosticsCapturedCategories.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Diagnostic Settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache(); + diagnosticsCapturedCategories.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Diagnostic Settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if logs are enabled for all appropriate categories', function(done) { + const cache = createCache([diagnosticSettings[0]]); + diagnosticsCapturedCategories.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Diagnostic Setting is configured to log required categories'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if logs are not enabled for all appropriate categories', function(done) { + const cache = createCache([diagnosticSettings[1]]); + diagnosticsCapturedCategories.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Diagnostic Setting is not configured to log required categories'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/monitor/diagnosticsSettingsEnabled.js b/plugins/azure/monitor/diagnosticsSettingsEnabled.js new file mode 100644 index 000000000..185dd08fc --- /dev/null +++ b/plugins/azure/monitor/diagnosticsSettingsEnabled.js @@ -0,0 +1,43 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Diagnostics Settings Enabled', + category: 'Monitor', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Diagnostics Settings exist and are exporting activity logs.', + more_info: 'Diagnostic setting should be configured for all appropriate resources for your environment in order to log the interactions within your cloud resources and gain insight into the operations that were performed within that resource itself.', + recommended_action: 'Ensure that a Diagnostic status is enabled for all supported resources in Diagnostics Settings under Monitor.', + link: 'https://learn.microsoft.com/en-us/azure/monitoring-and-diagnostics/monitoring-overview-activity-logs#export-the-activity-log-with-a-log-profile', + apis: ['diagnosticSettingsOperations:list'], + realtime_triggers: ['microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.diagnosticSettingsOperations, (location, rcb) => { + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettingsOperations', 'list', location]); + + if (!diagnosticSettings) return rcb(); + + if (diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query for Diagnostic Settings : ' + helpers.addError(diagnosticSettings), location); + return rcb(); + } + + if (!diagnosticSettings.data.length) { + helpers.addResult(results, 2, 'No existing Diagnostic Settings found', location); + } else { + helpers.addResult(results, 0, 'Diagnostic Settings exist', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/monitor/diagnosticsSettingsEnabled.spec.js b/plugins/azure/monitor/diagnosticsSettingsEnabled.spec.js new file mode 100644 index 000000000..472dda4d9 --- /dev/null +++ b/plugins/azure/monitor/diagnosticsSettingsEnabled.spec.js @@ -0,0 +1,180 @@ +var expect = require('chai').expect; +var diagnosticsSettingsEnabled = require('./diagnosticsSettingsEnabled'); + +const diagnosticSettings = [ + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': true + } + ] + }, + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': false + } + ] + }, + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'eventHubAuthorizationRuleId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.EventHub/namespaces/test-setting/authorizationrules/RootManageSharedAccessKey', + 'eventHubName': '', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': true + } + ] + } +]; + +const createCache = (diagnosticSettings) => { + let settings = {}; + if (diagnosticSettings) { + settings['data'] = diagnosticSettings; + } + return { + diagnosticSettingsOperations: { + list: { + 'global': settings + } + } + }; +}; + +describe('diagnosticsSettingsEnabled', function() { + describe('run', function() { + it('should give failing result if no diagnostic settings found', function(done) { + const cache = createCache([]); + diagnosticsSettingsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing Diagnostic Settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if diagnostic settings found', function(done) { + const cache = createCache(diagnosticSettings); + diagnosticsSettingsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Diagnostic Settings exist'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache(); + diagnosticsSettingsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Diagnostic Settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/monitor/logAnalyticsWorkspacePublic.js b/plugins/azure/monitor/logAnalyticsWorkspacePublic.js new file mode 100644 index 000000000..1bb80d2d8 --- /dev/null +++ b/plugins/azure/monitor/logAnalyticsWorkspacePublic.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Log Analytics Public Workspace', + category: 'Monitor', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures Log Analytics Workspace is not publicly accessible.', + more_info: 'Securing Log Analytics workspaces through private links, and disallowing public access, enhances data protection, access control, and overall security by restricting entry to authorized networks and minimizing potential external threats.', + recommended_action: 'Configure Log Analytics workspaces with private links and deny access from public networks.' , + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/logs/private-link-configure#configure-access-to-your-resources', + apis: ['logAnalytics:listWorkspaces'], + realtime_triggers: ['microsoftoperationalinsights:workspaces:write', 'microsoftoperationalinsights:workspaces::delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.logAnalytics, (location, rcb) => { + const logAnalytics = helpers.addSource(cache, source, + ['logAnalytics', 'listWorkspaces', location]); + + if (!logAnalytics) return rcb(); + + if (logAnalytics.err || !logAnalytics.data) { + helpers.addResult(results, 3, + 'Unable to query for Log Analytics Workspaces: ' + helpers.addError(logAnalytics), location); + return rcb(); + } + + if (!logAnalytics.data.length) { + helpers.addResult(results, 0, 'No existing Log Analytics Workspaces found', location); + return rcb(); + } + + logAnalytics.data.forEach(function(workspace){ + + if ((workspace.publicNetworkAccessForIngestion && workspace.publicNetworkAccessForIngestion.toLowerCase() === 'enabled') || (workspace.publicNetworkAccessForQuery && workspace.publicNetworkAccessForQuery.toLowerCase() === 'enabled')) { + helpers.addResult(results, 2, + 'Log Analytics Workspace is publicly accessible', location, workspace.id); + } else { + helpers.addResult(results, 0, + 'Log Analytics Workspace is not publicly accessible', location, workspace.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/monitor/logAnalyticsWorkspacePublic.spec.js b/plugins/azure/monitor/logAnalyticsWorkspacePublic.spec.js new file mode 100644 index 000000000..ace5db755 --- /dev/null +++ b/plugins/azure/monitor/logAnalyticsWorkspacePublic.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var logAnalyticsWorkspacePublic = require('./logAnalyticsWorkspacePublic'); + +const workSpaces = [ + { + "id": "/subscriptions/01234567/resourcegroups/6685/providers/microsoft.operationalinsights/workspaces/test2", + "name": "test2", + "type": "Microsoft.OperationalInsights/workspaces", + "location": "eastus", + "publicNetworkAccessForQuery": "Enabled", + "publicNetworkAccessForIngestion": "Enabled" + }, + { + "id": "/subscriptions/01234567/resourcegroups/6685/providers/microsoft.operationalinsights/workspaces/test", + "name": "test", + "type": "Microsoft.OperationalInsights/workspaces", + "location": "eastus", + "publicNetworkAccessForQuery": "Disabled", + "publicNetworkAccessForIngestion": "Disabled" + } +]; + +const createCache = (logAnalytics) => { + let workspace = {}; + if (logAnalytics) { + workspace['data'] = logAnalytics; + } + return { + logAnalytics: { + listWorkspaces: { + 'eastus': workspace + } + } + }; +}; + +describe('logAnalyticsWorkspacePublic', function() { + describe('run', function() { + it('should give passing result if No existing Log Analytics Workspaces found', function(done) { + const cache = createCache([]); + logAnalyticsWorkspacePublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Log Analytics Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Log Analytics Workspaces', function(done) { + const cache = createCache(); + logAnalyticsWorkspacePublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Log Analytics Workspaces: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Log Analytics Workspace is not Public', function(done) { + const cache = createCache([workSpaces[1]]); + logAnalyticsWorkspacePublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Log Analytics Workspace is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Log Analytics Workspace is Public', function(done) { + const cache = createCache([workSpaces[0]]); + logAnalyticsWorkspacePublic.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log Analytics Workspace is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/monitor/logProfileArchiveData.js b/plugins/azure/monitor/logProfileArchiveData.js index 46dc7a44d..35d5dd789 100644 --- a/plugins/azure/monitor/logProfileArchiveData.js +++ b/plugins/azure/monitor/logProfileArchiveData.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Profile Archive Data', category: 'Monitor', + domain: 'Management and Governance', + severity: 'Low', description: 'Ensures the Log Profile is configured to export all activities from the control and management planes in all active locations', more_info: 'Exporting log activity for control plane activity allows for audited access to the Azure account with event data in the case of a security incident.', recommended_action: 'Ensure that all activity is logged to the Event Hub or storage account for archiving.' , - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/archive-activity-log', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/archive-activity-log', apis: ['logProfiles:list'], compliance: { hipaa: 'HIPAA has clearly defined audit requirements for environments ' + diff --git a/plugins/azure/monitor/logProfileArchiveData.spec.js b/plugins/azure/monitor/logProfileArchiveData.spec.js new file mode 100644 index 000000000..e3cc5158a --- /dev/null +++ b/plugins/azure/monitor/logProfileArchiveData.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +var logProfileArchiveData = require('./logProfileArchiveData'); + +const logProfile = [ + { + "id": "/subscriptions/1234/providers/microsoft.insights/logprofiles/test", + "type": null, + "name": "default", + "location": null, + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/1234/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.Storage/storageAccounts/devstoragetwo", + "serviceBusRuleId": null, + "locations": [ + "australiacentral", + "australiacentral2", + "australiaeast", + "australiasoutheast", + "brazilsouth", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "eastasia", + "eastus", + "eastus2", + "francecentral", + "francesouth", + "japaneast", + "japanwest", + "koreacentral", + "koreasouth", + "northcentralus", + "northeurope", + "southafricanorth", + "southafricawest", + "southcentralus", + "southindia", + "southeastasia", + "uaecentral", + "uaenorth", + "uksouth", + "ukwest", + "westcentralus", + "westeurope", + "westindia", + "westus", + "westus2", + "westus3", + "eastus2euap", + "centraluseuap", + "jioindiawest", + "jioindiacentral", + "swedencentral", + "germanywestcentral", + "germanycentral", + "germanynortheast", + "germanynorth", + "norwayeast", + "switzerlandnorth", + "norwaywest", + "switzerlandwest", + "brazilsoutheast", + "global", + "qatarcentral", + "polandcentral", + "italynorth", + "israelcentral" + ], + "categories": [ + "Write", + "Delete", + "Action" + ], + "retentionPolicy": { + "enabled": true, + "days": 82 + } + }, + { + "id": "/subscriptions/1234/providers/microsoft.insights/logprofiles/default", + "type": null, + "name": "default", + "location": null, + "kind": null, + "tags": null, + "identity": null, + "storageAccountId": "/subscriptions/1234/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.Storage/storageAccounts/devstoragetwo", + "serviceBusRuleId": null, + "retentionPolicy": { + "enabled": true, + "days": 82 + } + } +]; + +const createCache = (logProfile) => { + let settings = {}; + if (logProfile) { + settings['data'] = logProfile; + } + return { + logProfiles: { + list: { + 'global': settings + } + } + }; +}; + +describe('logProfileArchiveData', function() { + describe('run', function() { + it('should give passing result if No existing Log Profiles found', function(done) { + const cache = createCache([]); + logProfileArchiveData.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing Log Profiles found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Log Profiles', function(done) { + const cache = createCache(); + logProfileArchiveData.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Log Profiles'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if Log Profile is archiving all activities in all regions', function(done) { + const cache = createCache([logProfile[0]]); + logProfileArchiveData.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Log Profile is archiving all activities in all regions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if Log Profile has the following issues', function(done) { + const cache = createCache([logProfile[1]]); + logProfileArchiveData.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log Profile has the following issues'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/monitor/logProfileRetentionPolicy.js b/plugins/azure/monitor/logProfileRetentionPolicy.js deleted file mode 100644 index 25803d302..000000000 --- a/plugins/azure/monitor/logProfileRetentionPolicy.js +++ /dev/null @@ -1,66 +0,0 @@ -var async = require('async'); -var helpers = require('../../../helpers/azure'); - -module.exports = { - title: 'Log Profile Retention Policy', - category: 'Monitor', - description: 'Ensures that Log Profiles have a long retention policy.', - more_info: 'Log retention policies should be configured with sufficient retention to aid in investigation of prior security incidents and for compliance purposes.', - recommended_action: 'Ensure that the Activity Log export to Event Hub is configured with a retention policy of at least 365 days.', - link: 'https://docs.microsoft.com/en-us/azure/monitoring-and-diagnostics/monitoring-overview-activity-logs#export-the-activity-log-with-a-log-profile', - apis: ['logProfiles:list'], - compliance: { - pci: 'PCI requires log profile retention history to be' + - ' a minimum of 365 days.', - hipaa: 'HIPAA requires log profile data to be archived ' + - 'for a minimum of 365 days.' - }, - - run: function(cache, settings, callback) { - var results = []; - var source = {}; - var locations = helpers.locations(settings.govcloud); - - async.each(locations.logProfiles, (location, rcb) => { - const logProfiles = helpers.addSource(cache, source, - ['logProfiles', 'list', location]); - - if (!logProfiles) return rcb(); - - if (logProfiles.err || !logProfiles.data) { - helpers.addResult(results, 3, - 'Unable to query for Log Profiles: ' + helpers.addError(logProfiles), location); - return rcb(); - } - - if (!logProfiles.data.length) { - helpers.addResult(results, 2, 'No existing Log Profiles found', location); - return rcb(); - } - - logProfiles.data.forEach(function(logProfile) { - if (!logProfile.retentionPolicy) { - helpers.addResult(results, 2, - 'The Log Profile does not have a retention policy', - location, logProfile.id); - } else if (!logProfile.retentionPolicy.enabled) { - helpers.addResult(results, 2, - 'The Log Profile retention policy is not enabled', - location, logProfile.id); - } else if (!logProfile.retentionPolicy.days || logProfile.retentionPolicy.days < 365) { - helpers.addResult(results, 2, - `The Log Profile retention policy of ${logProfile.retentionPolicy.days || '0'} days is not sufficient (at least 365 days).`, - location, logProfile.id); - } else { - helpers.addResult(results, 0, - `The Log Profile retention policy of ${logProfile.retentionPolicy.days} days is sufficient.`, - location, logProfile.id); - } - }); - - rcb(); - }, function() { - callback(null, results, source); - }); - } -}; \ No newline at end of file diff --git a/plugins/azure/monitor/monitorLogsEnabled.js b/plugins/azure/monitor/monitorLogsEnabled.js new file mode 100644 index 000000000..59c738486 --- /dev/null +++ b/plugins/azure/monitor/monitorLogsEnabled.js @@ -0,0 +1,55 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Azure Monitor Logs Enabled', + category: 'Monitor', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Azure Monitor Logs are enabled for all logging categories and being archived in a Storage Account.', + more_info: 'Azure Monitor Logs is a feature of Azure Monitor that collects and organizes log and performance data from monitored resources and helps in identifying issues in resources performance.', + recommended_action: 'Enabled Azure Monitor Logs for all logging categories and archive in a Storage Account', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-platform-logs', + apis: ['diagnosticSettingsOperations:list'], + realtime_triggers: ['microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.diagnosticSettingsOperations, (location, rcb) => { + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettingsOperations', 'list', location]); + + if (!diagnosticSettings) return rcb(); + + if (diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query for Diagnostic Settings : ' + helpers.addError(diagnosticSettings), location); + return rcb(); + } + + if (!diagnosticSettings.data.length) { + helpers.addResult(results, 2, 'No existing Diagnostic Settings found', location); + return rcb(); + } + + diagnosticSettings.data.forEach(settings => { + if (settings.logs && settings.logs.length) { + let disabledLog = settings.logs.find(log => !log.enabled); + if (disabledLog) { + helpers.addResult(results, 2, 'Diagnostic Setting does not have Azure Monitor Logs enabled for all the logging categories', location, settings.id); + } else if (settings.storageAccountId && settings.storageAccountId.length) { + helpers.addResult(results, 0, 'Diagnostic Setting has Azure Monitor Logs enabled for all the logging categories and Storage Account configured', location, settings.id); + } else { + helpers.addResult(results, 2, 'Diagnostic Setting does not have a Storage Account configured for Azure Monitor Logs', location, settings.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/monitor/monitorLogsEnabled.spec.js b/plugins/azure/monitor/monitorLogsEnabled.spec.js new file mode 100644 index 000000000..ef6e9bf24 --- /dev/null +++ b/plugins/azure/monitor/monitorLogsEnabled.spec.js @@ -0,0 +1,202 @@ +var expect = require('chai').expect; +var monitorLogsEnabled = require('./monitorLogsEnabled'); + +const diagnosticSettings = [ + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': true + } + ] + }, + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': false + } + ] + }, + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test-setting', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test-setting', + 'location': 'global', + 'eventHubAuthorizationRuleId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.EventHub/namespaces/test-setting/authorizationrules/RootManageSharedAccessKey', + 'eventHubName': '', + 'logs': [ + { + 'category': 'Administrative', + 'enabled': true + }, + { + 'category': 'Security', + 'enabled': true + }, + { + 'category': 'ServiceHealth', + 'enabled': true + }, + { + 'category': 'Alert', + 'enabled': true + }, + { + 'category': 'Recommendation', + 'enabled': true + }, + { + 'category': 'Policy', + 'enabled': true + }, + { + 'category': 'Autoscale', + 'enabled': true + }, + { + 'category': 'ResourceHealth', + 'enabled': true + } + ] + } +]; + +const createCache = (diagnosticSettings) => { + let settings = {}; + if (diagnosticSettings) { + settings['data'] = diagnosticSettings; + } + return { + diagnosticSettingsOperations: { + list: { + 'global': settings + } + } + }; +}; + +describe('monitorLogsEnabled', function() { + describe('run', function() { + it('should give failing result if no diagnostic settings found', function(done) { + const cache = createCache([]); + monitorLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing Diagnostic Settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache(); + monitorLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Diagnostic Settings'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if logs are enabled for all logging categories and storage account configured', function(done) { + const cache = createCache([diagnosticSettings[0]]); + monitorLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Diagnostic Setting has Azure Monitor Logs enabled for all the logging categories and Storage Account configured'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if logs are not enabled for all logging categories and storage account configured', function(done) { + const cache = createCache([diagnosticSettings[1]]); + monitorLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Diagnostic Setting does not have Azure Monitor Logs enabled for all the logging categories'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if logs are enabled for all logging categories and storage account is not configured', function(done) { + const cache = createCache([diagnosticSettings[2]]); + monitorLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Diagnostic Setting does not have a Storage Account configured for Azure Monitor Logs'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/monitor/monitorResourceSku.js b/plugins/azure/monitor/monitorResourceSku.js new file mode 100644 index 000000000..7cbf7fe89 --- /dev/null +++ b/plugins/azure/monitor/monitorResourceSku.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Monitor Resource SKU', + category: 'Resources', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that basic or consumption SKUs are not used on artifacts that need to be monitored.', + more_info: 'Azure Monitor provides monitoring capabilities for resources and applications in Azure. Basic and Consumption SKUs provide limited monitoring capabilities compared to higher SKUs.', + link: 'https://learn.microsoft.com/en-us/azure/search/search-sku-tier', + recommended_action: 'Use a higher SKU for the resource to enable full monitoring capabilities.', + apis: ['resources:list'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.resources, function(location, rcb) { + const resources = helpers.addSource(cache, source, + ['resources', 'list', location]); + + if (!resources) return rcb(); + + if (resources.err || !resources.data) { + helpers.addResult(results, 3, + 'Unable to query for Resources: ' + helpers.addError(resources), location); + return rcb(); + } + + if (!resources.data.length){ + helpers.addResult(results, 0, 'No existing Resources found', location); + return rcb(); + } + + for (let resource of resources.data) { + if (!resource.id || !resource.sku) continue; + + if (resource.sku && resource.sku.name && (resource.sku.name.toLowerCase() === 'basic' ||resource.sku.name.toLowerCase() === 'consumption')){ + helpers.addResult(results, 2, `Resource is using ${resource.sku.name} SKU`, location, resource.id); + } else { + helpers.addResult(results, 0, `Resource is using ${resource.sku.name} SKU`, location, resource.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/monitor/monitorResourceSku.spec.js b/plugins/azure/monitor/monitorResourceSku.spec.js new file mode 100644 index 000000000..5f1fffe5d --- /dev/null +++ b/plugins/azure/monitor/monitorResourceSku.spec.js @@ -0,0 +1,80 @@ +var expect = require('chai').expect; +var resourceAppropriateSKU = require('./monitorResourceSku'); + +const resources = [ + { + id: '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + name: 'test', + type: 'Microsoft.Storage/storageAccounts', + sku: { name: 'Standard_LRS', tier: 'Standard' }, + kind: 'StorageV2', + location: 'eastus', + }, + { + id: '/subscriptions/123/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/test-storage-account', + name: 'test', + type: 'Microsoft.Storage/storageAccounts', + sku: { name: 'BASIC', tier: 'BASIC' }, + kind: 'StorageV2', + location: 'eastus', + }, +]; + +const createCache = (resource) => { + + return { + resources: { + list: { + 'eastus': { data:resource } + } + } + }; +}; + +describe('resourceAppropriateSKU', function() { + describe('run', function() { + it('should give passing result if no resource found', function(done) { + const cache = createCache([]); + resourceAppropriateSKU.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Resources found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for resource', function(done) { + const cache = createCache(null); + resourceAppropriateSKU.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Resources:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if not using basic/consumption SKU', function(done) { + const cache = createCache([resources[0]]); + resourceAppropriateSKU.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Resource is using Standard_LRS'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if using basic/consumption SKU', function(done) { + const cache = createCache([resources[1]]); + resourceAppropriateSKU.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Resource is using BASIC'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/mysqlserver/enforceMySQLSSLConnection.js b/plugins/azure/mysqlserver/enforceMySQLSSLConnection.js index 61dd9aea4..86df8c77c 100644 --- a/plugins/azure/mysqlserver/enforceMySQLSSLConnection.js +++ b/plugins/azure/mysqlserver/enforceMySQLSSLConnection.js @@ -4,11 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Enforce MySQL SSL Connection', category: 'MySQL Server', + domain: 'Databases', + severity: 'High', description: 'Ensures SSL connection is enforced on MySQL servers', more_info: 'MySQL servers should be set to use SSL for data transmission to ensure all data is encrypted in transit.', recommended_action: 'Ensure the connection security of each Azure Database for MySQL is configured to enforce SSL connections.', - link: 'https://docs.microsoft.com/en-us/azure/mysql/concepts-ssl-connection-security', + link: 'https://learn.microsoft.com/en-us/azure/mysql/concepts-ssl-connection-security', apis: ['servers:listMysql'], + remediation_min_version: '202103302200', + remediation_description: 'The SSL enforcement option will be enabled for the affected MySQL servers', + apis_remediate: ['servers:listMysql'], + actions: {remediate:['servers:update'], rollback:['servers:update']}, + permissions: {remediate: ['servers:update'], rollback: ['server:update']}, + realtime_triggers: ['microsoftdbformysql:servers:write'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + 'MySQL SSL connection should be used to ensure internal ' + @@ -54,5 +62,49 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'enforceMySQLSSLConnection'; + var baseUrl = 'https://management.azure.com{resource}?api-version=2017-12-01'; + var method = 'PATCH'; + + // for logging purposes + var serverNameArr = resource.split('/'); + var serverName = serverNameArr[serverNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'properties': { + 'sslEnforcement': 'Enabled' + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'SSLEnforcement': 'Disabled', + 'Server': serverName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } -}; +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/enforceMySQLSSLConnection.spec.js b/plugins/azure/mysqlserver/enforceMySQLSSLConnection.spec.js new file mode 100644 index 000000000..96cb06f05 --- /dev/null +++ b/plugins/azure/mysqlserver/enforceMySQLSSLConnection.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var server = require('./enforceMySQLSSLConnection'); + +const servers = [ + { + "sku": [Object], + "location": 'eastus', + "tags": {}, + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/prov"id"ers/Microsoft.DBforMySQL/servers/test-server', + "name": 'test-server', + "type": 'Microsoft.DBforMySQL/servers', + "sslEnforcement": 'Enabled', + "userVisibleState": 'Ready', + "fullyQualifiedDomainName": 'test-server.mysql.database.azure.com', + "earliestRestoreDate": '2021-03-14T17:53:38.68+00:00', + "infrastructureEncryption": 'Disabled', + "publicNetworkAccess": 'Enabled' + }, + { + "sku": [Object], + "location": 'eastus', + "tags": {}, + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforMySQL/servers/test-server', + "name": 'test-server', + "type": 'Microsoft.DBforMySQL/servers', + "sslEnforcement": 'Disabled', + "userVisibleState": 'Ready', + "fullyQualifiedDomainName": 'test-server.mysql.database.azure.com', + "earliestRestoreDate": '2021-03-14T17:53:38.68+00:00', + "infrastructureEncryption": 'Disabled', + "publicNetworkAccess": 'Enabled' + } +]; + +const createCache = (server) => { + return { + servers: { + listMysql: { + 'eastus': { + data: server + } + } + } + }; +}; + +const createErrorCache = () => { + return { + servers: { + listMysql: { + 'eastus': {} + } + } + }; +}; + +describe('enforceMySQLSSLConnection', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache([]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if MySQL Server does not enforce SSL connection', function(done) { + const cache = createCache([servers[1]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The MySQL server does not enforce SSL connections'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for MySQL servers', function(done) { + const cache = createErrorCache(); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if MySQL Server enforces SSL connection', function(done) { + const cache = createCache([servers[0]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The MySQL server enforces SSL connections'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.js b/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.js new file mode 100644 index 000000000..d188ae81d --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Data CMK Encrypted', + category: 'MySQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that MySQL flexible servers data is encrypted using CMK.', + more_info: 'MySQL flexible server allows you to encrypt data using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault. It adds an extra layer of protection against unauthorized access to sensitive data stored in the database.', + recommended_action: 'Ensure that MySQL flexible server have CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-customer-managed-key', + apis: ['servers:listMysqlFlexibleServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + if (!flexibleServer.id) continue; + + if (flexibleServer.dataEncryption && flexibleServer.dataEncryption.primaryKeyURI) { + helpers.addResult(results, 0, 'MySQL flexible server data is encrypted using CMK', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'MySQL flexible server data is not encrypted using CMK', location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.spec.js new file mode 100644 index 000000000..17341ed4e --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerCMKEncrypted.spec.js @@ -0,0 +1,103 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./mysqlFlexibleServerCMKEncrypted'); + +const createCache = (err, list) => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('mysqlFlexibleServerManagedIdentity', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should FAIL if MySQL server is not CMK encrypted', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server data is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": '5.8' + } + ], + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if MySQL server is CMK encrypted', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server data is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": "8.0", + "dataEncryption": { + "primaryKeyURI" : "https://test.vault.azure.net/keys/test2/9e0e3453676456e" + } + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should UNKNOWN if unable to query for server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, null + ); + + auth.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.js b/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.js new file mode 100644 index 000000000..16f911b14 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Diagnostic Logs', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that MySQL flexible server has diagnostic logs enabled.', + more_info: 'Enabling diagnostic logging for Azure Database for MySQL Flexible servers helps with performance monitoring, troubleshooting, and security optimization.', + recommended_action: 'Enable diagnostic logging for all MySQL flexible servers.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-monitoring', + apis: ['servers:listMysqlFlexibleServer', 'diagnosticSettings:listByMysqlFlexibleServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (let server of servers.data) { + if (!server.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByMysqlFlexibleServer', location, server.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for MySQL flexible server diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, server.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'MySQL flexible server has diagnostic logs enabled', location, server.id); + } else { + helpers.addResult(results, 2, 'MySQL flexible server does not have diagnostic logs enabled', location, server.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.spec.js new file mode 100644 index 000000000..c1e8dbc73 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerDignosticLogs.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var auth = require('./mysqlFlexibleServerDignosticLogs'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + }, +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.DBforPostgreSQL/servers/test/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'server-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (servers, ds) => { + const id = servers && servers.length ? servers[0].id : null; + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + data: servers + } + } + }, + diagnosticSettings: { + listByMysqlFlexibleServer: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('mysqlFlexibleServerLogsEnabled', function() { + describe('run', function() { + it('should give a passing result if no existing server found', function (done) { + const cache = createCache([], null); + auth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for server', function (done) { + const cache = createCache(null, ['error']); + auth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([servers[0]], null); + auth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible server diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([servers[0]], [diagnosticSettings[0]]); + auth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([servers[0]], [[]]); + auth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); + diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.js b/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.js new file mode 100644 index 000000000..efe9ac442 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Has Tags', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Azure MySQL flexible servers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify MySQL flexible server and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['servers:listMysql', 'servers:listMysqlFlexibleServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete', 'microsoftresources:tags:write'], + + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + if (!flexibleServer.id) continue; + + if (flexibleServer.tags && Object.entries(flexibleServer.tags).length > 0){ + helpers.addResult(results, 0, 'MySQL flexible server has tags associated', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'MySQL flexible server does not have tags associated', location, flexibleServer.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + }, +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.spec.js new file mode 100644 index 000000000..63fe4a997 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerHasTags.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var server = require('./mysqlFlexibleServerHasTags'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "name": 'test-server', + "tags": {"key": "value"}, + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "name": 'test-server', + "tags": {}, + } +]; + +const createCache = (server) => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + data: server + } + } + } + }; +}; + +const createErrorCache = () => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': {} + } + } + }; +}; + +describe('mysqlServerHasTags', function() { + describe('run', function() { + it('should give passing result if no servers found', function(done) { + const cache = createCache([]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if MySQL Server does not have tags', function(done) { + const cache = createCache([servers[1]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for MySQL servers', function(done) { + const cache = createErrorCache(); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if MySQL Server has tags', function(done) { + const cache = createCache([servers[0]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js new file mode 100644 index 000000000..731acafe6 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Managed Identity', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that MySQL flexible servers have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify MySQL flexible server add managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-azure-ad', + apis: ['servers:listMysqlFlexibleServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + if (!flexibleServer.id) return; + + if (flexibleServer.identity) { + helpers.addResult(results, 0, 'MySQL flexible server has managed identity enabled', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'MySQL flexible server does not have managed identity enabled', location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.spec.js new file mode 100644 index 000000000..fb6a5adf2 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.spec.js @@ -0,0 +1,103 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./mysqlFlexibleServerIdentity'); + +const createCache = (err, list) => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('mysqlFlexibleServerManagedIdentity', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should FAIL if MySQL server does not have managed identity', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": '5.8' + } + ], + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if MySQL server is using latest version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": "8.0", + "identity": { + "type": "userassigned" + } + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should UNKNOWN if unable to query for server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, null + ); + + auth.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js new file mode 100644 index 000000000..68d5f4d83 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js @@ -0,0 +1,113 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Public Access', + category: 'MySQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that MySQL flexible servers do not allow public access', + more_info: 'Configuring public access for MySQL flexible server instance allows the server to be accessible through public endpoint. MySQL flexible server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each MySQL flexible server is configured to prohibit traffic from the public.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-networking-public', + apis: ['servers:listMysqlFlexibleServer', 'firewallRules:listByFlexibleServerMysql'], + settings: { + mysql_flexible_server_allowed_ips: { + name: 'MySQL Flexible Server Allowed IPs', + description: 'Comma-separated list of customer defined IP addresses/ranges that are allowed to access MySQL flexible servers.', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbformysql:flexibleservers:write', 'microsoftdbformysql:flexibleservers:firewallrules:write', 'microsoftdbformysql:flexibleservers:firewallrules:delete', 'microsoftdbformysql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + mysql_flexible_server_allowed_ips: settings.mysql_flexible_server_allowed_ips || this.settings.mysql_flexible_server_allowed_ips.default + }; + + var allowedIps = []; + if (config.mysql_flexible_server_allowed_ips && config.mysql_flexible_server_allowed_ips.length > 0) { + allowedIps = config.mysql_flexible_server_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + if (!server.id) return; + + + if (server.network && + server.network.publicNetworkAccess && + server.network.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'MySQL Flexible Server is protected from outside traffic', location, server.id); + } else { + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByFlexibleServerMysql', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query MySQL Flexible Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing MySQL Flexible Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { + if (checkAllowedIps) { + if (endIpAddr && allowedIps.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else { + if (endIpAddr && endIpAddr.toString() === '255.255.255.255') { + publicAccess = true; + } else if (endIpAddr && endIpAddr.toString() === '0.0.0.0') { + publicAccess = true; + } + } + } + }); + + + if (publicAccess) { + helpers.addResult(results, 2, 'The MySQL flexible server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The MySQL flexible server is protected from outside traffic', location, server.id); + } + } + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js new file mode 100644 index 000000000..1345564c3 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js @@ -0,0 +1,226 @@ +var expect = require('chai').expect; +var mysqlFlexibleServerPublicAccess = require('./mysqlFlexibleServerPublicAccess'); + +const listMysqlFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-2", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Enabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-3", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-4", + "type": "Microsoft.DBforMySQL/flexibleServers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowedIP", + "name": "AllowedIP", + "startIpAddress": "192.168.1.1", + "endIpAddress": "192.168.1.1" + } +]; + +const createCache = (servers, rules, serversErr, rulesErr) => { + const cache = { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + data: servers || [], + err: serversErr || null + } + } + }, + firewallRules: { + listByFlexibleServerMysql: { + 'eastus': {} + } + } + }; + + if (servers && servers.length > 0) { + servers.forEach(server => { + if (server && server.id) { + cache.firewallRules.listByFlexibleServerMysql.eastus[server.id] = { + data: rules || [], + err: rulesErr || null + }; + } + }); + } + + return cache; +}; + +describe('mysqlFlexibleServerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if no existing SQL Flexible Server Firewall Rules found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL Flexible Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [] + + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if SQL Server has private network access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL Flexible Server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[0]], + [] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if SQL Server is open to outside traffic', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The MySQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[0]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if SQL Server firewall does not allow public access', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The MySQL flexible server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[2]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if The SQL server is protected from outside traffic', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The MySQL flexible server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[2]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + { message: 'unable to query servers'} + + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for server firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query MySQL Flexible Server Firewall Rules'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [], + null, + { message: 'Unable to query for server firewall rules'} + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.js b/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.js new file mode 100644 index 000000000..749ec2d83 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.js @@ -0,0 +1,56 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Version', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that MySQL flexible servers are using the latest server version.', + more_info: 'Using the latest version of Upgrade the version of MySQL flexible server to the latest available version will give access to new software features, resolve reported bugs through security patches, and improve compatibility with other applications and services.', + recommended_action: 'Ensure MySQL Flexible Servers are using the latest server version.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-upgrade', + apis: ['servers:listMysqlFlexibleServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + const latestServerVersion = 8.0; + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + if (!flexibleServer.id) continue; + + if (flexibleServer.version && parseFloat(flexibleServer.version) >= latestServerVersion) { + helpers.addResult(results, 0, + `MySQL flexible server has latest server version: ${flexibleServer.version}`, location, flexibleServer.id); + } else { + helpers.addResult(results, 2, + `MySQL flexible server does not have latest server version: ${latestServerVersion}`, location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.spec.js new file mode 100644 index 000000000..9ac967507 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerVersion.spec.js @@ -0,0 +1,100 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./mysqlFlexibleServerVersion'); + +const createCache = (err, list) => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('mysqlFlexibleServerVersion', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should FAIL if MySQL server is not using latest version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server does not have latest server version: 8'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": '5.8' + } + ], + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if MySQL server is using latest version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server has latest server version: 8'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "version": "8.0" + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should UNKNOWN if unable to query for server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, null + ); + + auth.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.js b/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.js new file mode 100644 index 000000000..6b75dce72 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.js @@ -0,0 +1,66 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Flexible Server Minimum TLS Version', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure TLS version on MySQL flexible servers is set to the default value.', + more_info: 'TLS connectivity helps to provide a new layer of security by connecting database server to client applications using Transport Layer Security (TLS). Enforcing TLS connections between database server and client applications helps protect against "man in the middle" attacks by encrypting the data stream between the server and application.', + recommended_action: 'Modify MySQL flexible server tls_version parameter and set to desired minimum TLS version.', + link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-connect-tls-ssl', + apis: ['servers:listMysqlFlexibleServer', 'flexibleServersConfigurations:listByServer'], + realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:configurations:write','microsoftdbformysql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listMysqlFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + const configurations = helpers.addSource(cache, source, + ['flexibleServersConfigurations', 'listByServer', location, flexibleServer.id]); + + if (!configurations || configurations.err || !configurations.data) { + helpers.addResult(results, 3, + 'Unable to query for ' + helpers.addError(configurations), location, flexibleServer.id); + continue; + } + + var configuration = configurations.data.filter(config => { + return (config.name == 'tls_version'); + }); + + var tls_versions = configuration && configuration[0] && configuration[0].value ? configuration[0].value.toUpperCase().split(','): ''; + + if (tls_versions.includes('TLSV1') || tls_versions.includes('TLSV1.1')) { + helpers.addResult(results, 2, 'MySQL flexible server is not using latest TLS version', location, flexibleServer.id); + } else { + helpers.addResult(results, 0, 'MySQL flexible server is using latest TLS version', location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.spec.js new file mode 100644 index 000000000..869986b90 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlFlexibleServersMinTls.spec.js @@ -0,0 +1,148 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./mysqlFlexibleServersMinTls'); + +const createCache = (err, list, configuration) => { + return { + servers: { + listMysqlFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + }, + flexibleServersConfigurations: { + listByServer: { + 'eastus': configuration + } + } + } +}; + +describe('mysqlFlexibleServersMinTls', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should FAIL if MySQL server is not using TLSV1.2', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL flexible server is not using latest TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers" + } + ], + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server": { + data: [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server/configurations/tls_version", + "value": "TLSV1", + "description": "Which protocols the server permits for encrypted connections. By default, TLS 1.2 is enforced", + "defaultValue": "TLSv1.2", + "dataType": "Set", + "allowedValues": "TLSv1,TLSv1.1,TLSv1.2", + "source": "user-override", + "isConfigPendingRestart": "False", + "isDynamicConfig": "False", + "isReadOnly": "False", + "name": "tls_version" + } + ] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if MySQL server is using TLSV1.2', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL flexible server is using latest TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers" + } + ], + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server": { + data: [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server/configurations/tls_version", + "value": "TLSV1.2", + "description": "Which protocols the server permits for encrypted connections. By default, TLS 1.2 is enforced", + "defaultValue": "TLSv1.2", + "dataType": "Set", + "allowedValues": "TLSv1,TLSv1.1,TLSv1.2", + "source": "user-override", + "isConfigPendingRestart": "False", + "isDynamicConfig": "False", + "isReadOnly": "False", + "name": "tls_version" + } + ] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should UNKNOWN if unable to query for configurations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers" + } + ], + {} + ); + + auth.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlServerHasTags.js b/plugins/azure/mysqlserver/mysqlServerHasTags.js new file mode 100644 index 000000000..f661da49c --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlServerHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'MySQL Server Has Tags', + category: 'MySQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Azure MySQL servers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify MySQL server and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['servers:listMysql'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listMysql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for MySQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing MySQL servers found', location); + return rcb(); + } + + for (let sqlserver of servers.data) { + if (!sqlserver.id) continue; + + if (sqlserver.tags && Object.entries(sqlserver.tags).length > 0){ + helpers.addResult(results, 0, 'MySQL server has tags associated', location, sqlserver.id); + } else { + helpers.addResult(results, 2, 'MySQL server does not have tags associated', location, sqlserver.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + }, +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlServerHasTags.spec.js b/plugins/azure/mysqlserver/mysqlServerHasTags.spec.js new file mode 100644 index 000000000..382fa44d2 --- /dev/null +++ b/plugins/azure/mysqlserver/mysqlServerHasTags.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var server = require('./mysqlServerHasTags'); + +const servers = [ + { + "sku": [Object], + "location": 'eastus', + "tags": {"key": "value"}, + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/prov"id"ers/Microsoft.DBforMySQL/servers/test-server', + "name": 'test-server', + "type": 'Microsoft.DBforMySQL/servers', + "sslEnforcement": 'Enabled', + "userVisibleState": 'Ready', + "fullyQualifiedDomainName": 'test-server.mysql.database.azure.com', + "earliestRestoreDate": '2021-03-14T17:53:38.68+00:00', + "infrastructureEncryption": 'Disabled', + "publicNetworkAccess": 'Enabled' + }, + { + "sku": [Object], + "location": 'eastus', + "tags": {}, + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforMySQL/servers/test-server', + "name": 'test-server', + "type": 'Microsoft.DBforMySQL/servers', + "sslEnforcement": 'Disabled', + "userVisibleState": 'Ready', + "fullyQualifiedDomainName": 'test-server.mysql.database.azure.com', + "earliestRestoreDate": '2021-03-14T17:53:38.68+00:00', + "infrastructureEncryption": 'Disabled', + "publicNetworkAccess": 'Enabled' + } +]; + +const createCache = (server) => { + return { + servers: { + listMysql: { + 'eastus': { + data: server + } + } + } + }; +}; + +const createErrorCache = () => { + return { + servers: { + listMysql: { + 'eastus': {} + } + } + }; +}; + +describe('mysqlServerHasTags', function() { + describe('run', function() { + it('should give passing result if no servers found', function(done) { + const cache = createCache([]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing MySQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if MySQL Server does not have tags', function(done) { + const cache = createCache([servers[1]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('MySQL server does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for MySQL servers', function(done) { + const cache = createErrorCache(); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for MySQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if MySQL Server has tags', function(done) { + const cache = createCache([servers[0]]); + server.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('MySQL server has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/defaultSecurityGroup.js b/plugins/azure/networksecuritygroups/defaultSecurityGroup.js index 816712b1c..66374a7e7 100644 --- a/plugins/azure/networksecuritygroups/defaultSecurityGroup.js +++ b/plugins/azure/networksecuritygroups/defaultSecurityGroup.js @@ -4,9 +4,11 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Default Security Group', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Low', description: 'Ensures that default security groups block all traffic by default', more_info: 'The default security group is often used for resources launched without a defined security group. For this reason, the default rules should be set to block all traffic to prevent an accidental exposure.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Update the rules for the default security group to deny all traffic by default', apis: ['networkSecurityGroups:listAll'], compliance: { @@ -15,6 +17,7 @@ module.exports = { 'to isolate resources. Ensure default security groups to not allow ' + 'unintended traffic to cross these isolation boundaries.' }, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete'], run: function(cache, settings, callback) { const results = []; @@ -30,7 +33,7 @@ module.exports = { if (!networkSecurityGroups) return rcb(); if (networkSecurityGroups.err || !networkSecurityGroups.data) { - helpers.addResult(results, 3, + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); return rcb(); } @@ -96,4 +99,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/azure/networksecuritygroups/defaultSecurityGroup.spec.js b/plugins/azure/networksecuritygroups/defaultSecurityGroup.spec.js new file mode 100644 index 000000000..2b9b710b7 --- /dev/null +++ b/plugins/azure/networksecuritygroups/defaultSecurityGroup.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +var defaultSecurityGroup = require('./defaultSecurityGroup'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [], + "defaultSecurityRules": [{ + "name": "DenyAllInBound", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/defaultSecurityRules/DenyAllInBound", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/defaultSecurityRules", + "properties": { + "provisioningState": "Succeeded", + "description": "Deny all inbound traffic", + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Deny", + "priority": 65500, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + { + "name": "DenyAllOutBound", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/defaultSecurityRules/DenyAllOutBound", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/defaultSecurityRules", + "properties": { + "provisioningState": "Succeeded", + "description": "Deny all outbound traffic", + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Deny", + "priority": 65500, + "direction": "Outbound", + "sourcePortRanges": [], + "destinationPortRanges": [], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + } + ], + "subnets": [] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-7dd8-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [{ + "name": "DenyAllOutBound", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg/defaultSecurityRules/DenyAllOutBound", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups/defaultSecurityRules", + "properties": { + "provisioningState": "Succeeded", + "description": "Deny all outbound traffic", + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Deny", + "priority": 65500, + "direction": "Outbound", + } + }], + "networkInterfaces": [{ + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + }] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-7dd8-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [], + "networkInterfaces": [{ + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + }] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('defaultSecurityGroup', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the Network Security Group has all required default inbound and outbound rules', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has all required default inbound and outbound rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Network Security Group does not have required default inbound and outbound rules', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have required default inbound and outbound rules:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Network Security Group is missing one or more default inbound or outbound rules', function(done) { + const cache = createCache([networkSecurityGroups[2]]); + defaultSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is missing one or more default inbound or outbound rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/excessiveSecurityGroups.js b/plugins/azure/networksecuritygroups/excessiveSecurityGroups.js index 81598833f..8f1c5fc53 100644 --- a/plugins/azure/networksecuritygroups/excessiveSecurityGroups.js +++ b/plugins/azure/networksecuritygroups/excessiveSecurityGroups.js @@ -4,9 +4,11 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Excessive Security Groups', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: '', description: 'Determines if there are an excessive number of security groups in the account', more_info: 'Keeping the number of security groups to a minimum helps reduce the attack surface of an account. Rather than creating new groups with the same rules for each project, common rules should be grouped under the same security groups. For example, instead of adding port 22 from a known IP to every group, create a single "SSH" security group which can be used on multiple instances.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Limit the number of security groups to prevent accidental authorizations.', apis: ['networkSecurityGroups:listAll'], settings: { @@ -23,6 +25,7 @@ module.exports = { default: 30 } }, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/azure/networksecuritygroups/excessiveSecurityGroups.spec.js b/plugins/azure/networksecuritygroups/excessiveSecurityGroups.spec.js new file mode 100644 index 000000000..c7995641d --- /dev/null +++ b/plugins/azure/networksecuritygroups/excessiveSecurityGroups.spec.js @@ -0,0 +1,134 @@ +var expect = require('chai').expect; +var excessiveSecurityGroups = require('./excessiveSecurityGroups'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('excessiveSecurityGroups', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + excessiveSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + excessiveSecurityGroups.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the count of Network Security Group is Acceptable', function(done) { + const cache = createCache(networkSecurityGroups); + excessiveSecurityGroups.run(cache, { excessive_security_groups_fail: 5, excessive_security_groups_warn: 4 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Acceptable number of security groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if the count of Network Security Group is Excessive', function(done) { + const cache = createCache(networkSecurityGroups); + excessiveSecurityGroups.run(cache, { excessive_security_groups_fail: 2, excessive_security_groups_warn: 1 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Excessive number of security groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give warning result if the count of Network Security Group is Large', function(done) { + const cache = createCache(networkSecurityGroups); + excessiveSecurityGroups.run(cache, { excessive_security_groups_fail: 3, excessive_security_groups_warn: 2 }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('Large number of security groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.js b/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.js new file mode 100644 index 000000000..6cd487ad2 --- /dev/null +++ b/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'NSG Flow Logs Enabled', + category: 'Network Security Groups', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Azure Network Security Groups have flows log enabled.', + more_info: 'Enabling NSG flow logs is crucial for maintaining a secure and optimized cloud environment. It provides the necessary visibility, monitoring capabilities, and data for optimizing resources by ensuring compliance, detecting intrusions, and responding effectively to network-related incidents.', + recommended_action: 'Enable flow logs for each Network Security Group.', + link: 'https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftinsights:extendeddiagnosticsettings:write','microsoftinsights:extendeddiagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + const networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No existing Network Security Groups found', location); + return rcb(); + } + + networkSecurityGroups.data.forEach(function(nsg) { + + if (nsg.flowLogs && nsg.flowLogs.length) { + helpers.addResult(results, 0, 'Network Security Group has flow logs enabled', location, nsg.id); + } else { + helpers.addResult(results, 2, 'Network Security Group does not have flow logs enabled', location, nsg.id); + } + + }); + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.spec.js b/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.spec.js new file mode 100644 index 000000000..60623db49 --- /dev/null +++ b/plugins/azure/networksecuritygroups/nsgFlowLogsEnabled.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var nsgFlowLogsEnabled = require('./nsgFlowLogsEnabled'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/1234567/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [], + "flowLogs":[ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus/flowLogs/test-flowlog', + "name": 'aadds-nsg"', + + } + ], + "defaultSecurityRules": [{ + "name": "DenyAllInBound", + "id": "/subscriptions/dce9d1sa-ebf6-437f-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/defaultSecurityRules/DenyAllInBound", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/defaultSecurityRules", + "properties": { + "provisioningState": "Succeeded", + "description": "Deny all inbound traffic", + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Deny", + "priority": 65500, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "subnets": [] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/1234567/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-7dd8-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [{ + "name": "DenyAllOutBound", + "id": "/subscriptions/1234567/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg/defaultSecurityRules/DenyAllOutBound", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups/defaultSecurityRules", + "properties": { + "provisioningState": "Succeeded", + "description": "Deny all outbound traffic", + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Deny", + "priority": 65500, + "direction": "Outbound", + } + }], + "networkInterfaces": [{ + "id": "/subscriptions/1234567/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + }] + }, + +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('nsgFlowLogsEnabled', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + nsgFlowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Network Security Groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + nsgFlowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the Network Security Group has flow logs enabled', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + nsgFlowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Network Security Group has flow logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Network Security Group does not have flow logs enabled', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + nsgFlowLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Network Security Group does not have flow logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.js b/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.js new file mode 100644 index 000000000..41f815869 --- /dev/null +++ b/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.js @@ -0,0 +1,92 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'NSG Flow Logs Retention Period', + category: 'Network Security Groups', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that Azure Network Security Groups (NSGs) have a sufficient flow log retention period', + more_info: 'A flow log data retention period of 90 days or more, allows you to collect the necessary amount of logging data required to check for anomalies and provide details about any potential security breach.', + recommended_action: 'Modify NSG flow logs and set desired value in days for retention period', + link: 'https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal', + apis: ['networkWatchers:listAll', 'flowLogs:list'], + settings: { + nsg_flowlog_retention_period: { + name: 'NSG Flow Log Retention Period', + default: '90', + description: 'Desired number of days for which NSG flow logs data will be retained.', + regex: '^([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-9][0-9]|3[0-5][0-9]|36[0-5])$' + } + }, + realtime_triggers: ['microsoftinsights:extendeddiagnosticsettings:write','microsoftinsights:extendeddiagnosticsettings:delete','microsoftstorage:storageaccounts:managementpolicies:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + retentionDays: parseInt(settings.nsg_flowlog_retention_period || this.settings.nsg_flowlog_retention_period.default) + }; + + async.each(locations.networkWatchers, function(location, rcb) { + const networkWatchers = helpers.addSource(cache, source, + ['networkWatchers', 'listAll', location]); + + if (!networkWatchers) return rcb(); + + if (networkWatchers.err || !networkWatchers.data) { + helpers.addResult(results, 3, + 'Unable to query for Network Watchers: ' + helpers.addError(networkWatchers), location); + return rcb(); + } + + if (!networkWatchers.data.length) { + helpers.addResult(results, 0, 'No Network Watchers found', location); + return rcb(); + } + + async.each(networkWatchers.data, function(networkWatcher, scb) { + const flowLogs = helpers.addSource(cache, source, + ['flowLogs', 'list', location, networkWatcher.id]); + + if (!flowLogs || flowLogs.err || !flowLogs.data) { + helpers.addResult(results, 3, + 'Unable to query for flow logs data: ' + helpers.addError(networkWatchers), location); + return scb(); + } + + if (!flowLogs.data.length) { + helpers.addResult(results, 0, 'No flow logs data found', location); + return scb(); + } + + for (const flowLog of flowLogs.data) { + if (!flowLog.id) continue; + let retentionDays = 0; + if (flowLog.retentionPolicy && flowLog.retentionPolicy.days) { + retentionDays = flowLog.retentionPolicy.days; + } + + if (retentionDays >= config.retentionDays) { + helpers.addResult(results, 0, + `NSG fLow log has retention period set to ${retentionDays} of ${config.retentionDays} days desired limit`, + location, flowLog.id); + } else { + helpers.addResult(results, 2, + `NSG fLow log has retention period set to ${retentionDays} of ${config.retentionDays} days desired limit`, + location, flowLog.id); + } + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.spec.js b/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.spec.js new file mode 100644 index 000000000..5163628b6 --- /dev/null +++ b/plugins/azure/networksecuritygroups/nsgFlowLogsRetentionPeriod.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +var nsgFlowLogsRetentionPeriod = require('./nsgFlowLogsRetentionPeriod'); + +const networkWatchers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus', + 'name': 'NetworkWatcher_eastus' + } +]; + +const flowLogs = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus/flowLogs/test-flowlog', + 'name': 'test-flowlog', + 'retentionPolicy': { + 'days': 100, + 'enabled': true + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus/flowLogs/test-flowlog', + 'name': 'test-flowlog', + 'retentionPolicy': { + 'days': 45, + 'enabled': true + } + } +]; + + +const createCache = (Watchers, flowLogs) => { + let logs = {}; + if (Watchers.length > 0) { + logs[Watchers[0].id] = { + data: flowLogs + }; + } + + return { + networkWatchers: { + listAll: { + 'eastus': { + data: Watchers + } + } + }, + flowLogs: { + list: { + 'eastus': logs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'watcher') { + return { + networkWatchers: { + listAll: { + 'eastus': {} + } + } + }; + } else { + return { + networkWatchers: { + listAll: { + 'eastus': { + data: [networkWatchers[0]] + } + } + }, + flowLogs: { + list: { + 'eastus': {} + } + } + }; + } +}; + +describe('nsgFlowLogsRetentionPeriod', function() { + describe('run', function() { + it('should give passing result if no network watchers', function(done) { + const cache = createCache([], []); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Network Watchers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no flow logs', function(done) { + const cache = createCache([networkWatchers[0]],[]); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No flow logs data found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for network watchers', function(done) { + const cache = createErrorCache('watcher'); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Watchers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for flow logs', function(done) { + const cache = createErrorCache('flowLog'); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for flow logs data:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if flow logs have desired retention period set', function(done) { + const cache = createCache([networkWatchers[0]], [flowLogs[0]]); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('NSG fLow log has retention period set to 100 of 90 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if flow logs do not have desired retention period set', function(done) { + const cache = createCache([networkWatchers[0]], [flowLogs[1]]); + nsgFlowLogsRetentionPeriod.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('NSG fLow log has retention period set to 45 of 90 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/monitor/nsgLogAnalyticsEnabled.js b/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.js similarity index 88% rename from plugins/azure/monitor/nsgLogAnalyticsEnabled.js rename to plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.js index e44d5bf3f..55390c75a 100644 --- a/plugins/azure/monitor/nsgLogAnalyticsEnabled.js +++ b/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.js @@ -3,11 +3,13 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'NSG Log Analytics Enabled', - category: 'Monitor', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures Network Security Group logs are sent to the Log Analytics workspace', more_info: 'Enabling Log Analytics for Network Security Groups ensures that logs are shipped to a central repository that can be queried and audited.', recommended_action: 'Enable sending of logs to Log Analytics for each Network Security Group resource in the Azure Monitor.', - link: 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs', apis: ['networkSecurityGroups:listAll', 'diagnosticSettings:listByNetworkSecurityGroup'], compliance: { pci: 'PCI requires monitoring and logging of all network traffic. ' + @@ -17,6 +19,7 @@ module.exports = { 'These include malicious attempts to access services within the ' + 'infrastructure.' }, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], run: function(cache, settings, callback) { const results = []; @@ -54,7 +57,6 @@ module.exports = { diagnosticSettings.data.forEach(function(ds) { if (ds.logs && ds.logs.length) found = true; }); - if (found) { helpers.addResult(results, 0, 'NSG Log Analytics is enabled for NSG', location, nsg.id); } else { diff --git a/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.spec.js b/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.spec.js new file mode 100644 index 000000000..1315e5990 --- /dev/null +++ b/plugins/azure/networksecuritygroups/nsgLogAnalyticsEnabled.spec.js @@ -0,0 +1,297 @@ +var expect = require('chai').expect; +var nsgLogAnalyticsEnabled = require('./nsgLogAnalyticsEnabled'); + +const networkSecurityGroups = [ + { + "name": "kubernetes", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "sku": { + "name": "Standard" + }, + "provisioningState": "Succeeded", + "resourceGuid": "e88ed351-f991-4268-94f5-57334c1443af", + "frontendIPConfigurations": [ + { + "name": "3859f556-a02d-42d9-8bd3-42301f41f8be", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/networkSecurityGroups/frontendIPConfigurations", + "properties": { + "provisioningState": "Succeeded", + "privateIPAllocationMethod": "Dynamic", + "publicIPAddress": { + "id": "/subscriptions/1234/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Network/publicIPAddresses/3859f556-a02d-42d9-8bd3-42301f41f8be" + }, + "inboundNatRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/inboundNatRules/jbs" + } + ], + "outboundRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/outboundRules/aksOutboundRule" + } + ] + } + } + ], + "backendAddressPools": [ + { + "name": "aksOutboundBackendPool", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/backendAddressPools/aksOutboundBackendPool", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "properties": { + "provisioningState": "Succeeded", + "outboundRules": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/outboundRules/aksOutboundRule" + } + ], + "backendIPConfigurations": [ + { + "id": "/subscriptions/1234/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool-30757528-vmss/virtualMachines/1/networkInterfaces/aks-agentpool-30757528-vmss/ipConfigurations/ipconfig1" + } + ] + }, + "type": "Microsoft.Network/networkSecurityGroups/backendAddressPools" + }, + { + "name": "kubernetes", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/backendAddressPools/kubernetes", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "properties": { + "provisioningState": "Succeeded", + "backendIPConfigurations": [ + { + "id": "/subscriptions/1234/resourceGroups/MC_Ali-Resource-Group_test-ali_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool-30757528-vmss/virtualMachines/1/networkInterfaces/aks-agentpool-30757528-vmss/ipConfigurations/ipconfig1" + } + ] + }, + "type": "Microsoft.Network/networkSecurityGroups/backendAddressPools" + } + ], + "loadBalancingRules": [], + "probes": [], + "inboundNatRules": [ + { + "name": "jbs", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/inboundNatRules/jbs", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/networkSecurityGroups/inboundNatRules", + "properties": { + "provisioningState": "Succeeded", + "frontendIPConfiguration": { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be" + }, + "frontendPort": 22, + "backendPort": 22, + "enableFloatingIP": false, + "idleTimeoutInMinutes": 4, + "protocol": "Tcp", + "enableDestinationServiceEndpoint": false, + "enableTcpReset": false, + "allowBackendPortConflict": false + } + } + ], + "outboundRules": [ + { + "name": "aksOutboundRule", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/outboundRules/aksOutboundRule", + "etag": "W/\"b44d8556-daee-4b29-b12f-f6b140df6cbd\"", + "type": "Microsoft.Network/networkSecurityGroups/outboundRules", + "properties": { + "provisioningState": "Succeeded", + "allocatedOutboundPorts": 0, + "protocol": "All", + "enableTcpReset": true, + "idleTimeoutInMinutes": 30, + "backendAddressPool": { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/backendAddressPools/aksOutboundBackendPool" + }, + "frontendIPConfigurations": [ + { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting/frontendIPConfigurations/3859f556-a02d-42d9-8bd3-42301f41f8be" + } + ] + } + } + ], + "inboundNatPools": [] + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [], + logs: [ + { + category: 'NetworkSecurityGroupEvent', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + }, + { + category: 'NetworkSecurityGroupRuleCounter', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + } + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/deleteasap/providers/microsoft.network/networksecuritygroups/gio-test-events-1-nsg/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourcegroups/defaultresourcegroup-eus/providers/microsoft.operationalinsights/workspaces/defaultworkspace-dce7d0ad-ebf6-437f-a3b0-28fc0d22117e-eus', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [], + logs: [], + logAnalyticsDestinationType: null + } +]; + + +const createCache = (networkSecurityGroup, diagnosticSetting) => { + let logs = {}; + if (networkSecurityGroup.length > 0) { + logs[networkSecurityGroup[0].id] = { + data: diagnosticSetting + }; + } + + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: networkSecurityGroup + } + } + }, + diagnosticSettings: { + listByNetworkSecurityGroup: { + 'eastus': logs + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'loadbalancer') { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; + } else { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: [networkSecurityGroups[0]] + } + } + }, + diagnosticSettings: { + listByNetworkSecurityGroup: { + 'eastus': {} + } + } + }; + } +}; + +describe('nsgLogAnalyticsEnabled', function() { + describe('run', function() { + it('should give passing result if No existing Network Security Groups found', function(done) { + const cache = createCache([], []); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Network Security Groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give Failing result if No existing diagnostics settings', function(done) { + const cache = createCache([networkSecurityGroups[0]],[]); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing diagnostics settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Network Security Groups', function(done) { + const cache = createErrorCache('loadbalancer'); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function(done) { + const cache = createErrorCache('flowLog'); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query diagnostics settings:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if NSG Log Analytics is enabled for NSG', function(done) { + const cache = createCache([networkSecurityGroups[0]], [diagnosticSettings[0]]); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('NSG Log Analytics is enabled for NSG'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if NSG Log Analytics is not enabled for NSG', function(done) { + const cache = createCache([networkSecurityGroups[0]], [diagnosticSettings[1]]); + nsgLogAnalyticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('NSG Log Analytics is not enabled for NSG'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openAllPorts.js b/plugins/azure/networksecuritygroups/openAllPorts.js index a287f9c6b..0fd16a039 100644 --- a/plugins/azure/networksecuritygroups/openAllPorts.js +++ b/plugins/azure/networksecuritygroups/openAllPorts.js @@ -4,9 +4,11 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open All Ports', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Ensures Network Security Groups do not expose all ports to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, almost all services should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict ports to known IP addresses', apis: ['networkSecurityGroups:listAll'], compliance: { @@ -18,6 +20,7 @@ module.exports = { 'Security groups should be properly secured to prevent access to ' + 'backend services.' }, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -44,13 +47,13 @@ module.exports = { let ports = { 'TCP': ['*'], - 'UCP': ['*'], + 'UDP': ['*'], '*' : ['*'] }; let service = 'All Ports'; - helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results, true); rcb(); }, function() { diff --git a/plugins/azure/networksecuritygroups/openAllPorts.spec.js b/plugins/azure/networksecuritygroups/openAllPorts.spec.js new file mode 100644 index 000000000..6dea9b76b --- /dev/null +++ b/plugins/azure/networksecuritygroups/openAllPorts.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +var openAllPorts = require('./openAllPorts'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5986", + "5987", + "*" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openAllPorts', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the Network Security Group has no public open ports found', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if the Network Security Group has some public open ports', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCIFS.js b/plugins/azure/networksecuritygroups/openCIFS.js index 04c7d4510..899f84fce 100644 --- a/plugins/azure/networksecuritygroups/openCIFS.js +++ b/plugins/azure/networksecuritygroups/openCIFS.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open CIFS', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if UDP port 445 for CIFS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as CIFS should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict UDP port 445 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_inputs: { + openCIFSAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openCIFSAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,22 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openCIFS'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['UDP', '*']; + var ports = [445]; + + var errors = []; + var actions = []; + + // create the params necessary for the remediation + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCIFS.spec.js b/plugins/azure/networksecuritygroups/openCIFS.spec.js new file mode 100644 index 000000000..fbe949f4d --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCIFS.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +var openCIFS = require('./openCIFS'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5986", + "5987", + "445" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openCIFS', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the UDP port 445 for CIFS is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if the UDP port 445 for CIFS is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraClient.js b/plugins/azure/networksecuritygroups/openCassandraClient.js new file mode 100644 index 000000000..ed092c995 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraClient.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Cassandra Client', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP port 9042 for Cassandra Client is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Client should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 9042 to known IP addresses. ', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [9042] + }; + + let service = 'Cassandra Client'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraClient.spec.js b/plugins/azure/networksecuritygroups/openCassandraClient.spec.js new file mode 100644 index 000000000..d9b96a35a --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraClient.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openCassandraClient = require('./openCassandraClient'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowCassandraClient", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowCassandraClient", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowElasticsearch", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowElasticsearch", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "9042" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openCassandraClient', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has cassandra client TCP port 9042 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has cassandra client TCP port 9042 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraInternode.js b/plugins/azure/networksecuritygroups/openCassandraInternode.js new file mode 100644 index 000000000..2ed8cb2f4 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraInternode.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Cassandra Internode', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP port 7000 for Cassandra Internode is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Internode should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 7000 to known IP addresses.', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [7000] + }; + + let service = 'Cassandra Internode'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraInternode.spec.js b/plugins/azure/networksecuritygroups/openCassandraInternode.spec.js new file mode 100644 index 000000000..ce75e8bda --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraInternode.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openCassandraInternode = require('./openCassandraInternode'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowCassandraInternode", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowCassandraInternode", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "7000" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openCassandraInternode', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Cassandra Inter node TCP port 7000 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Inter node TCP port 7000 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraMonitoring.js b/plugins/azure/networksecuritygroups/openCassandraMonitoring.js new file mode 100644 index 000000000..228b3b5b5 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraMonitoring.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Cassandra Monitoring', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP port 7199 for Cassandra Monitoring is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Monitoring should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 7199 to known IP addresses.', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [7199] + }; + + let service = 'Cassandra Monitoring'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraMonitoring.spec.js b/plugins/azure/networksecuritygroups/openCassandraMonitoring.spec.js new file mode 100644 index 000000000..ec72541cc --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraMonitoring.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openCassandraMonitoring = require('./openCassandraMonitoring'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowCassandraMonitoring", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowCassandraMonitoring", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "7199" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openCassandraMonitoring', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Cassandra Monitoring TCP port 7199 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Monitoring TCP port 7199 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraThrift.js b/plugins/azure/networksecuritygroups/openCassandraThrift.js new file mode 100644 index 000000000..239b6c115 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraThrift.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Cassandra Thrift', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 9160 for Cassandra Thrift is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Thrift should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 9160 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [9160] + }; + + let service = 'Cassandra Thrift'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openCassandraThrift.spec.js b/plugins/azure/networksecuritygroups/openCassandraThrift.spec.js new file mode 100644 index 000000000..e9c09267f --- /dev/null +++ b/plugins/azure/networksecuritygroups/openCassandraThrift.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openCassandraThrift = require('./openCassandraThrift'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowCassandraThrift", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowCassandraThrift", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "9160" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openCassandraThrift', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Cassandra Thrift TCP port 9160 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Cassandra Thrift TCP port 9160 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openDNS.js b/plugins/azure/networksecuritygroups/openDNS.js index a6c7684bb..a9f7dbf04 100644 --- a/plugins/azure/networksecuritygroups/openDNS.js +++ b/plugins/azure/networksecuritygroups/openDNS.js @@ -4,16 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open DNS', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP or UDP port 53 for DNS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as DNS should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP and UDP port 53 to known IP addresses', apis: ['networkSecurityGroups:listAll'], remediation_min_version: '202008121825', remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', apis_remediate: ['networkSecurityGroups:listAll'], + remediation_inputs: { + openDNSAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openDNSAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -60,31 +77,11 @@ module.exports = { var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; var method = 'PUT'; var protocols = ['TCP','UDP','*']; - var port = 53; + var ports = [53]; var errors = []; var actions = []; // create the params necessary for the remediation - async.each(protocols,function(protocol, cb) { - helpers.remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, settings, resource, remediation_file, baseUrl, method,function(error, action) { - if (error && (error.length || Object.keys(error).length)) { - errors.push(error); - } else if (action && (action.length || Object.keys(action).length)){ - actions.push(action); - } - - cb(); - }); - }, function() { - if (errors && errors.length) { - remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); - settings.remediation_file = remediation_file; - return callback(errors, null); - } else { - remediation_file['post_remediate']['actions'][pluginName][resource] = actions; - settings.remediation_file = remediation_file; - return callback(null, actions); - } - }); + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openDNS.spec.js b/plugins/azure/networksecuritygroups/openDNS.spec.js new file mode 100644 index 000000000..1d024ef37 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openDNS.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var openDNS = require('./openDNS'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5986", + "5987", + "445", + "53" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openDNS', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the TCP or UDP port 53 for DNS is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if the TCP or UDP port 53 for DNS is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openDocker.js b/plugins/azure/networksecuritygroups/openDocker.js index a8edcf5b9..43e6c8b3a 100644 --- a/plugins/azure/networksecuritygroups/openDocker.js +++ b/plugins/azure/networksecuritygroups/openDocker.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Docker', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if Docker port 2375 or 2376 is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Docker should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP ports 2375 and 2376 to known IP addresses', + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openDockerAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openDockerAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openDocker'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [2375, 2376]; + var actions = []; + var errors = []; + + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openDocker.spec.js b/plugins/azure/networksecuritygroups/openDocker.spec.js new file mode 100644 index 000000000..0c397f836 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openDocker.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +var openDocker = require('./openDocker'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "2375", + "2376" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openDocker', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Docker port 2375 or 2376 is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Docker port 2375 or 2376 is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openElasticsearch.js b/plugins/azure/networksecuritygroups/openElasticsearch.js new file mode 100644 index 000000000..4bedd233a --- /dev/null +++ b/plugins/azure/networksecuritygroups/openElasticsearch.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Elasticsearch', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 9200 or 9300 for Elasticsearch is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Elasticsearch should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 9200 or 9300 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [9200, 9300] + }; + + let service = 'Elasticsearch'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openElasticsearch.spec.js b/plugins/azure/networksecuritygroups/openElasticsearch.spec.js new file mode 100644 index 000000000..fd98d227e --- /dev/null +++ b/plugins/azure/networksecuritygroups/openElasticsearch.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openElasticsearch = require('./openElasticsearch'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowElasticsearch", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowElasticsearch", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "9300" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openElasticsearch', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Elasticsearch TCP port 9200 or 9300 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Elasticsearch TCP port 9200 or 9300 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openFTP.js b/plugins/azure/networksecuritygroups/openFTP.js index e2d147fe6..4e00f3f1d 100644 --- a/plugins/azure/networksecuritygroups/openFTP.js +++ b/plugins/azure/networksecuritygroups/openFTP.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open FTP', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 20 or 21 for FTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as FTP should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 20 or 21 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openFTPAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openFTPAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,21 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openFTP'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [20, 21]; + var actions = []; + var errors = []; + + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openFTP.spec.js b/plugins/azure/networksecuritygroups/openFTP.spec.js new file mode 100644 index 000000000..a264383e7 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openFTP.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openFTP = require('./openFTP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "20", + "21" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openFTP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 20 or 21 for FTP is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 20 or 21 for FTP is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHTTP.js b/plugins/azure/networksecuritygroups/openHTTP.js new file mode 100644 index 000000000..a33cc7108 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHTTP.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open HTTP', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 80 for HTTP is open to the public', + more_info: 'Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required and narrowly configured.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 80 to known IP addresses. ', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [80] + }; + + let service = 'HTTP'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHTTP.spec.js b/plugins/azure/networksecuritygroups/openHTTP.spec.js new file mode 100644 index 000000000..a92b571cf --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHTTP.spec.js @@ -0,0 +1,162 @@ +var expect = require('chai').expect; +var openHTTP = require('./openHTTP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": ["80"], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openHTTP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 80 for HTTPS is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 80 for HTTPS is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHTTPS.js b/plugins/azure/networksecuritygroups/openHTTPS.js new file mode 100644 index 000000000..62eaa64a2 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHTTPS.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open HTTPS', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 443 for HTTPS is open to the public', + more_info: 'Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required and narrowly configured.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 443 to known IP addresses.', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [443] + }; + + let service = 'HTTPS'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHTTPS.spec.js b/plugins/azure/networksecuritygroups/openHTTPS.spec.js new file mode 100644 index 000000000..80d729404 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHTTPS.spec.js @@ -0,0 +1,162 @@ +var expect = require('chai').expect; +var openHTTPS = require('./openHTTPS'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": ["443"], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openHTTPS', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 443 for HTTPS is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 443 for HTTPS is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openHTTPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHadoopNameNode.js b/plugins/azure/networksecuritygroups/openHadoopNameNode.js index e7fa8da82..f998010ba 100644 --- a/plugins/azure/networksecuritygroups/openHadoopNameNode.js +++ b/plugins/azure/networksecuritygroups/openHadoopNameNode.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Hadoop HDFS NameNode Metadata Service', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 8020 for HDFS NameNode metadata service is open to the public.', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 8020 to known IP addresses for Hadoop/HDFS.', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openHadoopNameNodeAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openHadoopNameNodeAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openHadoopNameNode'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [8020]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; diff --git a/plugins/azure/networksecuritygroups/openHadoopNameNode.spec.js b/plugins/azure/networksecuritygroups/openHadoopNameNode.spec.js new file mode 100644 index 000000000..a308fa34d --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHadoopNameNode.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var openHadoopNameNode = require('./openHadoopNameNode'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "8020" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openHadoopNameNode', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 8020 for HDFS NameNode metadata service is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 8020 for HDFS NameNode metadata service is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.js b/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.js index 535e2ba88..f21497a0f 100644 --- a/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.js +++ b/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Hadoop HDFS NameNode WebUI', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 50070 and 50470 to known IP addresses for Hadoop/HDFS', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openHadoopNameNodeWebUIAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openHadoopNameNodeWebUIAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openHadoopNameNodeWebUI'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [50070, 50470]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; diff --git a/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.spec.js b/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.spec.js new file mode 100644 index 000000000..98acdd175 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openHadoopNameNodeWebUI.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openHadoopNameNodeWebUI = require('./openHadoopNameNodeWebUI'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "50070", + "50470" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openHadoopNameNodeWebUI', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openInternalWeb.js b/plugins/azure/networksecuritygroups/openInternalWeb.js new file mode 100644 index 000000000..a531197b5 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openInternalWeb.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Internal Web', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP port 8080 for internal web is open to the public', + more_info: 'Internal web port 8080 is used for web applications and proxy services. Allowing Inbound traffic from any IP address to TCP port 8080 is vulnerable to exploits like backdoor trojan attacks. It is a best practice to block port 8080 from the public internet.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 8080 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [8080] + }; + + let service = 'InternalWeb'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openInternalWeb.spec.js b/plugins/azure/networksecuritygroups/openInternalWeb.spec.js new file mode 100644 index 000000000..358fe6b5f --- /dev/null +++ b/plugins/azure/networksecuritygroups/openInternalWeb.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openInternalWeb = require('./openInternalWeb'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowInternalWeb", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowInternalWeb", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "8080" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openInternalWeb', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Internal Web TCP port 8080 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Internal Web TCP port 8080 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openKibana.js b/plugins/azure/networksecuritygroups/openKibana.js index f93e5312c..48c53a18c 100644 --- a/plugins/azure/networksecuritygroups/openKibana.js +++ b/plugins/azure/networksecuritygroups/openKibana.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Kibana', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5601 for Kibana is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Kibana should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 5601 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openKibanaAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openKibanaAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { var results = []; @@ -46,5 +68,19 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openKibana'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [5601]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openKibana.spec.js b/plugins/azure/networksecuritygroups/openKibana.spec.js new file mode 100644 index 000000000..57a30c80f --- /dev/null +++ b/plugins/azure/networksecuritygroups/openKibana.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openKibana = require('./openKibana'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5601" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openKibana', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 5601 for Kibana is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 5601 for Kibana is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openLDAP.js b/plugins/azure/networksecuritygroups/openLDAP.js new file mode 100644 index 000000000..76e1ad225 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openLDAP.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open LDAP', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP or UDP port 389 for LDAP is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as LDAP should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP or UDP port 389 to known IP addresses. ', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [389] + }; + + let service = 'LDAP'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openLDAP.spec.js b/plugins/azure/networksecuritygroups/openLDAP.spec.js new file mode 100644 index 000000000..d8736b0cd --- /dev/null +++ b/plugins/azure/networksecuritygroups/openLDAP.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openLDAP = require('./openLDAP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowLDAP", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowMemchached", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "389" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openLDAP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has LDAP TCP port 389 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has LDAP TCP port 389 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openLDAPS.js b/plugins/azure/networksecuritygroups/openLDAPS.js new file mode 100644 index 000000000..29b84a2b4 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openLDAPS.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open LDAPS', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP port 636 for LDAP SSL is open to the public', + more_info: 'LDAP SSL port 636 is used for Secure LDAP authentication. Allowing Inbound traffic from any IP address to TCP port 636 is vulnerable to DoS attacks. It is a best practice to block port 636 from the public internet.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 636 to known IP addresses.', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [636] + }; + + let service = 'LDAPS'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openLDAPS.spec.js b/plugins/azure/networksecuritygroups/openLDAPS.spec.js new file mode 100644 index 000000000..a577db001 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openLDAPS.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openLDAPS = require('./openLDAPS'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowLDAPS", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowMemchached", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "636" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openLDAPS', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has LDAPS TCP port 636 not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has LDAPS TCP port 636 open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMemcached.js b/plugins/azure/networksecuritygroups/openMemcached.js new file mode 100644 index 000000000..8e676240e --- /dev/null +++ b/plugins/azure/networksecuritygroups/openMemcached.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Memcached', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if TCP or UDP port 11211 for Memcached is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Memcached should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP and UDP port 11211 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [11211], + 'UDP': [11211] + }; + + let service = 'Memcached'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMemcached.spec.js b/plugins/azure/networksecuritygroups/openMemcached.spec.js new file mode 100644 index 000000000..6d23d10e8 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openMemcached.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openMemcached = require('./openMemcached'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowMemchached", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowMemchached", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "11211" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openMemcached', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if security group has Memcached TCP or UDP port not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if security group has Memcached TCP or UDP port open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMongoDB.js b/plugins/azure/networksecuritygroups/openMongoDB.js new file mode 100644 index 000000000..2d20cfb67 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openMongoDB.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open MongoDB', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 27017 or 27018 or 27019 for MongoDB is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MongoDB should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 27017 or 27018 or 27019 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [27017, 27018, 27019] + }; + + let service = 'MongoDB'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMongoDB.spec.js b/plugins/azure/networksecuritygroups/openMongoDB.spec.js new file mode 100644 index 000000000..f99b44092 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openMongoDB.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openMongoDB = require('./openMongoDB'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowMongoDB", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "27017" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openMongoDB', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP 27017 or 27018 or 27019 MongoDB port is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP 27017 or 27018 or 27019 MongoDB port is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openMongoDB.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMySQL.js b/plugins/azure/networksecuritygroups/openMySQL.js index 7276278a8..419aac6aa 100644 --- a/plugins/azure/networksecuritygroups/openMySQL.js +++ b/plugins/azure/networksecuritygroups/openMySQL.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open MySQL', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 4333 or 3306 for MySQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MySQL should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP ports 4333 and 3306 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openMySQLAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openMySQLAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openMySQL'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [3306, 4333]; + var actions = []; + var errors = []; + + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openMySQL.spec.js b/plugins/azure/networksecuritygroups/openMySQL.spec.js new file mode 100644 index 000000000..b753b34e0 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openMySQL.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openMySQL = require('./openMySQL'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "4333", + "3306" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openMySQL', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 4333 or 3306 for MySQL is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 4333 or 3306 for MySQL is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openNetBIOS.js b/plugins/azure/networksecuritygroups/openNetBIOS.js index 772243f7d..29a761d0a 100644 --- a/plugins/azure/networksecuritygroups/openNetBIOS.js +++ b/plugins/azure/networksecuritygroups/openNetBIOS.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open NetBIOS', category: 'Network Security Groups', - description: 'Determine if UDP port 137 or 138 for NetBIOS is open to the public', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if UDP port 137, 138 or 139 for NetBIOS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as NetBIOS should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', - recommended_action: 'Restrict UDP ports 137 and 138 to known IP addresses', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict UDP ports 137, 138 and 139 to known IP addresses.', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openNetBIOSAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openNetBIOSAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -34,7 +56,7 @@ module.exports = { } var ports = { - 'UDP': [137, 138] + 'UDP': [137, 138, 139] }; var service = 'NetBIOS'; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openNetBIOS'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['UDP','*']; + var ports = [137, 138, 139]; + var actions = []; + var errors = []; + + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openNetBIOS.spec.js b/plugins/azure/networksecuritygroups/openNetBIOS.spec.js new file mode 100644 index 000000000..3f01b20dd --- /dev/null +++ b/plugins/azure/networksecuritygroups/openNetBIOS.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openNetBIOS = require('./openNetBIOS'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "137", + "138" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openNetBIOS', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if UDP port 137 or 138 for NetBIOS is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if UDP port 137 or 138 for NetBIOS is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openOracle.js b/plugins/azure/networksecuritygroups/openOracle.js index 79d4c8ec1..f57d3c234 100644 --- a/plugins/azure/networksecuritygroups/openOracle.js +++ b/plugins/azure/networksecuritygroups/openOracle.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Oracle', category: 'Network Security Groups', - description: 'Determine if TCP port 1521 for Oracle is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 1521, 2483 for Oracle is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', - recommended_action: 'Restrict TCP ports 1521 to known IP addresses', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP ports 1521, 2483 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openOracleAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openOracleAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { var results = []; @@ -35,7 +57,7 @@ module.exports = { var ports = { - 'TCP': [1521] + 'TCP': [1521, 2483] }; var service = 'Oracle'; @@ -46,5 +68,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openOracle'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP' ,'*']; + var ports = [1521, 2483]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } -}; \ No newline at end of file +}; diff --git a/plugins/azure/networksecuritygroups/openOracle.spec.js b/plugins/azure/networksecuritygroups/openOracle.spec.js new file mode 100644 index 000000000..0097e1a57 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openOracle.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openOracle = require('./openOracle'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "1521" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openOracle', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 1521 for Oracle is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 1521 for Oracle is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.js b/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.js index 937c2c954..fe5b6b925 100644 --- a/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.js +++ b/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Oracle Auto Data Warehouse', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 1522 for Oracle Auto Data Warehouse is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle Auto Data Warehouse should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP ports 1522 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openOracleAutoDataWarehouseAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openOracleAutoDataWarehouseAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { var results = []; @@ -45,5 +67,21 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openOracleAutoDataWarehouse'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [1522]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.spec.js b/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.spec.js new file mode 100644 index 000000000..adc91c654 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openOracleAutoDataWarehouse.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openOracleAutoDataWarehouse = require('./openOracleAutoDataWarehouse'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "1522" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openOracleAutoDataWarehouse', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 1522 for Oracle Auto Data Warehouse is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 1522 for Oracle Auto Data Warehouse is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openPostgreSQL.js b/plugins/azure/networksecuritygroups/openPostgreSQL.js index 4cdf266dd..5b502d4bb 100644 --- a/plugins/azure/networksecuritygroups/openPostgreSQL.js +++ b/plugins/azure/networksecuritygroups/openPostgreSQL.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open PostgreSQL', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 5432 for PostgreSQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as PostgreSQL should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 5432 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openPostgreSQLAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openPostgreSQLAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openPostgreSQL'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [5432]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openPostgreSQL.spec.js b/plugins/azure/networksecuritygroups/openPostgreSQL.spec.js new file mode 100644 index 000000000..411314a8d --- /dev/null +++ b/plugins/azure/networksecuritygroups/openPostgreSQL.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openPostgreSQL = require('./openPostgreSQL'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5432" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openPostgreSQL', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 5432 for PostgreSQL is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 5432 for PostgreSQL is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRDP.js b/plugins/azure/networksecuritygroups/openRDP.js index c6c62e651..e7aee3744 100644 --- a/plugins/azure/networksecuritygroups/openRDP.js +++ b/plugins/azure/networksecuritygroups/openRDP.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open RDP', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 3389 for RDP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RDP should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'For each VM, open the Networking blade and verify that the Inbound Port Rules do not have a rule for RDP with a source equal to Any or Internet', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openRDPAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openRDPAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { var results = []; @@ -46,5 +68,19 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openRDP'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [3389]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRDP.spec.js b/plugins/azure/networksecuritygroups/openRDP.spec.js new file mode 100644 index 000000000..6c6d57eac --- /dev/null +++ b/plugins/azure/networksecuritygroups/openRDP.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openRDP = require('./openRDP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "3389" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openRDP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 3389 for RDP is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 3389 for RDP is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRPC.js b/plugins/azure/networksecuritygroups/openRPC.js index 472e100ca..134c871ed 100644 --- a/plugins/azure/networksecuritygroups/openRPC.js +++ b/plugins/azure/networksecuritygroups/openRPC.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open RPC', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 135 for RPC is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RPC should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 135 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openRPCAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openRPCAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openRPC'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [135]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRPC.spec.js b/plugins/azure/networksecuritygroups/openRPC.spec.js new file mode 100644 index 000000000..86a267b8f --- /dev/null +++ b/plugins/azure/networksecuritygroups/openRPC.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openRPC = require('./openRPC'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "135" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openRPC', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 135 for RPC is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 135 for RPC is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRedis.js b/plugins/azure/networksecuritygroups/openRedis.js new file mode 100644 index 000000000..b5f091785 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openRedis.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open Redis', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 6379 for Redis is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Redis should be restricted to known IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict TCP port 6379 to known IP addresses', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'TCP': [6379], + }; + + let service = 'Redis'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openRedis.spec.js b/plugins/azure/networksecuritygroups/openRedis.spec.js new file mode 100644 index 000000000..ab47472ea --- /dev/null +++ b/plugins/azure/networksecuritygroups/openRedis.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openRedis = require('./openRedis'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "TCP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "6379" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openRedis', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP 6379 Redis port is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP 6379 Redis port is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security group') + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSMBoTCP.js b/plugins/azure/networksecuritygroups/openSMBoTCP.js index 245035f59..ecc119085 100644 --- a/plugins/azure/networksecuritygroups/openSMBoTCP.js +++ b/plugins/azure/networksecuritygroups/openSMBoTCP.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open SMBoTCP', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 445 for Windows SMB over TCP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMB should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 445 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openSMBoTCPAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSMBoTCPAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openSMBoTCP'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [445]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSMBoTCP.spec.js b/plugins/azure/networksecuritygroups/openSMBoTCP.spec.js new file mode 100644 index 000000000..8c7c1fd32 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSMBoTCP.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openSMBoTCP = require('./openSMBoTCP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "445" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSMBoTCP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 445 for Windows SMB over TCP is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 445 for Windows SMB over TCP is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSMTP.js b/plugins/azure/networksecuritygroups/openSMTP.js index b3f6e4b8a..f998198f2 100644 --- a/plugins/azure/networksecuritygroups/openSMTP.js +++ b/plugins/azure/networksecuritygroups/openSMTP.js @@ -4,11 +4,33 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Open SMTP', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 25 for SMTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMTP should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 25 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openSMTPAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSMTPAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { var results = []; @@ -46,5 +68,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openSMTP'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [25]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSMTP.spec.js b/plugins/azure/networksecuritygroups/openSMTP.spec.js new file mode 100644 index 000000000..a03a8efd4 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSMTP.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openSMTP = require('./openSMTP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "25" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSMTP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 25 for SMTP is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 25 for SMTP is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSNMP.js b/plugins/azure/networksecuritygroups/openSNMP.js new file mode 100644 index 000000000..1829fcb06 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSNMP.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open SNMP', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Determine if UDP port 161 for SNMP is open to the public', + more_info: 'SNMP UDP 161 used by various devices and applications for logging events, monitoring and management. Allowing Inbound traffic from any external IP address on port 161 is vulnerable to DoS attack. It is a best practice to block port 161 completely unless explicitly required.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + recommended_action: 'Restrict UDP port 161 to known IP addresses.', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + let ports = { + 'UDP': [161], + }; + + let service = 'SNMP'; + + helpers.findOpenPorts(networkSecurityGroups.data, ports, service, location, results); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSNMP.spec.js b/plugins/azure/networksecuritygroups/openSNMP.spec.js new file mode 100644 index 000000000..9873f4c8f --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSNMP.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openSNMP = require('./openSNMP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "UDP", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "161" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSNMP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if UDP 161 SNMP port is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if UDP 161 SNMP port is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSQLServer.js b/plugins/azure/networksecuritygroups/openSQLServer.js index c11a88c2a..badd021ae 100644 --- a/plugins/azure/networksecuritygroups/openSQLServer.js +++ b/plugins/azure/networksecuritygroups/openSQLServer.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open SQLServer', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 1433 or UDP port 1434 for SQL Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SQL server should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 1433 and UDP port 1434 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_inputs: { + openSQLServerAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSQLServerAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,44 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openSQLServer'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + + var portMap = { + 'TCP' : [1433], + 'UDP' : [1434], + '*' : [1433, 1434] + }; + + var actions = []; + var errors = []; + + async.eachOf(portMap, function(ports, protocol, cb){ + var protocols = []; + //ports = []; + protocols.push(protocol); + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, cb); + }, function(err){ + if (err) errors.push(err); + if (errors && errors.length) { + remediation_file['post_remediate']['actions'][pluginName]['error'] = errors.join(', '); + settings.remediation_file = remediation_file; + callback(errors, null); + } else if (actions && actions.length) { + remediation_file['post_remediate']['actions'][pluginName][resource] = actions; + settings.remediation_file = remediation_file; + callback(null, actions); + } else { + callback('No action taken'); + } + } + ); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSQLServer.spec.js b/plugins/azure/networksecuritygroups/openSQLServer.spec.js new file mode 100644 index 000000000..1761e8ef6 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSQLServer.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openSQLServer = require('./openSQLServer'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "1433", + "1434" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSQLServer', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 1433 or UDP port 1434 for SQL Server is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 1433 or UDP port 1434 for SQL Server is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSSH.js b/plugins/azure/networksecuritygroups/openSSH.js index 76f43ab3a..19a65f478 100644 --- a/plugins/azure/networksecuritygroups/openSSH.js +++ b/plugins/azure/networksecuritygroups/openSSH.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open SSH', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 22 for SSH is open to the public', more_info: 'In order to deny ssh access to your virtual machines, you have to set your inbound security rules of Network Security Group to exclude ssh access to your Virtual Machine', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 22 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openSSHAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSSHAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openSSH'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [22]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSSH.spec.js b/plugins/azure/networksecuritygroups/openSSH.spec.js new file mode 100644 index 000000000..a6214a1b7 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSSH.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openSSH = require('./openSSH'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "22" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSSH', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 22 for SSH is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 22 for SSH is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSalt.js b/plugins/azure/networksecuritygroups/openSalt.js index 3bedc81f8..89d0115ae 100644 --- a/plugins/azure/networksecuritygroups/openSalt.js +++ b/plugins/azure/networksecuritygroups/openSalt.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Salt', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP ports 4505 or 4506 for the Salt master are open to the public', more_info: 'Active Salt vulnerabilities, CVE-2020-11651 and CVE-2020-11652 are exploiting Salt instances exposed to the internet. These ports should be closed immediately.', link: 'https://help.saltstack.com/hc/en-us/articles/360043056331-New-SaltStack-Release-Critical-Vulnerability', recommended_action: 'Restrict TCP ports 4505 and 4506 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openSaltAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openSaltAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openSalt'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [4505, 4506]; + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openSalt.spec.js b/plugins/azure/networksecuritygroups/openSalt.spec.js new file mode 100644 index 000000000..8f3eea407 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openSalt.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var openSalt = require('./openSalt'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "4505", + "4506" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openSalt', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 4505 or 4506 for the Salt master is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 4505 or 4506 for the Salt master is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openTelnet.js b/plugins/azure/networksecuritygroups/openTelnet.js index f83071d04..4dea2740c 100644 --- a/plugins/azure/networksecuritygroups/openTelnet.js +++ b/plugins/azure/networksecuritygroups/openTelnet.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open Telnet', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 23 for Telnet is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Telnet should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 23 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openTelnetAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openTelnetAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openTelnet'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP','*']; + var ports = [23]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openTelnet.spec.js b/plugins/azure/networksecuritygroups/openTelnet.spec.js new file mode 100644 index 000000000..d7a02f390 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openTelnet.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openTelnet = require('./openTelnet'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "23" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openTelnet', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 23 for Telnet is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 23 for Telnet is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openUDP.js b/plugins/azure/networksecuritygroups/openUDP.js new file mode 100644 index 000000000..400808734 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openUDP.js @@ -0,0 +1,83 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Open UDP Ports', + category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Internet exposed UDP ports on network security groups are disabled.', + more_info: 'The potential security problem with broadly exposing UDP services over the Internet is that attackers can use DDoS amplification techniques to reflect spoofed UDP traffic from Azure Virtual Machines. The most common types of these attacks use exposed DNS, NTP, SSDP, SNMP, CLDAP and other UDP-based services as amplification source for disrupting services of other machines on the Azure Virtual Network or even attack networked devices outside of Azure.', + link: 'https://learn.microsoft.com/en-us/azure/security/benchmarks/security-controls-v2-network-security#ns-1-implement-security-for-internal-traffic', + recommended_action: 'Disable direct UDP access to your Azure Virtual Machines from the Internet', + apis: ['networkSecurityGroups:listAll'], + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.networkSecurityGroups, function(location, rcb) { + + let networkSecurityGroups = helpers.addSource( + cache, source, ['networkSecurityGroups', 'listAll', location] + ); + + if (!networkSecurityGroups) return rcb(); + + if (networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + if (!networkSecurityGroups.data.length) { + helpers.addResult(results, 0, 'No security groups found', location); + return rcb(); + } + + for (let s in networkSecurityGroups.data) { + var sg = networkSecurityGroups.data[s]; + let openUdpPorts = false; + if (sg.securityRules && + sg.securityRules.length) { + let InvalidSourceAddressPrefixes = ['*', '0.0.0.0', '/0', '/0', 'internet', 'any']; + + var accessRules = sg.securityRules.filter((rule) => { + return (rule.properties && + rule.properties.access && + rule.properties.access.toLowerCase() == 'allow' && + rule.properties.direction.toLowerCase() === 'inbound' && + rule.properties.protocol.toLowerCase() === 'udp'); + }); + + for (var rule in accessRules) { + if (!accessRules[rule].properties) continue; + + let dRule = accessRules[rule].properties; + + if (dRule.sourceAddressPrefix && + InvalidSourceAddressPrefixes.indexOf(dRule.sourceAddressPrefix) > -1) { + openUdpPorts = true; + } + } + + if (openUdpPorts) { + helpers.addResult(results, 2, + 'The security group: ' + sg.name + ' has open UDP ports for internet access', location, sg.id); + } else { + helpers.addResult(results, 0, + 'The security group: ' + sg.name + ' does not have open UDP ports for internet access', location, sg.id); + } + } else { + helpers.addResult(results, 0, + 'The security group: ' + sg.name + ' does not have any security rules', location, sg.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openUDP.spec.js b/plugins/azure/networksecuritygroups/openUDP.spec.js new file mode 100644 index 000000000..5d9377fee --- /dev/null +++ b/plugins/azure/networksecuritygroups/openUDP.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var openUDP = require('./openUDP'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "UDP", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "UDP", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openUDP', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openUDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openUDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if the security group does not have open UDP ports for internet access', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openUDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have open UDP ports for internet access'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if the security group has open UDP ports for internet access', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openUDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has open UDP ports for internet access'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/networksecuritygroups/openVNCClient.js b/plugins/azure/networksecuritygroups/openVNCClient.js index 2e07f0882..1f6c79cb2 100644 --- a/plugins/azure/networksecuritygroups/openVNCClient.js +++ b/plugins/azure/networksecuritygroups/openVNCClient.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open VNC Client', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5500 for VNC Client is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Client should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 5500 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openVNCClientAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openVNCClientAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openVNCClient'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [5500]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openVNCClient.spec.js b/plugins/azure/networksecuritygroups/openVNCClient.spec.js new file mode 100644 index 000000000..2da05e4cc --- /dev/null +++ b/plugins/azure/networksecuritygroups/openVNCClient.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openVNCClient = require('./openVNCClient'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5500" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openVNCClient', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 5500 for VNC Client is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 5500 for VNC Client is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openVNCServer.js b/plugins/azure/networksecuritygroups/openVNCServer.js index 870b16b98..ce2fc8d15 100644 --- a/plugins/azure/networksecuritygroups/openVNCServer.js +++ b/plugins/azure/networksecuritygroups/openVNCServer.js @@ -4,11 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Open VNC Server', category: 'Network Security Groups', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5900 for VNC Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Server should be restricted to known IP addresses.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/manage-network-security-group', recommended_action: 'Restrict TCP port 5900 to known IP addresses', apis: ['networkSecurityGroups:listAll'], + apis_remediate: ['networkSecurityGroups:listAll'], + remediation_min_version: '202011201836', + remediation_description: 'The impacted network security group rule will be deleted if no input is provided. If the failing port is in a port range and no input is provided, the range will be deleted. Otherwise, any input will replace the open CIDR rule.', + remediation_inputs: { + openVNCServerAzureReplacementIpAddress: { + name: '(Optional) Replacement IPv4 CIDR', + description: 'The IPv4 CIDR block used to replace the open IP rule', + regex: '^([0-9]{1,3}\\.){3}[0-9]{1,3}(\\/([0-9]|[1-2][0-9]|3[0-2]))$', + required: false + }, + openVNCServerAzureReplacementIpv6Address: { + name: '(Optional) Replacement IPv6 CIDR', + description: 'The IPv6 CIDR block used to replace the open IP rule', + regex: '^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$', + required: false + } + }, + actions: {remediate:['networkSecurityGroups:update'], rollback:['networkSecurityGroups:update']}, + permissions: {remediate: ['networkSecurityGroups:update'], rollback: ['networkSecurityGroups:update']}, + realtime_triggers: ['microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete','microsoftnetwork:networksecuritygroups:securityrules:write','microsoftnetwork:networksecuritygroups:securityrules:delete'], run: function(cache, settings, callback) { const results = []; @@ -45,5 +67,20 @@ module.exports = { }, function() { callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'openVNCServer'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-05-01'; + var method = 'PUT'; + var protocols = ['TCP', '*']; + var ports = [5900]; + + var actions = []; + var errors = []; + helpers.remediateOpenPortsHelper( putCall, pluginName, protocols, ports, config, cache, settings, resource, remediation_file, baseUrl, method, actions, errors, callback); } }; \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/openVNCServer.spec.js b/plugins/azure/networksecuritygroups/openVNCServer.spec.js new file mode 100644 index 000000000..3fc205348 --- /dev/null +++ b/plugins/azure/networksecuritygroups/openVNCServer.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var openVNCServer = require('./openVNCServer'); + +const networkSecurityGroups = [ + { + "name": "aadds-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "4a6b1ca1-a123-4829-a25d-1a6bcde3fg45", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "5986", + "sourceAddressPrefix": "AzureActiveDirectoryDomainServices", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + } + }, + { + "name": "AllowRD", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowRD", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "3389", + "sourceAddressPrefix": "CorpNetSaw", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 201, + "direction": "Inbound", + } + } + ], + "defaultSecurityRules": [], + "subnets": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet" + } + ] + }, + { + "name": "test-vm-1-nsg", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-vm-1-nsg", + "etag": "W/\"9479cb49-b812-4f0f-825b-2960bfcd14e3\"", + "type": "Microsoft.Network/networkSecurityGroups", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "12a3456b-8cc0-4d9e-aa71-99cdc67b4506", + "securityRules": [ + { + "name": "AllowPSRemoting", + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg/securityRules/AllowPSRemoting", + "etag": "W/\"a1bb27cd-711f-4ede-b673-2fe8e7e07eee\"", + "type": "Microsoft.Network/networkSecurityGroups/securityRules", + "properties": { + "provisioningState": "Succeeded", + "protocol": "*", + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 301, + "direction": "Inbound", + "sourcePortRanges": [], + "destinationPortRanges": [ + "5900" + ], + "sourceAddressPrefixes": [], + "destinationAddressPrefixes": [] + } + }, + ], + "defaultSecurityRules": [], + "networkInterfaces": [ + { + "id": "/subscriptions/ab12c345-def7-890g-a1b2-28fc0d22117e/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-vm-1969" + } + ] + } +]; + +const createCache = (securityGroups) => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': { + data: securityGroups + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkSecurityGroups: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('openVNCServer', function() { + describe('run', function() { + it('should give passing result if no Network Security Groups found', function(done) { + const cache = createCache([]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No security groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Security Groups', function(done) { + const cache = createErrorCache(); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Security Groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if TCP port 5900 for VNC Server is not open to public', function(done) { + const cache = createCache([networkSecurityGroups[0]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('does not have'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if TCP port 5900 for VNC Server is open to public', function(done) { + const cache = createCache([networkSecurityGroups[1]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/networksecuritygroups/networkWatcherEnabled.js b/plugins/azure/networkwatchers/networkWatcherEnabled.js similarity index 89% rename from plugins/azure/networksecuritygroups/networkWatcherEnabled.js rename to plugins/azure/networkwatchers/networkWatcherEnabled.js index e3d74b176..40c08c001 100644 --- a/plugins/azure/networksecuritygroups/networkWatcherEnabled.js +++ b/plugins/azure/networkwatchers/networkWatcherEnabled.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure/'); module.exports = { title: 'Network Watcher Enabled', - category: 'Network Security Groups', + category: 'Network Watcher', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures Network Watcher is enabled in all locations', more_info: 'Network Watcher helps locate, diagnose, and gain insights into Azure networks. Enabling Network Watcher in all locations ensures that no resources are being used in locations that are not authorized.', recommended_action: 'Enable the Network Watcher service in all locations.', - link: 'https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview', + link: 'https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview', apis: ['networkWatchers:listAll', 'virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:networkwatchers:write', 'microsoftnetwork:networkwatchers:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/networkwatchers/networkWatcherEnabled.spec.js b/plugins/azure/networkwatchers/networkWatcherEnabled.spec.js new file mode 100644 index 000000000..35f9f6f61 --- /dev/null +++ b/plugins/azure/networkwatchers/networkWatcherEnabled.spec.js @@ -0,0 +1,164 @@ +var expect = require('chai').expect; +var networkWatcherEnabled = require('./networkWatcherEnabled'); + +const networkWatchers = [ + { + "name": "NetworkWatcher_eastus", + "id": "/subscriptions/def1d0ac-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/NetworkWatcherRG/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus", + "etag": "W/\"a12bcd34-5333-4361-a645-0f110712c17e\"", + "type": "Microsoft.Network/networkWatchers", + "location": "eastus", + "provisioningState": "Succeeded", + "runningOperationIds": [] + }, + { + "name": "NetworkWatcher_eastus2", + "id": "/subscriptions/def1d0ac-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/NetworkWatcherRG/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus2", + "etag": "W/\"s31sde21-686a-449e-b678-1eb7bc38310e\"", + "type": "Microsoft.Network/networkWatchers", + "location": "eastus2", + "provisioningState": "Failed", + "runningOperationIds": [] + } +]; + +const virtualNetworks = [ + { + "name": "aadds-vnet", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet", + "etag": "W/\"9647a968-4864-4a13-a916-5cf7dd6fabff\"", + "type": "Microsoft.Network/virtualNetworks", + "location": "eastus", + "provisioningState": "Succeeded", + "resourceGuid": "e9502313-7cdc-400b-bd64-d97e361e63a4", + "addressSpace": { + "addressPrefixes": [ + "10.0.6.0/24" + ] + }, + "subnets": [ + { + "name": "aadds-subnet", + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Network/virtualNetworks/aadds-vnet/subnets/aadds-subnet", + "etag": "W/\"9647a968-4864-4a13-a916-5cf7dd6fabff\"", + "properties": { + "provisioningState": "Succeeded", + "addressPrefix": "10.0.6.0/24", + "networkSecurityGroup": { + "id": "/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/akhtar-rg/providers/Microsoft.Network/networkSecurityGroups/aadds-nsg" + }, + "delegations": [], + "privateEndpointNetworkPolicies": "Enabled", + "privateLinkServiceNetworkPolicies": "Enabled" + }, + "type": "Microsoft.Network/virtualNetworks/subnets" + } + ], + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (watchers, virtualNetworks) => { + return { + networkWatchers: { + listAll: { + 'eastus': { + data: watchers + } + } + }, + virtualNetworks: { + listAll: { + 'eastus': { + data: virtualNetworks + } + } + } + }; +}; + +const createErrorCache = () => { + return { + networkWatchers: { + listAll: { + 'eastus': {} + } + }, + virtualNetworks: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('networkWatcherEnabled', function() { + describe('run', function() { + it('should give passing result if no Virtual Networks or Network Watchers found', function(done) { + const cache = createCache([], []); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Networks or Network Watchers in the region'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Network Watchers', function(done) { + const cache = createErrorCache(); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Network Watchers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Virtual Networks', function(done) { + const cache = createCache([], null); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no Network Watchers found', function(done) { + const cache = createCache([], [virtualNetworks[0]]); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Network Watcher is not enabled in the region'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Network Watcher is enabled', function(done) { + const cache = createCache([networkWatchers[0]], [virtualNetworks[0]]); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Network Watcher is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Network Watcher is not enabled', function(done) { + const cache = createCache([networkWatchers[1]], [virtualNetworks[0]]); + networkWatcherEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Network Watcher is not successfully provisioned for the region'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/openai/accountCMKEncrypted.js b/plugins/azure/openai/accountCMKEncrypted.js new file mode 100644 index 000000000..82e68a978 --- /dev/null +++ b/plugins/azure/openai/accountCMKEncrypted.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'OpenAI Account CMK Encrypted', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensures that Azure OpenAI accounts are encrypted using CMK.', + more_info: 'Azure OpenAI allows you to encrypt your accounts using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption provides enhanced security control over data and the ability to manage and audit key access, ensuring that sensitive data processed by Azure OpenAI models remains protected.', + recommended_action: 'Ensure that Azure OpenAI accounts have CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/encrypt-data-at-rest', + apis: ['openAI:listAccounts'], + realtime_triggers: ['microsoftcognitiveservices:accounts:write','microsoftcognitiveservices:accounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.openAI, function(location, rcb) { + const accounts = helpers.addSource(cache, source, + ['openAI', 'listAccounts', location]); + + if (!accounts) return rcb(); + + + if (accounts.err || !accounts.data) { + helpers.addResult(results, 3, 'Unable to query OpenAI accounts: ' + helpers.addError(accounts), location); + return rcb(); + } + + if (!accounts.data.length) { + helpers.addResult(results, 0, 'No existing OpenAI accounts found', location); + return rcb(); + } + + for (let account of accounts.data) { + + if (account.properties && account.properties.encryption && + account.properties.encryption.keySource && + account.properties.encryption.keySource.toLowerCase() == 'microsoft.keyvault') { + helpers.addResult(results, 0, + 'OpenAI Account is encrypted using CMK', location, account.id); + } else { + helpers.addResult(results, 2, + 'OpenAI Account is not encrypted using CMK', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/openai/accountCMKEncrypted.spec.js b/plugins/azure/openai/accountCMKEncrypted.spec.js new file mode 100644 index 000000000..954c7f443 --- /dev/null +++ b/plugins/azure/openai/accountCMKEncrypted.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var accountCMKEncrypted = require('./accountCMKEncrypted'); + +const accounts = [ + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1", + "name": "acc1", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "properties": { + "encryption": { + 'keySource': 'Microsoft.Keyvault' + } + } + }, + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc2", + "name": "acc2", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus" + }, + + +]; + +const createCache = (accounts) => { + return { + openAI: { + listAccounts: { + 'eastus': { + data: accounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + openAI: { + listAccounts: { + 'eastus': {} + } + } + }; +}; + +describe('accountCMKEncrypted', function() { + describe('run', function() { + it('should give passing result if no openai accounts', function(done) { + const cache = createCache([]); + accountCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing OpenAI accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for openai accounts', function(done) { + const cache = createErrorCache(); + accountCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OpenAI accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if openai account is encrypted using CMK', function(done) { + const cache = createCache([accounts[0]]); + accountCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenAI Account is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if openai account is not encrypted using CMK', function(done) { + const cache = createCache([accounts[1]]); + accountCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenAI Account is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/openai/accountDiagnosticLogging.js b/plugins/azure/openai/accountDiagnosticLogging.js new file mode 100644 index 000000000..d6667f3d0 --- /dev/null +++ b/plugins/azure/openai/accountDiagnosticLogging.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'OpenAI Account Diagnostic Logging Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Azure OpenAI accounts.', + more_info: 'Enabling diagnostic logs for Azure OpenAI accounts is crucial for monitoring and troubleshooting. It helps in tracking usage, detecting anomalies, and understanding API interactions, thereby enhancing the operational security and efficiency of AI applications.', + recommended_action: 'Modify the OpenAI account settings and enable diagnostic logs.', + link: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/monitoring', + apis: ['openAI:listAccounts', 'diagnosticSettings:listByOpenAIAccounts'], + realtime_triggers: ['microsoftcognitiveservices:accounts:write','microsoftcognitiveservices:accounts:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.openAI, function(location, rcb) { + const accounts = helpers.addSource(cache, source, + ['openAI', 'listAccounts', location]); + + if (!accounts) return rcb(); + + + if (accounts.err || !accounts.data) { + helpers.addResult(results, 3, 'Unable to query OpenAI accounts: ' + helpers.addError(accounts), location); + return rcb(); + } + + if (!accounts.data.length) { + helpers.addResult(results, 0, 'No existing OpenAI accounts found', location); + return rcb(); + } + + for (let account of accounts.data) { + if (!account.id) continue; + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByOpenAIAccounts', location, account.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for account diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, account.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'OpenAI account has diagnostic logs enabled', location, account.id); + } else { + helpers.addResult(results, 2, 'OpenAI account does not have diagnostic logs enabled', location, account.id); + } + + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/openai/accountDiagnosticLogging.spec.js b/plugins/azure/openai/accountDiagnosticLogging.spec.js new file mode 100644 index 000000000..e0b826c75 --- /dev/null +++ b/plugins/azure/openai/accountDiagnosticLogging.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var accountDiagnosticLogging = require('./accountDiagnosticLogging'); + +const accounts = [ + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1", + "name": "acc1", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "properties": { + "encryption": { + 'keySource': 'Microsoft.Keyvault' + } + } + }, + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc2", + "name": "acc2", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus" + }, + + +]; + +const diagnosticSettings = [ + { + id: 'subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'openai-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (accounts, ds) => { + const id = accounts && accounts.length ? accounts[0].id : null; + return { + openAI: { + listAccounts: { + 'eastus': { + data: accounts + } + } + }, + diagnosticSettings: { + listByOpenAIAccounts: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('accountDiagnosticLogging', function() { + describe('run', function() { + it('should give passing result if no openai accounts', function (done) { + const cache = createCache([], null); + accountDiagnosticLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing OpenAI accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for openai accounts', function (done) { + const cache = createCache(null, ['error']); + accountDiagnosticLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OpenAI accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([accounts[0]], null); + accountDiagnosticLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for account diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([accounts[0]], [diagnosticSettings[0]]); + accountDiagnosticLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenAI account has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([accounts[0]], [[]]); + accountDiagnosticLogging.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenAI account does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/openai/accountHasTags.js b/plugins/azure/openai/accountHasTags.js new file mode 100644 index 000000000..fd989a37d --- /dev/null +++ b/plugins/azure/openai/accountHasTags.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'OpenAI Account Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Azure OpenAI accounts have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Azure OpenAI accounts and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['openAI:listAccounts'], + realtime_triggers: ['microsoftcognitiveservices:accounts:write','microsoftcognitiveservices:accounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.openAI, function(location, rcb) { + const accounts = helpers.addSource(cache, source, + ['openAI', 'listAccounts', location]); + + if (!accounts) return rcb(); + + + if (accounts.err || !accounts.data) { + helpers.addResult(results, 3, 'Unable to query OpenAI accounts: ' + helpers.addError(accounts), location); + return rcb(); + } + + if (!accounts.data.length) { + helpers.addResult(results, 0, 'No existing OpenAI accounts found', location); + return rcb(); + } + + for (let account of accounts.data) { + + if (account.tags && Object.entries(account.tags).length > 0) { + helpers.addResult(results, 0, + 'OpenAI Account has tags associated', location, account.id); + } else { + helpers.addResult(results, 2, + 'OpenAI Account does not have tags associated', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/openai/accountHasTags.spec.js b/plugins/azure/openai/accountHasTags.spec.js new file mode 100644 index 000000000..5d0327f0d --- /dev/null +++ b/plugins/azure/openai/accountHasTags.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var accountHasTags = require('./accountHasTags'); + +const accounts = [ + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1", + "name": "acc1", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "tags": { + "test": "test" + } + }, + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc2", + "name": "acc2", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus" + }, + + +]; + +const createCache = (accounts) => { + return { + openAI: { + listAccounts: { + 'eastus': { + data: accounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + openAI: { + listAccounts: { + 'eastus': {} + } + } + }; +}; + +describe('accountHasTags', function() { + describe('run', function() { + it('should give passing result if no openai accounts', function(done) { + const cache = createCache([]); + accountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing OpenAI accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for openai accounts', function(done) { + const cache = createErrorCache(); + accountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OpenAI accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if openai account has tags associated', function(done) { + const cache = createCache([accounts[0]]); + accountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenAI Account has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if openai account does not have tags associated', function(done) { + const cache = createCache([accounts[1]]); + accountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenAI Account does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/openai/accountManagedIdentity.js b/plugins/azure/openai/accountManagedIdentity.js new file mode 100644 index 000000000..a1c4ca418 --- /dev/null +++ b/plugins/azure/openai/accountManagedIdentity.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'OpenAI Account Managed Identity Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures a system or user assigned managed identity is enabled to authenticate to Azure OpenAI accounts.', + more_info: 'Enabling managed identity for Azure OpenAI accounts automates credential management, enhancing security by avoiding hard-coded credentials and simplifying access control to Azure services.', + recommended_action: 'Enable system or user-assigned identities for all Azure OpenAI accounts.', + link: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/managed-identity', + apis: ['openAI:listAccounts'], + realtime_triggers: ['microsoftcognitiveservices:accounts:write','microsoftcognitiveservices:accounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.openAI, function(location, rcb) { + const accounts = helpers.addSource(cache, source, + ['openAI', 'listAccounts', location]); + + if (!accounts) return rcb(); + + + if (accounts.err || !accounts.data) { + helpers.addResult(results, 3, 'Unable to query OpenAI accounts: ' + helpers.addError(accounts), location); + return rcb(); + } + + if (!accounts.data.length) { + helpers.addResult(results, 0, 'No existing OpenAI accounts found', location); + return rcb(); + } + + for (let account of accounts.data) { + + if (account.identity && account.identity.type && + (account.identity.type.toLowerCase() === 'systemassigned' || account.identity.type.toLowerCase() === 'userassigned')) { + helpers.addResult(results, 0, + 'OpenAI Account has managed identity enabled', location, account.id); + } else { + helpers.addResult(results, 2, + 'OpenAI Account does not have managed identity enabled', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/openai/accountManagedIdentity.spec.js b/plugins/azure/openai/accountManagedIdentity.spec.js new file mode 100644 index 000000000..84640d92a --- /dev/null +++ b/plugins/azure/openai/accountManagedIdentity.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var accountManagedIdentity = require('./accountManagedIdentity'); + +const accounts = [ + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1", + "name": "acc1", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "identity": { + "principalId": "11111", + "tenantId": "33333", + "type": "SystemAssigned" + }, + }, + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc2", + "name": "acc2", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus" + }, + + +]; + +const createCache = (accounts) => { + return { + openAI: { + listAccounts: { + 'eastus': { + data: accounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + openAI: { + listAccounts: { + 'eastus': {} + } + } + }; +}; + +describe('accountManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no openai accounts', function(done) { + const cache = createCache([]); + accountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing OpenAI accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for openai accounts', function(done) { + const cache = createErrorCache(); + accountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OpenAI accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if openai account has managed identity enabled', function(done) { + const cache = createCache([accounts[0]]); + accountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenAI Account has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if openai account does not have managed identity enabled', function(done) { + const cache = createCache([accounts[1]]); + accountManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenAI Account does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/openai/accountPublicAccessDisabled.js b/plugins/azure/openai/accountPublicAccessDisabled.js new file mode 100644 index 000000000..e5ab13c2c --- /dev/null +++ b/plugins/azure/openai/accountPublicAccessDisabled.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'OpenAI Account Public Access Disabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensures that Azure OpenAI accounts are not publicly accessible.', + more_info: 'Making OpenAI accounts publicly accessible can expose sensitive data and AI-generated content to unauthorized users, increasing the risk of data breaches and misuse of AI resources, which could lead to significant security and privacy concerns.', + recommended_action: 'Ensure that Azure OpenAI have public network access disabled.', + link: 'https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-virtual-networks?tabs=portal#use-private-endpoints', + apis: ['openAI:listAccounts'], + realtime_triggers: ['microsoftcognitiveservices:accounts:write','microsoftcognitiveservices:accounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.openAI, function(location, rcb) { + const accounts = helpers.addSource(cache, source, + ['openAI', 'listAccounts', location]); + + if (!accounts) return rcb(); + + + if (accounts.err || !accounts.data) { + helpers.addResult(results, 3, 'Unable to query OpenAI accounts: ' + helpers.addError(accounts), location); + return rcb(); + } + + if (!accounts.data.length) { + helpers.addResult(results, 0, 'No existing OpenAI accounts found', location); + return rcb(); + } + + for (let account of accounts.data) { + + if (account.properties && + account.properties.publicNetworkAccess && + account.properties.publicNetworkAccess.toLowerCase() == 'enabled') { + helpers.addResult(results, 2, + 'OpenAI Account is publicly accessible', location, account.id); + } else { + helpers.addResult(results, 0, + 'OpenAI Account is not publicly accessible', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/openai/accountPublicAccessDisabled.spec.js b/plugins/azure/openai/accountPublicAccessDisabled.spec.js new file mode 100644 index 000000000..28c74f3ea --- /dev/null +++ b/plugins/azure/openai/accountPublicAccessDisabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var accountPublicAccessDisabled = require('./accountPublicAccessDisabled'); + +const accounts = [ + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc1", + "name": "acc1", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "properties": { + "publicNetworkAccess": 'Disabled' + } + }, + { + "id": "/subscriptions/12424/resourceGroups/bvttest/providers/Microsoft.CognitiveServices/accounts/acc2", + "name": "acc2", + "type": "Microsoft.CognitiveServices/accounts", + "location": "eastus", + "properties": { + "publicNetworkAccess": 'Enabled' + } + }, + + +]; + +const createCache = (accounts) => { + return { + openAI: { + listAccounts: { + 'eastus': { + data: accounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + openAI: { + listAccounts: { + 'eastus': {} + } + } + }; +}; + +describe('accountPublicAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no openai accounts', function(done) { + const cache = createCache([]); + accountPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing OpenAI accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for openai accounts', function(done) { + const cache = createErrorCache(); + accountPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query OpenAI accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if openai account is not publicly accessible', function(done) { + const cache = createCache([accounts[0]]); + accountPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OpenAI Account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if openai account is publicly accessible', function(done) { + const cache = createCache([accounts[1]]); + accountPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OpenAI Account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/policyservice/resourceAllowedLocations.js b/plugins/azure/policyservice/resourceAllowedLocations.js index 70807a913..d2db59e22 100644 --- a/plugins/azure/policyservice/resourceAllowedLocations.js +++ b/plugins/azure/policyservice/resourceAllowedLocations.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Resources Allowed Locations', category: 'Azure Policy', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures deployed resources and resource groups belong to the list set in the allowed locations for resource groups policy', more_info: 'Setting allowed locations for a service helps ensure the service can only be deployed in expected locations.', recommended_action: 'Ensure that all services contain policy definitions that defined allowed locations.', - link: 'https://docs.microsoft.com/en-us/azure/governance/policy/assign-policy-portal', + link: 'https://learn.microsoft.com/en-us/azure/governance/policy/assign-policy-portal', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write', 'microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/policyservice/resourceAllowedLocations.spec.js b/plugins/azure/policyservice/resourceAllowedLocations.spec.js new file mode 100644 index 000000000..a0ebd0b15 --- /dev/null +++ b/plugins/azure/policyservice/resourceAllowedLocations.spec.js @@ -0,0 +1,205 @@ +var expect = require('chai').expect; +var resourceAllowedLocations = require('./resourceAllowedLocations'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/db6d35583b2147ac96dcb3ca", + "type": "Microsoft.Authorization/policyAssignments", + "name": "db6d35583b2147ac96dcb3ca", + "displayName": "Allowed locations", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "listOfAllowedLocations": { + "value": [ + "asia", + "asiapacific", + "australia", + "australiacentral", + "australiacentral2", + "australiaeast", + "australiasoutheast", + "brazil", + "brazilsouth", + "brazilsoutheast", + "canada", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "centralusstage", + "eastasia", + "eastasiastage", + "eastus", + "eastusstage", + "eastus2", + "eastus2stage", + "europe", + "france", + "francecentral", + "francesouth", + "germany", + "germanynorth", + "germanywestcentral", + "global", + "india", + "japan", + "japaneast", + "japanwest", + "jioindiacentral", + "jioindiawest", + "korea", + "koreacentral", + "koreasouth", + "northcentralus", + "northcentralusstage", + "northeurope", + "norway", + "norwayeast", + "norwaywest", + "singapore", + "southafrica", + "southafricanorth", + "southafricawest", + "southcentralus", + "southcentralusstage", + "southindia", + "southeastasia", + "southeastasiastage", + "swedencentral", + "switzerland", + "switzerlandnorth", + "switzerlandwest", + "uaecentral", + "uaenorth", + "uksouth", + "ukwest", + "uae", + "uk", + "unitedstates", + "unitedstateseuap", + "westcentralus", + "westeurope", + "westindia", + "westus", + "westusstage", + "westus2", + "westus2stage", + "westus3" + ] + } + }, + "metadata": { + "assignedBy": "Akhtar pucit", + "parameterScopes": { + "listOfAllowedLocations": "/subscriptions/1234" + }, + "createdBy": "f3eb1c86-38e5-40d0-b120-e7476956bc8e", + "createdOn": "2022-07-15T16:26:12.6623135Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/9817efd5921b4d59ae85b5a1", + "type": "Microsoft.Authorization/policyAssignments", + "name": "9817efd5921b4d59ae85b5a1", + "location": "eastus", + "displayName": "Monitor unencrypted VM Disks in Azure Security Center", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/123", + "notScopes": [], + "parameters": {}, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-05-22T15:55:48.8730114Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'global': settings + } + } + }; +}; + +// const createErrorCache = () => { +// return { +// policyAssignments: { +// list: { +// 'global': {} +// } +// } +// }; +// }; + +describe('resourceAllowedLocations', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + resourceAllowedLocations.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + resourceAllowedLocations.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if The policy to audit resources launched in allowed locations is enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + resourceAllowedLocations.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The policy to audit resources launched in allowed locations is enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if No existing assignment for the resources launched in allowed locations policy', function(done) { + const cache = createCache([policyAssignments[1]]); + resourceAllowedLocations.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing assignment for the resources launched in allowed locations policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/policyservice/resourceLocationMatch.js b/plugins/azure/policyservice/resourceLocationMatch.js index 0f0fc47f9..353307e37 100644 --- a/plugins/azure/policyservice/resourceLocationMatch.js +++ b/plugins/azure/policyservice/resourceLocationMatch.js @@ -4,13 +4,17 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Resource Location Matches Resource Group', category: 'Azure Policy', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures a policy is configured to audit that deployed resource locations match their resource group locations', more_info: 'Using Azure Policy to monitor resource location compliance helps ensure that new resources are not launched into locations that do not match their resource group.', recommended_action: 'Enable the built-in Azure Policy definition: Audit resource location matches resource group location', - link: 'https://docs.microsoft.com/en-us/azure/governance/policy/assign-policy-portal', + link: 'https://learn.microsoft.com/en-us/azure/governance/policy/assign-policy-portal', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write', 'microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { + // console.log(JSON.stringify(cache, null, 2)); const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); diff --git a/plugins/azure/policyservice/resourceLocationMatch.spec.js b/plugins/azure/policyservice/resourceLocationMatch.spec.js new file mode 100644 index 000000000..76a6c227c --- /dev/null +++ b/plugins/azure/policyservice/resourceLocationMatch.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var resourceLocationMatch = require('./resourceLocationMatch'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/12345/providers/Microsoft.Authorization/policyAssignments/69030cac631c4ede9355aa9b", + "type": "Microsoft.Authorization/policyAssignments", + "name": "69030cac631c4ede9355aa9b", + "displayName": "Audit resource location matches resource group location", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0a914e76-4921-4c19-b460-a2d36003525a", + "scope": "/subscriptions/12345", + "notScopes": [ + "/subscriptions/12345/resourceGroups/sadeedrg" + ], + "parameters": {}, + "metadata": { + "assignedBy": "Sadeed Rehman", + "parameterScopes": {}, + "createdBy": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "createdOn": "2022-07-18T13:44:25.7283635Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/123/providers/Microsoft.Authorization/policyAssignments/9817efd5921b4d59ae85b5a1", + "type": "Microsoft.Authorization/policyAssignments", + "name": "9817efd5921b4d59ae85b5a1", + "location": "eastus", + "displayName": "Monitor unencrypted VM Disks in Azure Security Center", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/123", + "notScopes": [], + "parameters": {}, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-05-22T15:55:48.8730114Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'global': settings + } + } + }; +}; + +describe('resourceLocationMatch', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + resourceLocationMatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + resourceLocationMatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if The policy to audit matching resource location to resource group location is assigned', function(done) { + const cache = createCache([policyAssignments[0]]); + resourceLocationMatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The policy to audit matching resource location to resource group location is assigned'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if No existing assignment for the resource location matches resource group location policy', function(done) { + const cache = createCache([policyAssignments[1]]); + resourceLocationMatch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing assignment for the resource location matches resource group location policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js new file mode 100644 index 000000000..debdec24c --- /dev/null +++ b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js @@ -0,0 +1,75 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Azure Entra ID Admin Configured', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Entra ID admin is set up on all PostgreSQL servers.', + more_info: 'Using Azure Entra ID authentication allows key rotation and permission management to be managed in one location for all servers. This can be done are configuring an Entra ID administrator.', + recommended_action: 'Set up an Entra ID admin for PostgreSQL database servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-sign-in-aad-authentication', + apis: ['servers:listPostgres', 'serverAdministrators:list'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + + for (let postgresServer of servers.data) { + const serverAdministrators = helpers.addSource(cache, source, + ['serverAdministrators', 'list', location, postgresServer.id]); + + if (!serverAdministrators || serverAdministrators.err || !serverAdministrators.data) { + helpers.addResult(results, 3, + 'Unable to query for Entra ID admins: ' + helpers.addError(serverAdministrators), location, postgresServer.id); + } else { + if (!serverAdministrators.data.length) { + helpers.addResult(results, 2, 'No Entra ID admin found for the server', location, postgresServer.id); + } else { + var entraIdAdminEnabled = false; + serverAdministrators.data.forEach(serverAdministrator => { + if (serverAdministrator.name && + serverAdministrator.name.toLowerCase() === 'activedirectory') { + entraIdAdminEnabled = true; + } + }); + + if (entraIdAdminEnabled) { + helpers.addResult(results, 0, + 'Entra ID admin is enabled on the PostgreSQL server', location, postgresServer.id); + } else { + helpers.addResult(results, 2, + 'Entra ID admin is not enabled on the PostgreSQL server', location, postgresServer.id); + } + } + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js new file mode 100644 index 000000000..da241b6f1 --- /dev/null +++ b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js @@ -0,0 +1,140 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var activeDirectoryAdminEnabled = require('./activeDirectoryAdminEnabled'); + +const listPostgres = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/servers/test-server", + }, +]; + +const serverAdministrators = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/servers/test-server/administrators/ActiveDirectory", + "name": "ActiveDirectory", + "type": "PostgreSQL.Server.PAL", + "administratorType": "ActiveDirectory", + "login": "abc@cloudexploit.com" + }, + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/servers/test-server/administrators/ActiveDirectory", + "type": "PostgreSQL.Server.PAL", + "administratorType": "ActiveDirectory", + "login": "abc@cloudexploit.com" + } +] +const createCache = (err, list, adlist, aderr) => { + const id = (list && list.length) ? list[0].id : null; + return { + servers: { + listPostgres: { + 'eastus': { + err: err, + data: list + } + } + }, + serverAdministrators: { + list: { + 'eastus': { + [id]: { + err: aderr, + data: adlist + } + } + } + } + } +}; + +describe('activeDirectoryAdminEnabled', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + activeDirectoryAdminEnabled.run(cache, {}, callback); + }) + + it('should give failing result if Entra ID admin is not enabled on the PostgreSQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Entra ID admin is not enabled on the PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + listPostgres, + [serverAdministrators[1]] + ); + + activeDirectoryAdminEnabled.run(cache, {}, callback); + }); + + it('should give failing result if No Entra ID admin found for the server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Entra ID admin found for the server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + listPostgres, + [] + ); + + activeDirectoryAdminEnabled.run(cache, {}, callback); + }); + + it('should give passing result if Entra ID admin is enabled on the PostgreSQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Entra ID admin is enabled on the PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + listPostgres, + [serverAdministrators[0]] + ); + + activeDirectoryAdminEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + { message: "Unable to list servers" }, + ); + + activeDirectoryAdminEnabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/azureServicesAccessDisabled.js b/plugins/azure/postgresqlserver/azureServicesAccessDisabled.js new file mode 100644 index 000000000..78cb778c2 --- /dev/null +++ b/plugins/azure/postgresqlserver/azureServicesAccessDisabled.js @@ -0,0 +1,78 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Services Network Access Disabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that PostgreSQL servers do not allow access to other Azure services.', + more_info: 'To secure your PostgreSQL server, it is recommended to disable public network access. Instead, configure firewall rules to allow connections from specific network ranges or utilize VNET rules for access from designated virtual networks. This helps prevent unauthorized access from Azure services outside your subscription.', + recommended_action: 'Disable public network access for PostgreSQL database servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules', + apis: ['servers:listPostgres', 'firewallRules:listByServerPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write', 'microsoftdbforpostgresql:servers:firewallrules:write','microsoftdbforpostgresql:servers:firewallrules:delete','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL servers found', location); + return rcb(); + } + + for (let postgresServer of servers.data) { + if (!postgresServer.id) continue; + + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByServerPostgres', location, postgresServer.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query SQL Server Firewall Rules: ' + helpers.addError(firewallRules), location, postgresServer.id); + continue; + } + + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing SQL Server Firewall Rules found', location, postgresServer.id); + continue; + } + + let accessToServices = true; + for (let rule of firewallRules.data) { + if (rule.name && rule.name.toLowerCase() === 'allowallwindowsazureips') { + accessToServices = false; + break; + } + } + + if (accessToServices) { + helpers.addResult(results, 0, + 'Access to other Azure services is disabled for PostgreSQL server', location, postgresServer.id); + } else { + helpers.addResult(results, 2, + 'Access to other Azure services is not disabled for PostgreSQL server', location, postgresServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/azureServicesAccessDisabled.spec.js b/plugins/azure/postgresqlserver/azureServicesAccessDisabled.spec.js new file mode 100644 index 000000000..5cf80668f --- /dev/null +++ b/plugins/azure/postgresqlserver/azureServicesAccessDisabled.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var azureServicesAccessDisabled = require('./azureServicesAccessDisabled'); + +const listPostgres = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant", + "type": "Microsoft.DBforPostgreSQL/servers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant/firewallRules/TestRule", + "name": "testRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + }, + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllWindowsAzureIps", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + }, +]; +const createCache = (list, rules) => { + const serverId = (list && list.length) ? list[0].id : null; + return { + servers: { + listPostgres: { + 'eastus': { + data: list + } + } + }, + firewallRules: { + listByServerPostgres: { + 'eastus': { + [serverId]: { + data: rules + } + } + } + } + } +}; + +describe('azureServicesAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + + azureServicesAccessDisabled.run(cache, {}, callback); + }) + + it('should give failing result if postgresql server does not have access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Access to other Azure services is not disabled for PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgres[0]], + [firewallRules[1]] + ); + + azureServicesAccessDisabled.run(cache, {}, callback); + }); + + it('should give passing result if postgresql server not have access to azure services', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Access to other Azure services is disabled for PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgres[0]], + [firewallRules[0]] + ); + + azureServicesAccessDisabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null + ); + + azureServicesAccessDisabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/connectionThrottlingEnabled.js b/plugins/azure/postgresqlserver/connectionThrottlingEnabled.js index 36e595117..4bea18a32 100644 --- a/plugins/azure/postgresqlserver/connectionThrottlingEnabled.js +++ b/plugins/azure/postgresqlserver/connectionThrottlingEnabled.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Connection Throttling Enabled', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures connection throttling is enabled for PostgreSQL servers', more_info: 'Connection throttling slows the amount of query and error logs sent by the server from the same IP address, limiting DoS attacks or the slowing down of servers due to excessive legitimate user logs.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the connection_throttling setting enabled.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.js b/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.js new file mode 100644 index 000000000..7d7d68a94 --- /dev/null +++ b/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Diagnostic Logging Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures diagnostic logging is enabled for PostgreSQL servers.', + more_info: 'Enabling diagnostic logging for Azure Database for PostgreSQL servers helps with performance monitoring, troubleshooting, and security optimization.', + recommended_action: 'Enable diagnostic logging for all PostgreSQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/single-server/concepts-server-logs', + apis: ['servers:listPostgres', 'diagnosticSettings:listByPostgresServers'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write', 'microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + + for (let server of servers.data) { + if (!server.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByPostgresServers', location, server.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for PostgreSQL Server diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, server.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'PostgreSQL Server has diagnostic logs enabled', location, server.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL Server does not have diagnostic logs enabled', location, server.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.spec.js b/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.spec.js new file mode 100644 index 000000000..6f2229a30 --- /dev/null +++ b/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var diagnosticLoggingEnabled = require('./diagnosticLoggingEnabled'); + +const servers = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/servers/test-server", + }, +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.DBforPostgreSQL/servers/test/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'server-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (servers, ds) => { + const id = servers && servers.length ? servers[0].id : null; + return { + servers: { + listPostgres: { + 'eastus': { + data: servers + } + } + }, + diagnosticSettings: { + listByPostgresServers: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('diagnosticLoggingEnabled', function() { + describe('run', function() { + it('should give a passing result if no PostgreSQL Servers are found', function (done) { + const cache = createCache([], null); + diagnosticLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function (done) { + const cache = createCache(null, ['error']); + diagnosticLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([servers[0]], null); + diagnosticLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Server diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([servers[0]], [diagnosticSettings[0]]); + diagnosticLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL Server has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([servers[0]], [[]]); + diagnosticLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL Server does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/postgresqlserver/enforcePostgresSSLConnection.js b/plugins/azure/postgresqlserver/enforcePostgresSSLConnection.js index 2e40ef305..7e107b0e5 100644 --- a/plugins/azure/postgresqlserver/enforcePostgresSSLConnection.js +++ b/plugins/azure/postgresqlserver/enforcePostgresSSLConnection.js @@ -4,11 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Enforce PostgreSQL SSL Connection', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures SSL connections are enforced on PostgreSQL Servers', more_info: 'SSL prevents infiltration attacks by encrypting the data stream between the server and application.', recommended_action: 'Ensure the connection security settings of each PostgreSQL server are configured to enforce SSL connections.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/concepts-ssl-connection-security', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/concepts-ssl-connection-security', apis: ['servers:listPostgres'], + remediation_min_version: '202101041600', + remediation_description: 'The SSL enforcement option will be enabled for the affected PostreSQL servers', + apis_remediate: ['servers:listPostgres'], + actions: {remediate:['servers:update'], rollback:['servers:update']}, + permissions: {remediate: ['servers:update'], rollback: ['server:update']}, + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + 'PostgreSQL SSL connection should be used to ensure internal ' + @@ -56,5 +64,48 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'enforcePostgresSSLConnection'; + var baseUrl = 'https://management.azure.com{resource}?api-version=2017-12-01'; + var method = 'PATCH'; + + // for logging purposes + var serverNameArr = resource.split('/'); + var serverName = serverNameArr[serverNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'properties': { + 'sslEnforcement': 'Enabled' + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'SSLEnforcement': 'Disabled', + 'Server': serverName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/postgresqlserver/flexibleServerATP.js b/plugins/azure/postgresqlserver/flexibleServerATP.js new file mode 100644 index 000000000..1211a8968 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerATP.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Advanced Threat Protection', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures Advanced Threat Protection is enabled on PostgreSQL flexible servers.', + more_info: 'Enabling Advanced Threat Protection provides security alerts on anomalous activities, allowing you to detect potential threats and respond to them as they occur.', + recommended_action: 'Ensure Advanced Threat Protection is enabled for all PostgreSQL Flexible Servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-security#enable-enhanced-security-with-microsoft-defender-for-cloud', + apis: ['servers:listPostgresFlexibleServer', 'advancedThreatProtectionSettings:listPostgresFlexibleServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete','microsoftdbforpostgresql:flexibleservers:advancedthreatprotectionsettings:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + const advancedThreatProtectionSettings = helpers.addSource(cache, source, + ['advancedThreatProtectionSettings', 'listPostgresFlexibleServer', location, server.id]); + + if (!advancedThreatProtectionSettings || advancedThreatProtectionSettings.err || !advancedThreatProtectionSettings.data) { + helpers.addResult(results, 3, + 'Unable to query for Advanced Threat Protection settings: ' + helpers.addError(advancedThreatProtectionSettings), location, server.id); + } else { + if (!advancedThreatProtectionSettings.data.length) { + helpers.addResult(results, 2, 'No Advanced Threat Protection setting found', location, server.id); + } else { + let atpEnabled = advancedThreatProtectionSettings.data.find(threadProtectionSetting => + threadProtectionSetting.state && + threadProtectionSetting.state.toLowerCase() == 'enabled'); + if (atpEnabled) { + helpers.addResult(results, 0, + 'Advanced Threat Protection is enabled for PostgreSQL flexible server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Advanced Threat Protection is disabled for PostgreSQL flexible server', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerATP.spec.js b/plugins/azure/postgresqlserver/flexibleServerATP.spec.js new file mode 100644 index 000000000..416bccef9 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerATP.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var enableATP = require('./flexibleServerATP'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + } +]; + +const advancedThreatProtectionSettings = [ + { + id: "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + name: 'Default', + type: 'Microsoft.Sql/servers/advancedThreatProtectionSettings', + state: 'Enabled', + creationTime: '2022-05-12T10:08:23.127Z' + }, + { + id: "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + name: 'Default', + type: 'Microsoft.Sql/servers/advancedThreatProtectionSettings', + state: 'Disabled', + creationTime: '2022-05-12T10:08:23.127Z' + }, +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + advancedThreatProtectionSettings: { + listPostgresFlexibleServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('enableATP', function() { + describe('run', function() { + it('should give passing result if no PostgreSQL flexible servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give failing result if no Database Advanced Threat Protection settings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Advanced Threat Protection setting found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give failing result if Advanced Threat Protection for the PostgreSQL flexible server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Advanced Threat Protection is disabled for PostgreSQL flexible server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [advancedThreatProtectionSettings[1]] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give passing result if Advanced Threat Protection for the PostgreSQL flexible server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Advanced Threat Protection is enabled for PostgreSQL flexible server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [advancedThreatProtectionSettings[0]] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Database Advanced Threat Protection settings', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Database Advanced Threat Protection settings'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Database Advanced Threat Protection settings'} + ); + + enableATP.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.js b/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.js new file mode 100644 index 000000000..ceccab796 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.js @@ -0,0 +1,35 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL FLexible Server Connection Throttling Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that connection throttling is enabled for PostgreSQL flexible servers.', + more_info: 'Enabling connection_throttle parameter for PostgreSQL flexible servers mitigates the risk of brute-force attacks by temporarily blocking IP addresses with multiple failed login attempts, enhancing security and server stability.', + recommended_action: 'Ensures that server parameters for each PostgreSQL flexible server have connection_throttle setting enabled.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/how-to-configure-server-parameters-using-portal', + apis: ['servers:listPostgresFlexibleServer', 'flexibleServersConfigurations:listByPostgresServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete','microsoftdbforpostgresql:flexibleservers:configurations:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + helpers.checkFlexibleServerConfigs(servers, cache, source, location, results, 'PostgreSQL Flexible', 'connection_throttle.enable', 'Connection throttling'); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.spec.js b/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.spec.js new file mode 100644 index 000000000..e5145ea10 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerConnectionThrottle.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var auth = require('./flexibleServerConnectionThrottle'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + }, +] + +const configurations = [ + { + id: '/subscriptions/22345/resourceGroups/test2/providers/Microsoft.DBforPostgreSQL/flexibleServers/testfs/configurations/password_encryption', + name: 'connection_throttle.enable', + type: 'Microsoft.DBforPostgreSQL/flexibleServers/configurations', + value: 'ON', + description: 'Enables temporary connection throttling per IP for too many login failures.', + defaultValue: 'OFF', + dataType: 'Enumeration', + allowedValues: 'on,off', + source: 'user-override', + isDynamicConfig: true, + isReadOnly: false, + isConfigPendingRestart: false + }, + + { + id: '/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/testfs/configurations/password_encryption', + name: 'connection_throttle.enable', + type: 'Microsoft.DBforPostgreSQL/flexibleServers/configurations', + value: 'OFF', + description: 'Enables temporary connection throttling per IP for too many login failures.', + defaultValue: 'OFF', + dataType: 'Enumeration', + allowedValues: 'on,off', + source: 'user-override', + isDynamicConfig: true, + isReadOnly: false, + isConfigPendingRestart: false + } + + +] + +const createCache = (err, list, configuration) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + }, + flexibleServersConfigurations: { + listByPostgresServer: { + 'eastus': configuration + } + } + } +}; + +describe('flexibleServerConnectionThrottle', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Flexible Servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should give UNKNOWN if unable to query for PostgreSQL flexible Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + null, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should give UNKNOWN if unable to query for configurations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should FAIL if PostgreSQL server has connection throttle not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Connection throttling is disabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[1]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if PostgreSQL server has connection throttle enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Connection throttling is enabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[0]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.js b/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.js new file mode 100644 index 000000000..36c84cea9 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.js @@ -0,0 +1,66 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Diagnostic Logging', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures diagnostic logging is enabled for PostgreSQL flexible servers.', + more_info: 'Enabling diagnostic logging for Azure Database for PostgreSQL flexible servers helps with performance monitoring, troubleshooting, and security optimization.', + recommended_action: 'Enable diagnostic logging for all PostgreSQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-logging', + apis: ['servers:listPostgresFlexibleServer', 'diagnosticSettings:listByPostgresFlexibleServers'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write', 'microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete','microsoftdbforpostgresql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Flexible Servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Flexible Servers found', location); + return rcb(); + } + + for (let server of servers.data) { + if (!server.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByPostgresFlexibleServers', location, server.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for PostgreSQL Flexible Server diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, server.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'PostgreSQL Flexible Server has diagnostic logs enabled', location, server.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL Flexible Server does not have diagnostic logs enabled', location, server.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.spec.js b/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.spec.js new file mode 100644 index 000000000..d81b96e87 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerDiagnosticLogs.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var flexibleServerDiagnosticLogs = require('./flexibleServerDiagnosticLogs'); + +const servers = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/servers/test-server", + }, +]; + + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.DBforPostgreSQL/servers/test/providers/microsoft.insights/diagnosticSettings/test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'server-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (servers, ds) => { + const id = servers && servers.length ? servers[0].id : null; + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: servers + } + } + }, + diagnosticSettings: { + listByPostgresFlexibleServers: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('flexibleServerDiagnosticLogs', function() { + describe('run', function() { + it('should give a passing result if no PostgreSQL Servers are found', function (done) { + const cache = createCache([], null); + flexibleServerDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Flexible Servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function (done) { + const cache = createCache(null, ['error']); + flexibleServerDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Flexible Servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([servers[0]], null); + flexibleServerDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Flexible Server diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([servers[0]], [diagnosticSettings[0]]); + flexibleServerDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL Flexible Server has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([servers[0]], [[]]); + flexibleServerDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL Flexible Server does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); + diff --git a/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.js b/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.js new file mode 100644 index 000000000..aca4b7d27 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.js @@ -0,0 +1,35 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Log Disconnections Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that disconnection logs are enabled for PostgreSQL flexible servers.', + more_info: 'Enabling log_disconnections parameter records all activity data which helps in logging attempted and successful disconnections from the flexible server.', + recommended_action: 'Ensure that server parameters for each PostgreSQL flexible server have log_disconnections setting enabled.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/how-to-configure-server-parameters-using-portal', + apis: ['servers:listPostgresFlexibleServer', 'flexibleServersConfigurations:listByPostgresServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete','microsoftdbforpostgresql:flexibleservers:configurations:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + helpers.checkFlexibleServerConfigs(servers, cache, source, location, results, 'PostgreSQL Flexible', 'log_disconnections', 'Log disconnections'); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.spec.js b/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.spec.js new file mode 100644 index 000000000..c123e5ee2 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var auth = require('./flexibleServerLogDisconnections'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + }, +] + +const configurations = [ + { + "id": "/subscriptions/12345/resourceGroups/tests/providers/Microsoft.DBforPostgreSQL/servers/test1/configurations/log_checkpoints", + "name": "log_disconnections", + "type": "Microsoft.DBforPostgreSQL/servers/configurations", + "value": "on", + "description": "Logs each checkpoint.", + "defaultValue": "on", + "dataType": "Boolean", + "allowedValues": "on,off", + "source": "system-default", + "location": "ukwest", + "storageAccount": { + "name": "gioservertest1" + } + }, + + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/giost1/configurations/log_checkpoints", + "name": "log_disconnections", + "type": "Microsoft.DBforPostgreSQL/servers/configurations", + "value": "off", + "description": "Logs each checkpoint.", + "defaultValue": "on", + "dataType": "Boolean", + "allowedValues": "on,off", + "source": "system-default", + "location": "ukwest", + "storageAccount": { + "name": "gioservertest1" + } + } + + +] + +const createCache = (err, list, configuration) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + }, + flexibleServersConfigurations: { + listByPostgresServer: { + 'eastus': configuration + } + } + } +}; + +describe('flexibleServerLogDisconnections', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Flexible Servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should give UNKNOWN if unable to query for PostgreSQL flexible Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + null, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should give UNKNOWN if unable to query for configurations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should FAIL if PostgreSQL server has log disconnections not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log disconnections is disabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[1]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if PostgreSQL server has log disconnection enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Log disconnections is enabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[0]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerLogDuration.js b/plugins/azure/postgresqlserver/flexibleServerLogDuration.js new file mode 100644 index 000000000..69d60c49a --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerLogDuration.js @@ -0,0 +1,32 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL FLexible Server Log Duration Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that connection duration logs are enabled for PostgreSQL flexible servers.', + more_info: 'Enabling connection duration logs on PostgreSQL flexible servers allows for logging the duration of each completed SQL statement, aiding in performance monitoring, identifying long-running queries, and ensuring compliance with auditing requirements.', + recommended_action: 'Ensure the server parameters for each PostgreSQL flexible servers have the log_duration setting enabled.', + link: 'https://learn.microsoft.com/en-us/azure/flexible-server/how-to-configure-server-parameters-using-portal', + apis: ['servers:listPostgresFlexibleServer', 'flexibleServersConfigurations:listByPostgresServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete','microsoftdbforpostgresql:flexibleservers:configurations:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + helpers.checkFlexibleServerConfigs(servers, cache, source, location, results, 'PostgreSQL Flexible', 'log_duration', 'Duration logs'); + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerLogDuration.spec.js b/plugins/azure/postgresqlserver/flexibleServerLogDuration.spec.js new file mode 100644 index 000000000..04c753542 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerLogDuration.spec.js @@ -0,0 +1,167 @@ +var expect = require('chai').expect; +var auth = require('./flexibleServerLogDuration'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + }, +] + +const configurations = [ + { + "id": "/subscriptions/12345/resourceGroups/tests/providers/Microsoft.DBforPostgreSQL/servers/test1/configurations/log_checkpoints", + "name": "log_duration", + "type": "Microsoft.DBforPostgreSQL/servers/configurations", + "value": "on", + "description": "Logs each checkpoint.", + "defaultValue": "on", + "dataType": "Boolean", + "allowedValues": "on,off", + "source": "system-default", + "location": "ukwest", + "storageAccount": { + "name": "gioservertest1" + } + }, + + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/giost1/configurations/log_checkpoints", + "name": "log_duration", + "type": "Microsoft.DBforPostgreSQL/servers/configurations", + "value": "off", + "description": "Logs each checkpoint.", + "defaultValue": "on", + "dataType": "Boolean", + "allowedValues": "on,off", + "source": "system-default", + "location": "ukwest", + "storageAccount": { + "name": "gioservertest1" + } + } + + +] + +const createCache = (err, list, configuration) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + }, + flexibleServersConfigurations: { + listByPostgresServer: { + 'eastus': configuration + } + } + } +}; + +describe('flexibleServerLogDuration', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Flexible Servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should give UNKNOWN if unable to query for PostgreSQL flexible Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + null, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should give UNKNOWN if unable to query for configurations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should FAIL if PostgreSQL flexible server has Duration logs not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Duration logs is disabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[1]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if PostgreSQL server has Duration logs enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Duration logs is enabled for the PostgreSQL Flexible Server configuration'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[0]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.js b/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.js new file mode 100644 index 000000000..714ce2003 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.js @@ -0,0 +1,78 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Services Public Network Access Disabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that PostgreSQL flexible servers do not allow access to other Azure services.', + more_info: 'To secure your PostgreSQL flexible server, it is recommended to disable public network access. Instead, configure firewall rules to allow connections from specific network ranges or utilize VNET rules for access from designated virtual networks. This helps prevent unauthorized access from Azure services outside your subscription.', + recommended_action: 'Disable public network access for PostgreSQL database servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules', + apis: ['servers:listPostgresFlexibleServer', 'firewallRules:listByFlexibleServerPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write', 'microsoftdbforpostgresql:flexibleservers:firewallrules:write','microsoftdbforpostgresql:flexibleservers:firewallrules:delete','microsoftdbforpostgresql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + for (let postgresServer of servers.data) { + if (!postgresServer.id) continue; + + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByFlexibleServerPostgres', location, postgresServer.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query Postgres Flexible Server Firewall Rules: ' + helpers.addError(firewallRules), location, postgresServer.id); + continue; + } + + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing postgres Flexible Server Firewall Rules found', location, postgresServer.id); + continue; + } + + let accessToServices = true; + for (let rule of firewallRules.data) { + if (rule.name && rule.name.toLowerCase().includes('allowallazureservicesandresourceswithinazureips')) { + accessToServices = false; + break; + } + } + + if (accessToServices) { + helpers.addResult(results, 0, + 'Access to other Azure services is disabled for PostgreSQL flexible server', location, postgresServer.id); + } else { + helpers.addResult(results, 2, + 'Access to other Azure services is not disabled for PostgreSQL flexible server', location, postgresServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.spec.js b/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.spec.js new file mode 100644 index 000000000..362a8a17b --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerPrivateAccess.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var flexibleServerPrivateAccess = require('./flexibleServerPrivateAccess'); + +const listPostgresFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant/firewallRules/TestRule", + "name": "testRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + }, + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllAzureServicesAndResourcesWithinAzureIps_2023-12-1_16-2-47')", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + }, +]; +const createCache = (list, rules) => { + const serverId = (list && list.length) ? list[0].id : null; + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: list + } + } + }, + firewallRules: { + listByFlexibleServerPostgres: { + 'eastus': { + [serverId]: { + data: rules + } + } + } + } + } +}; + +describe('flexibleServerPrivateAccess', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + + flexibleServerPrivateAccess.run(cache, {}, callback); + }) + + it('should give failing result if postgresql flexiable server does not have access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Access to other Azure services is not disabled for PostgreSQL flexible server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgresFlexibleServer[0]], + [firewallRules[1]] + ); + + flexibleServerPrivateAccess.run(cache, {}, callback); + }); + + it('should give passing result if postgresql server not have access to azure services', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Access to other Azure services is disabled for PostgreSQL flexible server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgresFlexibleServer[0]], + [firewallRules[0]] + ); + + flexibleServerPrivateAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null + ); + + flexibleServerPrivateAccess.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerPrivateDns.js b/plugins/azure/postgresqlserver/flexibleServerPrivateDns.js new file mode 100644 index 000000000..cf5870c4f --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerPrivateDns.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Private DNS Zone Integrated', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that PostgreSQL flexible servers have private DNS zone integrated.', + more_info: 'Integrating Private DNS Zones with PostgreSQL flexible servers enhances DNS service reliability and security within your Azure virtual network, ensuring seamless DNS resolution and streamlined domain management.', + recommended_action: 'Ensure Vnet and private DNS zone (private access) is integrated for PostgreSQL flexible server.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-networking-private#using-private-dns-zone', + apis: ['servers:listPostgresFlexibleServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + for (let flexibleServer of servers.data) { + if (!flexibleServer.id) continue; + + if (flexibleServer.network && flexibleServer.network.privateDnsZoneArmResourceId) { + helpers.addResult(results, 0, 'PostgreSQL flexible server has private DNS zone integrated', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL flexible server does not have private DNS zone integrated', location, flexibleServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerPrivateDns.spec.js b/plugins/azure/postgresqlserver/flexibleServerPrivateDns.spec.js new file mode 100644 index 000000000..2df1b42ed --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerPrivateDns.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var flexibleServerPrivateDns = require('./flexibleServerPrivateDns'); + +const listPostgresFlexibleServer = [ +{ + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "network":{ + "publicNetworkAccess": "Enabled" + } +}, +{ + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server2", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "network":{ + "delegatedSubnetResourceId" : "/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/virtualNetworks/test/subnets/default", + "privateDnsZoneArmResourceId" : "/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/privateDnsZones/testflexibleserver11.private.postgres.database.azure.com", + "publicNetworkAccess": "Disabled" + } + +} + +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('flexibleServerPrivateDns', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + flexibleServerPrivateDns.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if private Dns zone is not integrated', function(done) { + const cache = createCache([listPostgresFlexibleServer[0]]); + flexibleServerPrivateDns.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL flexible server does not have private DNS zone integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give should give passing result if private Dns zone is integrated', function(done) { + const cache = createCache([listPostgresFlexibleServer[1]]); + flexibleServerPrivateDns.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL flexible server has private DNS zone integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + flexibleServerPrivateDns.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }) +}) diff --git a/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.js b/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.js new file mode 100644 index 000000000..c497420ac --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.js @@ -0,0 +1,64 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server SCRAM Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure PostgreSQL flexible servers are using SCRAM authentication protocol for password encryption.', + more_info: 'Using SCRAM (Salted Challenge Response Authentication Mechanism) enhances authentication security in PostgreSQL by defending against common password-based vulnerabilities, bolstering protection against credential interception and replay attacks.', + recommended_action: 'Modify PostgreSQL flexible server to use SCRAM for password encryption instead of MD5.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/how-to-connect-scram', + apis: ['servers:listPostgresFlexibleServer', 'flexibleServersConfigurations:listByPostgresServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete','microsoftdbforpostgresql:flexibleservers:configurations:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + const configurations = helpers.addSource(cache, source, + ['flexibleServersConfigurations', 'listByPostgresServer', location, flexibleServer.id]); + + if (!configurations || configurations.err || !configurations.data) { + helpers.addResult(results, 3, + 'Unable to query for configuration' + helpers.addError(configurations), location, flexibleServer.id); + continue; + } + + var configuration = configurations.data.find(config => { + return (config.name == 'password_encryption'); + }); + + if (configuration && configuration.value && configuration.value.toUpperCase().includes('SCRAM')) { + helpers.addResult(results, 0, 'PostgreSQL flexible server is using SCRAM authentication protocol', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL flexible server is not using SCRAM authentication protocol', location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.spec.js b/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.spec.js new file mode 100644 index 000000000..a014d0819 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerSCRAMEnabled.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var auth = require('./flexibleServerSCRAMEnabled'); + +const servers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + }, +] + +const configurations = [ + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourceGroups/meerab-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/testfs/configurations/password_encryption', + name: 'password_encryption', + type: 'Microsoft.DBforPostgreSQL/flexibleServers/configurations', + value: 'SCRAM-SHA-256', + description: 'Determines the algorithm to use to encrypt the password..', + defaultValue: 'md5', + dataType: 'Enumeration', + allowedValues: 'md5,scram-sha-256', + source: 'user-override', + isDynamicConfig: true, + isReadOnly: false, + isConfigPendingRestart: false + }, + + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourceGroups/meerab-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/testfs/configurations/password_encryption', + name: 'password_encryption', + type: 'Microsoft.DBforPostgreSQL/flexibleServers/configurations', + value: 'md5', + description: 'Determines the algorithm to use to encrypt the password..', + defaultValue: 'md5', + dataType: 'Enumeration', + allowedValues: 'md5,scram-sha-256', + source: 'user-override', + isDynamicConfig: true, + isReadOnly: false, + isConfigPendingRestart: false + } + + +] + +const createCache = (err, list, configuration) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: err, + data: list + } + } + }, + flexibleServersConfigurations: { + listByPostgresServer: { + 'eastus': configuration + } + } + } +}; + +describe('flexibleServerSCRAMEnabled', function() { + describe('run', function() { + it('should PASS if no existing servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [], + {} + ); + + auth.run(cache, {}, callback); + }); + + it('should give UNKNOWN if unable to query for PostgreSQL flexible Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + null, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should give UNKNOWN if unable to query for configurations', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + {} + ); + + auth.run(cache, {}, callback); + }) + + it('should FAIL if PostgreSQL server is not using SCRAM', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL flexible server is not using SCRAM authentication protocol'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[1]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if PostgreSQL server is using SCRAM', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL flexible server is using SCRAM authentication protocol'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + servers, + { + "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server": { + data: [configurations[0]] + } + } + ); + + auth.run(cache, {}, callback); + }); + + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.js b/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.js new file mode 100644 index 000000000..091f2103c --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server VNet Integrated', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that PostgreSQL flexible servers have VNet integrated.', + more_info: 'Configuring PostgreSQL flexible server to operate within a Virtual Network (VNet) offers a myriad of benefits for enhanced security and operational control. By integrating with a VNet allows to proactively safeguard your server against potential security threats and unauthorized access.', + recommended_action: 'Ensures VNet (private access) is integrated for PostgreSQL flexible server.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-networking-private', + apis: ['servers:listPostgresFlexibleServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete',], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + for (let flexibleServer of servers.data) { + if (!flexibleServer.id) continue; + if (flexibleServer.network && flexibleServer.network.delegatedSubnetResourceId) { + helpers.addResult(results, 0, 'PostgreSQL flexible server has VNet integrated', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL flexible server does not have VNet integrated', location, flexibleServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.spec.js b/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.spec.js new file mode 100644 index 000000000..6b62912df --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerVNetIntegrated.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var flexibleServerVNetIntegrated = require('./flexibleServerVNetIntegrated'); + +const listPostgresFlexibleServer = [ +{ + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "network":{ + "publicNetworkAccess": "Enabled" + } +}, +{ + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server2", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "network":{ + "delegatedSubnetResourceId" : "/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/virtualNetworks/test/subnets/default", + "privateDnsZoneArmResourceId" : "/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/privateDnsZones/testflexibleserver11.private.postgres.database.azure.com", + "publicNetworkAccess": "Disabled" + } + +} + +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('flexibleServerVNetIntegrated', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + flexibleServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Vnet is not configured', function(done) { + const cache = createCache([listPostgresFlexibleServer[0]]); + flexibleServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL flexible server does not have VNet integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give should give passing result if Vnet is configured', function(done) { + const cache = createCache([listPostgresFlexibleServer[1]]); + flexibleServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL flexible server has VNet integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + flexibleServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }) +}) diff --git a/plugins/azure/postgresqlserver/flexibleServerVersion.js b/plugins/azure/postgresqlserver/flexibleServerVersion.js new file mode 100644 index 000000000..d98cf54a8 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerVersion.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Version', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure PostgreSQL flexible servers is using the latest server version.', + more_info: 'Using the latest version of PostgreSQL for flexible servers will give access to new software features, resolve reported bugs through security patches, and improve compatibility with other applications and services.', + recommended_action: 'Upgrade the version of PostgreSQL flexible server to the latest available version.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-supported-versions', + apis: ['servers:listPostgresFlexibleServer'], + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write','microsoftdbforpostgresql:flexibleservers:delete',], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + for (var flexibleServer of servers.data) { + if (!flexibleServer.id || !flexibleServer.version) continue; + + let version = parseFloat(flexibleServer.version); + + if (version && version >= 16) { + helpers.addResult(results, 0, + 'PostgreSQL flexible server has the latest server version', location, flexibleServer.id); + } else { + helpers.addResult(results, 2, + 'PostgreSQL flexible server does not the latest server version', location, flexibleServer.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/flexibleServerVersion.spec.js b/plugins/azure/postgresqlserver/flexibleServerVersion.spec.js new file mode 100644 index 000000000..0dea33518 --- /dev/null +++ b/plugins/azure/postgresqlserver/flexibleServerVersion.spec.js @@ -0,0 +1,106 @@ +var expect = require('chai').expect; +var flexibleServerVersion = require('./flexibleServerVersion'); + +const listPostgresFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "version": '16' + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server1", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "version": '10' + } +]; + + +const createCache = (list) => { + return { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: list + } + } + } + } +}; + +describe('flexibleServerVersion', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache([]); + + flexibleServerVersion.run(cache, {}, callback); + }) + + it('should give failing result if postgresql flexiable server does nothave the latest version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL flexible server does not the latest server version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgresFlexibleServer[1]] + ); + + flexibleServerVersion.run(cache, {}, callback); + }); + + it('should give passing result if postgresql server have the latest version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL flexible server has the latest server version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listPostgresFlexibleServer[0]] + ); + + flexibleServerVersion.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null + ); + + flexibleServerVersion.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.js b/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.js new file mode 100644 index 000000000..7ce46fa53 --- /dev/null +++ b/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.js @@ -0,0 +1,58 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Enable Geo-Redundant Backups', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that your Microsoft Azure PostgreSQL database servers have geo-redundant backups enabled.', + more_info: 'Enabling geo-redundant backup storage for PostgreSQL database servers gives better protection and ability to restore your server in a different region in the event of a disaster.', + recommended_action: 'PostgreSQL servers does not support modifying geo-redundant storage configuration. ' + + 'You need to create a new server using current server\'s configuration with geo-redundant backup storage enabled ' + + 'and then delete the current PostgreSQL server', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/concepts-backup', + apis: ['servers:listPostgres'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + + for (let postgresServer of servers.data) { + if (postgresServer.storageProfile && + postgresServer.storageProfile.geoRedundantBackup && + postgresServer.storageProfile.geoRedundantBackup.toUpperCase() === 'ENABLED') { + helpers.addResult(results, 0, + 'The PostgreSQL Server has geo-redundant backup storage enabled', location, postgresServer.id); + } else { + helpers.addResult(results, 2, + 'The PostgreSQL Server does not have geo-redundant backup storage enabled', location, postgresServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.spec.js b/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.spec.js new file mode 100644 index 000000000..5714ff0d5 --- /dev/null +++ b/plugins/azure/postgresqlserver/geoRedundantBackupEnabled.spec.js @@ -0,0 +1,109 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var geoRedundantBackupEnabled = require('./geoRedundantBackupEnabled'); + +const listPostgres = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant", + "type": "Microsoft.DBforPostgreSQL/servers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + } + }, + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/akhtar-rg/providers/Microsoft.DBforPostgreSQL/servers/geo-redundant", + "type": "Microsoft.DBforPostgreSQL/servers", + "storageProfile": { + "storageMB": 5120, + "backupRetentionDays": 7, + "geoRedundantBackup": "Enabled", + "storageAutogrow": "Disabled" + } + } +]; +const createCache = (err, list) => { + return { + servers: { + listPostgres: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('geoRedundantBackupEnabled', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + geoRedundantBackupEnabled.run(cache, {}, callback); + }) + + it('should give failing result if postgresql server has geo-redundant backup storage enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL Server does not have geo-redundant backup storage enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [listPostgres[0]] + ); + + geoRedundantBackupEnabled.run(cache, {}, callback); + }); + + it('should give passing result if postgresql server does not have geo-redundant backup storage enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL Server has geo-redundant backup storage enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [listPostgres[1]] + ); + + geoRedundantBackupEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + { message: "Unable to list servers" }, + ); + + geoRedundantBackupEnabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/logCheckpointsEnabled.js b/plugins/azure/postgresqlserver/logCheckpointsEnabled.js index 1254b2392..1c119af0b 100644 --- a/plugins/azure/postgresqlserver/logCheckpointsEnabled.js +++ b/plugins/azure/postgresqlserver/logCheckpointsEnabled.js @@ -4,16 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Checkpoints Enabled', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures log checkpoints are enabled for PostgreSQL servers', more_info: 'Log checkpoints logs queries and errors that arise in the server, enabling faster detection of incidents.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the log_checkpoints setting enabled.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], compliance: { hipaa: 'HIPAA requires that a secure audit log record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/logConnectionsEnabled.js b/plugins/azure/postgresqlserver/logConnectionsEnabled.js index 3ff423cfe..0f3537bb0 100644 --- a/plugins/azure/postgresqlserver/logConnectionsEnabled.js +++ b/plugins/azure/postgresqlserver/logConnectionsEnabled.js @@ -4,16 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Connections Enabled', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures connection logs are enabled for PostgreSQL servers', more_info: 'Connection logs ensure all attempted and successful connections to the server are logged.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the log_connections setting enabled.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], compliance: { hipaa: 'HIPAA requires that a secure audit log record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/logDisconnectionsEnabled.js b/plugins/azure/postgresqlserver/logDisconnectionsEnabled.js index df52f04ff..13712dcf4 100644 --- a/plugins/azure/postgresqlserver/logDisconnectionsEnabled.js +++ b/plugins/azure/postgresqlserver/logDisconnectionsEnabled.js @@ -4,16 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Disconnections Enabled', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures disconnection logs are enabled for PostgreSQL servers', more_info: 'Disconnection logs ensure all attempted and successful disconnections from the server are logged.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the log_disconnections setting enabled.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], compliance: { hipaa: 'HIPAA requires that a secure audit log record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/logDurationEnabled.js b/plugins/azure/postgresqlserver/logDurationEnabled.js index d8a34476a..294c8ec6f 100644 --- a/plugins/azure/postgresqlserver/logDurationEnabled.js +++ b/plugins/azure/postgresqlserver/logDurationEnabled.js @@ -4,16 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Duration Enabled', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures connection duration logs are enabled for PostgreSQL servers', more_info: 'Connection duration logs log duration times of connections to the server and can be used to locate suspicious long-running connections.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the log_duration setting enabled.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], compliance: { hipaa: 'HIPAA requires that a secure audit log record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/logRetentionDays.js b/plugins/azure/postgresqlserver/logRetentionDays.js index eafb535ce..dc814cc60 100644 --- a/plugins/azure/postgresqlserver/logRetentionDays.js +++ b/plugins/azure/postgresqlserver/logRetentionDays.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Retention Period', category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures logs are configured to be retained for 4 or more days for PostgreSQL servers', more_info: 'Having a long log retention policy ensures that all critical logs are stored for long enough to access and view in case of a security incident.', recommended_action: 'Ensure the server parameters for each PostgreSQL server have the log_retention_days setting set to 4 or more days.', - link: 'https://docs.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-server-parameters-using-portal', apis: ['servers:listPostgres', 'configurations:listByServer'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete','microsoft.dbforpostgresql:servers:configurations:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.js b/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.js new file mode 100644 index 000000000..1b5cfb8cc --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Encryption At Rest with BYOK', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensure that Azure PostgreSQL Database Servers data is encrypted with CMK.', + more_info: 'Data at rest encryption with BYOK ensures that your PostgreSQL server data is protected using a key that you manage. Enabling BYOK adds an extra layer of security by allowing you to control access to the encryption keys.', + recommended_action: 'Enable CMK encryotion for PostgreSQL database servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/single-server/concepts-data-encryption-postgresql', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers:' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + + for (let server of servers.data) { + if (!server.id) continue; + + if (server.byokEnforcement && server.byokEnforcement.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, 'PostgreSQL server is encrypted using CMK', location, server.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL server is not encrypted using CMK', location, server.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.spec.js b/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.spec.js new file mode 100644 index 000000000..70bcf7399 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlCMKEncrypted.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var postgresqlCMKEncrypted = require('./postgresqlCMKEncrypted'); + +const listPostgres = [ + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': {}, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Disabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Enabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Enabled', + 'publicNetworkAccess': 'Enabled' + } +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgres: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('postgresqlCMKEncrypted', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + postgresqlCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if PostgreSQL Server is not encrypted using CMK', function(done) { + const cache = createCache([listPostgres[0]]); + postgresqlCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL server is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if PostgreSQL Server is encrypted using CMK', function(done) { + const cache = createCache([listPostgres[1]]); + postgresqlCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL server is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + postgresqlCMKEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js new file mode 100644 index 000000000..52b39bd27 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js @@ -0,0 +1,106 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Public Access', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that PostgreSQL flexible servers do not allow public access', + more_info: 'Configuring public access for PostgreSQL flexible server instance allows the server to be accessible through public endpoint. PostgreSQL flexible server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each PostgreSQL flexible server is configured to prohibit traffic from the public address.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules', + apis: ['servers:listPostgresFlexibleServer', 'firewallRules:listByFlexibleServerPostgres'], + settings: { + server_firewall_end_ip: { + name: 'PostgreSQL Server Firewall Rule End IP', + description: 'Comma separated list of IP addresses which cannot be end IPs for firewall rule', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write', 'microsoftdbforpostgresql:flexibleservers:firewallrules:write', 'microsoftdbforpostgresql:flexibleservers:firewallrules:delete', 'microsoftdbforpostgresql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var config = { + server_firewall_end_ip: settings.server_firewall_end_ip || this.settings.server_firewall_end_ip.default + }; + + var checkEndIp = (config.server_firewall_end_ip.length > 0); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + + if (server.network && server.network.publicNetworkAccess && server.network.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'The PostgreSQL flexible server has public network access disabled', location, server.id); + + } else { + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByFlexibleServerPostgres', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query PostgreSQL Flexible Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Flexible Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && endIpAddr) { + if (checkEndIp) { + if (startIpAddr.toString().indexOf('0.0.0.0') > -1 && + config.server_firewall_end_ip.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else if (startIpAddr.toString() === '0.0.0.0' && + (endIpAddr.toString() === '255.255.255.255' || endIpAddr.toString() === '0.0.0.0')) { + publicAccess = true; + } + } + }); + + if (publicAccess) { + helpers.addResult(results, 2, 'The PostgreSQL flexible server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The PostgreSQL flexible server is protected from outside traffic', location, server.id); + } + } + } + + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js new file mode 100644 index 000000000..72101d942 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var postgresqlFlexibleServerPublicAccess = require('./postgresqlFlexibleServerPublicAccess'); + +const listPostgresFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server-2", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "network": { + "publicNetworkAccess": "Enabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server-3", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowIPv6", + "name": "AllowIPv6", + "startIpAddress": "::", + "endIpAddress": "::" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowIPv6Alt", + "name": "AllowIPv6Alt", + "startIpAddress": "::/0", + "endIpAddress": "::/0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/RestrictedIP", + "name": "RestrictedIP", + "startIpAddress": "0.0.0.0", + "endIpAddress": "192.168.1.1" + } +]; + +const createCache = (servers, rules1) => { + const serverId1 = (servers && servers.length > 0) ? servers[0].id : null; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: servers + } + } + }, + firewallRules: { + listByFlexibleServerPostgres: { + 'eastus': {} + } + } + }; + + if (serverId1) { + cache.firewallRules.listByFlexibleServerPostgres.eastus[serverId1] = { + data: rules1 || [] + }; + } + + return cache; +}; + +describe('postgresqlFlexibleServerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public network access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL flexible server has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[0]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but no firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Flexible Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], []); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule with restricted end IP', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[4]]); + postgresqlFlexibleServerPublicAccess.run(cache, {server_firewall_end_ip: '192.168.1.1'}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (full range)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[0]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (0.0.0.0-0.0.0.0)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[1]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL flexible servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: 'Error querying servers' + } + } + } + }; + + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query PostgreSQL Flexible Server Firewall Rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: [listPostgresFlexibleServer[1]] + } + } + }, + firewallRules: { + listByFlexibleServerPostgres: { + 'eastus': { + [listPostgresFlexibleServer[1].id]: { + err: 'Error querying firewall rules' + } + } + } + } + }; + + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.js b/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.js new file mode 100644 index 000000000..b9768286d --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Infrastructure Double Encryption', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures Infrastructure double encryption is enabled for PostgreSQL Database Servers.', + more_info: 'Double Encryption adds layer of encryption is implemented at the hardware level before the storage or network level. Information will be encrypted before it is even accessed, preventing both interception of data in motion if the network layer encryption is broken and data at rest in system resources such as memory or processor cache.', + recommended_action: 'Enable Infrastructure double encryotion for PostgreSQL database servers.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-double-encryption', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL servers found', location); + return rcb(); + } + + for (let postgresqlDB of servers.data) { + if (!postgresqlDB.id) continue; + + if (postgresqlDB.infrastructureEncryption && + postgresqlDB.infrastructureEncryption.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, + 'Infrastructure double encryption is enabled for PostgreSQL server', location, postgresqlDB.id); + } else { + helpers.addResult(results, 2, + 'Infrastructure double encryption is not enabled for PostgreSQL server', location, postgresqlDB.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.spec.js b/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.spec.js new file mode 100644 index 000000000..2e6890d29 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlInfraDoubleEncryption.spec.js @@ -0,0 +1,107 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auth = require('./postgresqlInfraDoubleEncryption'); + +const createCache = (err, data) => { + return { + servers: { + listPostgres: { + 'eastus': { + err: err, + data: data + } + } + } + } +}; + +describe('postgresqlInfraDoubleEncryption', function() { + describe('run', function() { + it('should PASS if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [] + ); + + auth.run(cache, {}, callback); + }) + + it('should FAIL if postgresql server has Infrastructure Double Encryption disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Infrastructure double encryption is not enabled for PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.DBforPostgreSQL/servers/gioservertest1", + "name": "gioservertest1", + "type": "Microsoft.DBforPostgreSQL/servers", + "location": "ukwest", + "tags": { + "environment": "dev" + }, + "sku": { + "name": "B_Gen5_1", + "tier": "Basic", + "capacity": 1, + "family": "Gen5" + }, + "infrastructureEncryption": 'Disabled', + } + ] + ); + + auth.run(cache, {}, callback); + }); + + it('should PASS if postgresql server has Infrastructure Double Encryption enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Infrastructure double encryption is enabled for PostgreSQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.DBforPostgreSQL/servers/gioservertest1", + "name": "gioservertest1", + "type": "Microsoft.DBforPostgreSQL/servers", + "location": "ukwest", + "tags": { + "environment": "dev" + }, + "sku": { + "name": "B_Gen5_1", + "tier": "Basic", + "capacity": 1, + "family": "Gen5" + }, + "infrastructureEncryption": 'Enabled', + } + ] + ); + + auth.run(cache, {}, callback); + }); + + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.js b/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.js new file mode 100644 index 000000000..a94fc753e --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.js @@ -0,0 +1,52 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Private Endpoints Configured', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that PostgreSQL Servers are accessible only through private endpoints', + more_info: 'Azure Private Endpoint is a network interface that connects you privately and securely to a service powered by Azure Private Link. Private Endpoint uses a private IP address from your VNet, effectively bringing the service such as Azure SQL Server into your VNet.', + recommended_action: 'Ensure that Private Endpoints are configured properly and Public Network Access is disabled for PostgreSQL Server', + link: 'https://learn.microsoft.com/en-us/azure/private-link/private-link-overview', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No PostgreSQL servers found', location); + return rcb(); + } + + for (const server of servers.data) { + if (server.privateEndpointConnections && server.privateEndpointConnections.length) { + helpers.addResult(results, 0, 'Private Endpoints are configured for the PostgreSQL Server', location, server.id); + } else { + helpers.addResult(results, 2, 'Private Endpoints are not configured for the PostgreSQL Server', location, server.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.spec.js b/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.spec.js new file mode 100644 index 000000000..9a8ef0339 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +var privateEndpoints = require('./postgresqlPrivateEndpoints'); + +const listPostgres = [ + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'test', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLS1_0', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'test', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLS1_2', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/privateEndpointConnections/test-endpoint', + 'provisioningState': 'Ready' + } + ], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + } + +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgres: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('privateEndpoints', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + privateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if private endpoints are not configured', function(done) { + const cache = createCache([listPostgres[0]]); + privateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Private Endpoints are not configured for the PostgreSQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give should give passing result if private endpoints are configured', function(done) { + const cache = createCache([listPostgres[1]]); + privateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Private Endpoints are configured for the PostgreSQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + privateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL servers: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlServerHasTags.js b/plugins/azure/postgresqlserver/postgresqlServerHasTags.js new file mode 100644 index 000000000..c1cac923a --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Has Tags', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Azure PostgreSQL servers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify PostgreSQL servers and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const listPostgres = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!listPostgres) return rcb(); + + if (listPostgres.err || !listPostgres.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers: ' + helpers.addError(listPostgres), location); + return rcb(); + } + + if (!listPostgres.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + for (let postgresServer of listPostgres.data) { + if (!postgresServer.id) continue; + + if (postgresServer.tags && Object.entries(postgresServer.tags).length > 0){ + helpers.addResult(results, 0, 'PostgreSQL Server has tags associated', location, postgresServer.id); + } else { + helpers.addResult(results, 2, 'PostgreSQL Server does not have tags associated', location, postgresServer.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/postgresqlServerHasTags.spec.js b/plugins/azure/postgresqlserver/postgresqlServerHasTags.spec.js new file mode 100644 index 000000000..4af305687 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerHasTags.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var storage = require('./postgresqlServerHasTags'); + +const listPostgres = [ + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': {}, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Disabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + } +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgres: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('postgresqlServerHasTags', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if PostgreSQL Server does not have tags associated', function(done) { + const cache = createCache([listPostgres[1]]); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL Server does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if PostgreSQL Server has tags associated', function(done) { + const cache = createCache([listPostgres[0]]); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL Server has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL Servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js new file mode 100644 index 000000000..4e8f89633 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js @@ -0,0 +1,109 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Public Access', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that PostgreSQL servers do not allow public access', + more_info: 'Configuring public access for PostgreSQL server instance allows the server to be accessible through public endpoint. PostgreSQL server server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each PostgreSQL server is configured to prohibit traffic from the public address.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/concepts-firewall-rules', + apis: ['servers:listPostgres', 'firewallRules:listByServerPostgres'], + settings: { + postgresql_server_allowed_ips: { + name: 'PostgreSQL Server Allowed IPs', + description: 'Comma-separated list of customer defined IP addresses/ranges that are allowed to access PostgreSQL servers', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write', 'microsoftdbforpostgresql:servers:firewallrules:write', 'microsoftdbforpostgresql:servers:firewallrules:delete', 'microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + var config = { + postgresql_server_allowed_ips: settings.postgresql_server_allowed_ips || this.settings.postgresql_server_allowed_ips.default + }; + + var allowedIps = []; + if (config.postgresql_server_allowed_ips && config.postgresql_server_allowed_ips.length > 0) { + allowedIps = config.postgresql_server_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + if (!server.id) return; + + if (server.publicNetworkAccess && server.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'The PostgreSQL server has public network access disabled', location, server.id); + } else { + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByServerPostgres', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query PostgreSQL Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { + if (checkAllowedIps) { + if (endIpAddr && allowedIps.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else { + if (endIpAddr && endIpAddr.toString() === '255.255.255.255') { + publicAccess = true; + } else if (endIpAddr && endIpAddr.toString() === '0.0.0.0') { + publicAccess = true; + } + } + } + }); + + if (publicAccess) { + helpers.addResult(results, 2, 'The PostgreSQL server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The PostgreSQL server is protected from outside traffic', location, server.id); + } + } + } + } + + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js new file mode 100644 index 000000000..e8a9ab94b --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js @@ -0,0 +1,298 @@ +var expect = require('chai').expect; +var postgresqlServerPublicAccess = require('./postgresqlServerPublicAccess'); + +const listPostgresServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server", + "type": "Microsoft.DBforPostgreSQL/servers", + "publicNetworkAccess": "Disabled" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server-2", + "type": "Microsoft.DBforPostgreSQL/servers", + "publicNetworkAccess": "Enabled" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server-3", + "type": "Microsoft.DBforPostgreSQL/servers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/TestRule", + "name": "TestRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "192.168.1.1", + "endIpAddress": "192.168.1.10" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/CustomerIP", + "name": "CustomerIP", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "10.0.0.1", + "endIpAddress": "10.0.0.1" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAllWindowsAzureIPs", + "name": "AllowAllWindowsAzureIPs", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/CustomerDefinedRule", + "name": "CustomerDefinedRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "10.0.0.1" + } +]; + +const createCache = (servers, rules1, rules2) => { + const serverId1 = (servers && servers.length > 0) ? servers[0].id : null; + const serverId2 = (servers && servers.length > 1) ? servers[1].id : null; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + data: servers + } + } + }, + firewallRules: { + listByServerPostgres: { + 'eastus': {} + } + } + }; + + if (serverId1) { + cache.firewallRules.listByServerPostgres.eastus[serverId1] = { + data: rules1 || [] + }; + } + + if (serverId2) { + cache.firewallRules.listByServerPostgres.eastus[serverId2] = { + data: rules2 || [] + }; + } + + return cache; +}; + +describe('postgresqlServerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public network access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL server has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but no firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], []); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but restrictive firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (full range)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[1]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (0.0.0.0-0.0.0.0)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[2]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has AllowAllWindowsAzureIPs firewall rule', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[4]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has customer defined IP in firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[3]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + err: 'Error querying servers' + } + } + } + }; + + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query PostgreSQL Server Firewall Rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + data: [listPostgresServer[1]] + } + } + }, + firewallRules: { + listByServerPostgres: { + 'eastus': { + [listPostgresServer[1].id]: { + err: 'Error querying firewall rules' + } + } + } + } + }; + + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should check firewall rules if server has no publicNetworkAccess property', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule with customer defined IP as end address', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[5]]); + postgresqlServerPublicAccess.run(cache, {postgresql_server_allowed_ips: '10.0.0.1'}, callback); + }); + + it('should give passing result if server has firewall rule with customer defined IP but not matching allowed IPs', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[5]]); + postgresqlServerPublicAccess.run(cache, {postgresql_server_allowed_ips: '192.168.1.1'}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlTlsVersion.js b/plugins/azure/postgresqlserver/postgresqlTlsVersion.js new file mode 100644 index 000000000..8a3cf98db --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlTlsVersion.js @@ -0,0 +1,70 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Minimum TLS Version', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures Microsoft Azure PostgreSQL Servers do not allow outdated TLS certificate versions.', + more_info: 'TLS 1.2 or higher should be used for all TLS connections to Microsoft Azure PostgreSQL server. This setting applies to all databases associated with the server.', + recommended_action: 'Modify PostgreSQL server to use TLS version 1.2 or higher.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/single-server/how-to-tls-configurations', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No PostgreSQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + if (!server.id) return; + + if (server.minimalTlsVersion && server.minimalTlsVersion !== 'TLSEnforcementDisabled') { + const tlsVersionRegex = /^TLS\d+_\d+$/; + if (!tlsVersionRegex.test(server.minimalTlsVersion)) { + helpers.addResult(results, 2, 'Postgresql server TLS version cannot be parsed', location, server.id); + } else { + var numericTlsVersion = parseFloat(server.minimalTlsVersion.replace('TLS', '').replace('_', '.')); + if (numericTlsVersion >= 1.2) { + helpers.addResult(results, 0, + 'PostgreSQL server is using TLS version 1.2 or higher', + location, server.id); + } else { + helpers.addResult(results, 2, + 'PostgreSQL server is not using TLS version 1.2', + location, server.id); + } + } + } else { + helpers.addResult(results, 2, + 'PostgreSQL server allows all TLS versions', + location, server.id); + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlTlsVersion.spec.js b/plugins/azure/postgresqlserver/postgresqlTlsVersion.spec.js new file mode 100644 index 000000000..71318fda8 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlTlsVersion.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var postgresqlTlsVersion = require('./postgresqlTlsVersion'); + +const listPostgres = [ + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLS1_0', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': { "key": "value" }, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLS1_2', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': {}, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Disabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + } +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgres: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('postgresqlTlsVersion', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + postgresqlTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if PostgreSQL Server is using TLS version less than desired TLS version', function(done) { + const cache = createCache([listPostgres[0]]); + postgresqlTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL server is not using TLS version 1.2'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if PostgreSQL Server is using TLS version equal to or higher than desired TLS version', function(done) { + const cache = createCache([listPostgres[1]]); + postgresqlTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL server is using TLS version 1.2 or higher'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give failing result if PostgreSQL Server allows all TLS versions', function(done) { + const cache = createCache([listPostgres[2]]); + postgresqlTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL server allows all TLS versions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give UnKnown result if unable to query postgreSQL Server', function(done) { + const cache = createCache(null); + postgresqlTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for PostgreSQL servers: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }) +}) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.js b/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.js new file mode 100644 index 000000000..e2f4c7bc2 --- /dev/null +++ b/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Auto-Growth Enabled', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Storage Auto-Growth feature is enabled for Microsoft Azure PostgreSQL servers.', + more_info: 'Storage auto grow prevents your server from reaching the storage limit and becoming read-only. For servers with 100 GB or less of provisioned storage, the size is increased by 5 GB when the free space is below 10%. For servers with more than 100 GB of provisioned storage, the size is increased by 5% when the free space is below 10 GB.', + recommended_action: 'Modify PostgreSQL servers to enable storage auto-growth feature', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-auto-grow-storage-portal', + apis: ['servers:listPostgres'], + realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + + const listPostgres = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!listPostgres) return rcb(); + + if (listPostgres.err || !listPostgres.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL Servers: ' + helpers.addError(listPostgres), location); + return rcb(); + } + + if (!listPostgres.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Servers found', location); + return rcb(); + } + + for (let postgresServer of listPostgres.data) { + if (postgresServer.storageProfile && + postgresServer.storageProfile.storageAutogrow && + postgresServer.storageProfile.storageAutogrow.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, + 'Storage Auto Growth is enabled for PostgreSQL Server', location, postgresServer.id); + } else { + helpers.addResult(results, 2, + 'Storage Auto Growth is not enabled for PostgreSQL Server', location, postgresServer.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.spec.js b/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.spec.js new file mode 100644 index 000000000..f6140b51b --- /dev/null +++ b/plugins/azure/postgresqlserver/storageAutoGrowthEnabled.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var storage = require('./storageAutoGrowthEnabled'); + +const listPostgres = [ + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': {}, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Enabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + }, + { + 'sku': { + 'name': 'B_Gen5_1', + 'tier': 'Basic', + 'family': 'Gen5', + 'capacity': 1 + }, + 'location': 'eastus', + 'tags': {}, + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.DBforPostgreSQL/servers/server1', + 'name': 'server1', + 'type': 'Microsoft.DBforPostgreSQL/servers', + 'administratorLogin': 'Khulnasoftadmin', + 'storageProfile': { + 'storageMB': 5120, + 'backupRetentionDays': 7, + 'geoRedundantBackup': 'Disabled', + 'storageAutogrow': 'Disabled' + }, + 'version': '11', + 'sslEnforcement': 'Enabled', + 'minimalTlsVersion': 'TLSEnforcementDisabled', + 'userVisibleState': 'Ready', + 'fullyQualifiedDomainName': 'server1.postgres.database.azure.com', + 'earliestRestoreDate': '2021-03-10T12:45:13.233+00:00', + 'replicationRole': '', + 'masterServerId': '', + 'byokEnforcement': 'Disabled', + 'privateEndpointConnections': [], + 'infrastructureEncryption': 'Disabled', + 'publicNetworkAccess': 'Enabled' + } +]; + +const createCache = (listPostgres) => { + return { + servers: { + listPostgres: { + 'eastus': { + data: listPostgres + } + } + } + }; +}; + +describe('storageAutoGrowth', function() { + describe('run', function() { + it('should give passing result if no servers', function(done) { + const cache = createCache({}); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing PostgreSQL Servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if storage auto growth is not enabled for postgresql server', function(done) { + const cache = createCache([listPostgres[1]]); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Auto Growth is not enabled for PostgreSQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if storage auto growth is enabled for postgresql server', function(done) { + const cache = createCache([listPostgres[0]]); + storage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Auto Growth is enabled for PostgreSQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/queueservice/queueServiceAllAccessAcl.js b/plugins/azure/queueservice/queueServiceAllAccessAcl.js index b74848fb0..cd47a4258 100755 --- a/plugins/azure/queueservice/queueServiceAllAccessAcl.js +++ b/plugins/azure/queueservice/queueServiceAllAccessAcl.js @@ -5,11 +5,13 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Queue Service All Access ACL', category: 'Queue Service', + domain: 'Application Integration', + severity: 'High', description: 'Ensures queues do not allow full write, delete, or read ACL permissions', more_info: 'Queues can be configured to allow object read, write or delete. This option should not be configured unless there is a strong business requirement.', recommended_action: 'Disable global read, write, delete policies on all queues and ensure the ACL is configured with least privileges.', - link: 'https://docs.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-portal', - apis: ['storageAccounts:list', 'storageAccounts:listKeys', 'queueService:listQueuesSegmented', 'queueService:getQueueAcl'], + link: 'https://learn.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-portal', + apis: ['storageAccounts:list', 'storageAccounts:listKeys', 'queueService:listQueuesSegmentedNew', 'queueService:getQueueAcl'], compliance: { hipaa: 'HIPAA access controls require data to be secured with least-privileged ' + 'ACLs. Queue Service ACLs enable granular permissions for data access.', @@ -70,26 +72,22 @@ module.exports = { } else { var acl = getQueueAcl.data; var fullPermissions = []; - if (acl.signedIdentifiers && Object.keys(acl.signedIdentifiers).length) { for (var ident in acl.signedIdentifiers) { - var permissions = acl.signedIdentifiers[ident].Permissions; + var permissions = acl.signedIdentifiers[ident].accessPolicy.permissions; for (var i = 0; i <= permissions.length; i++) { switch (permissions.charAt(i)) { // case "r": // fullPermissions.push('read'); // break; - case 'c': - fullPermissions.push(`create (via identifier ${ident})`); - break; - case 'w': - fullPermissions.push(`write (via identifier ${ident})`); + case 'a': + fullPermissions.push(`add (via identifier ${ident})`); break; - case 'd': - fullPermissions.push(`delete (via identifier ${ident})`); + case 'u': + fullPermissions.push(`update (via identifier ${ident})`); break; - case 'l': - fullPermissions.push(`list (via identifier ${ident})`); + case 'p': + fullPermissions.push(`process (via identifier ${ident})`); break; default: break; diff --git a/plugins/azure/queueservice/queueServiceAllAccessAcl.spec.js b/plugins/azure/queueservice/queueServiceAllAccessAcl.spec.js new file mode 100644 index 000000000..a5aed8a83 --- /dev/null +++ b/plugins/azure/queueservice/queueServiceAllAccessAcl.spec.js @@ -0,0 +1,205 @@ +var expect = require('chai').expect; +var queueServiceAllAccessAcl = require('./queueServiceAllAccessAcl'); + +const storageAccounts = [ + { + sku: [Object], + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + tags: [Object], + privateEndpointConnections: [], + minimumTlsVersion: 'TLS1_2', + allowBlobPublicAccess: false, + networkAcls: [Object], + supportsHttpsTrafficOnly: true, + encryption: [Object], + accessTier: 'Hot', + provisioningState: 'Succeeded', + creationTime: '2021-03-09T16:54:18.2838672Z', + primaryEndpoints: [Object], + primaryLocation: 'eastus', + statusOfPrimary: 'available' + } +]; + +const listKeys = [ + { + keyName: 'key1', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, + { + keyName: 'key2', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, +]; + +const listQueuesSegmented = [ + { + "name": "queuecs" + } +]; + +const getQueueAcl = [ + { + name: 'queuecs', + signedIdentifiers: [{ accessPolicy: { permissions: 'raup' } }] + }, + { + name: 'queuecs', + signedIdentifiers: {} + }, + { + name: 'queuecs', + signedIdentifiers: [{ accessPolicy: { permissions: 'cwdl' } }] + }, +]; + +const createCache = (list, listKeys, segments, acl, keysErr) => { + var id = (list && list.length) ? list[0].id : null; + var segmentName = (segments && segments.length) ? segments[0].name : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: list + }, + }, + listKeys: { + 'eastus': { + [id]: { + err: keysErr, + data: listKeys + }, + }, + }, + }, + queueService: { + listQueuesSegmented: { + 'eastus': { + [id]: { + data: segments + } + } + }, + getQueueAcl: { + 'eastus': { + [id + '/queueService/' + segmentName]: { + data: acl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': { + err: { + message: 'error while listing storageAccounts' + }, + }, + }, + listKeys: { + 'eastus': { + err: { + message: 'error while listing storageAccount keys' + }, + }, + }, + }, + queueService: { + listQueuesSegmented: { + 'eastus': { + err: { + message: 'Unable to list queue Segments' + } + } + }, + getQueueAcl: { + 'eastus': { + err: { + message: 'Unable to get Queue Acl' + } + } + } + } + }; +}; + +describe('queueServiceAllAccessAcl', function () { + describe('run', function () { + it('should PASS if Queue ACL does not contain full access permissions', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listQueuesSegmented[0]], [getQueueAcl[2]]); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Queue ACL has not been configured', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listQueuesSegmented[0]], getQueueAcl[1]); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if Queue ACL allows', function (done) { + const cache = createCache([storageAccounts[0]], [listKeys[0]], [listQueuesSegmented[0]], getQueueAcl[0]); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if No storage accounts found', function (done) { + const cache = createCache([]); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for storage accounts', function (done) { + const cache = createErrorCache(); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for Queue Service using Storage Account SAS', function (done) { + const cache = createErrorCache(); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for Queue Service', function (done) { + const cache = createErrorCache(); + queueServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); + diff --git a/plugins/azure/recoveryService/recoveryVaultByokEncrypted.js b/plugins/azure/recoveryService/recoveryVaultByokEncrypted.js new file mode 100644 index 000000000..3bb8cdae5 --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultByokEncrypted.js @@ -0,0 +1,64 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Recovery Services Vault BYOK Encrypted', + category: 'Recovery Service Vault', + domain: 'Backup', + severity: 'High', + description: 'Ensure that Microsoft Azure Recovery Services Vaults have BYOK encryption enabled.', + more_info: 'A customer-managed key gives you the ownership to bring your own key in Azure Key Vault. When you enable a customer-managed key, you can manage its rotations, control the access and permissions to use it, and audit its use.', + recommended_action: 'Modify Recovery Service vault and enable BYOK encryption.', + link: 'https://learn.microsoft.com/en-us/azure/backup/encryption-at-rest-with-cmk', + apis: ['recoveryServiceVaults:getRecoveryServiceVault', 'recoveryServiceVaults:listBySubscriptionId'], + realtime_triggers: ['microsoftrecoveryservices:vaults:write', 'microsoftrecoveryservices:vaults:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.recoveryServiceVaults, (location, rcb) => { + const serviceVaults = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'listBySubscriptionId', location]); + + if (!serviceVaults) return rcb(); + + if (serviceVaults.err || !serviceVaults.data) { + helpers.addResult(results, 3, + 'Unable to list Recovery Service Vaults: ' + helpers.addError(serviceVaults), location); + return rcb(); + } + + if (!serviceVaults.data.length) { + helpers.addResult(results, 0, 'No Recovery Service Vaults found', location); + return rcb(); + } + + for (let vault of serviceVaults.data) { + if (!vault.id) continue; + + const getVault = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'getRecoveryServiceVault', location, vault.id]); + + if (!getVault.data || getVault.err) { + helpers.addResult(results, 3, + 'Unable to query for get Recovery Service Vault: ' + helpers.addError(getVault), location, vault.id); + continue; + } + + if (getVault.data.encryption && getVault.data.encryption.keyVaultProperties && + getVault.data.encryption.keyVaultProperties.keyUri) { + helpers.addResult(results, 0, 'Recovery Service Vault has BYOK encryption enabled', location, getVault.id); + } else { + helpers.addResult(results, 2, 'Recovery Service Vault does not have BYOK encryption enabled', location, getVault.id); + } + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/recoveryService/recoveryVaultByokEncrypted.spec.js b/plugins/azure/recoveryService/recoveryVaultByokEncrypted.spec.js new file mode 100644 index 000000000..cc538a273 --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultByokEncrypted.spec.js @@ -0,0 +1,112 @@ +var expect = require('chai').expect; +var recoveryVaultByokEncrypted = require('./recoveryVaultByokEncrypted'); + +const listServiceVaults = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/77777777-b0c6-47a2-b37c-d8e65a629c18/resourceGroups/HelloWorld/providers/Microsoft.RecoveryServices/vaults/today1', + 'type': "Microsoft.RecoveryServices/vaults", + } +]; + +const getServiceVault = [ + { + "id": "/subscriptions/123/resourceGroups/Default-RecoveryServices-ResourceGroup/providers/Microsoft.RecoveryServices/vaults/swaggerExample", + "type": "Microsoft.RecoveryServices/vaults", + "sku": { + "name": "Standard" + }, + "encryption": {} + }, + { + "id": "/subscriptions/123/resourceGroups/Default-RecoveryServices-ResourceGroup/providers/Microsoft.RecoveryServices/vaults/swaggerExample", + "type": "Microsoft.RecoveryServices/vaults", + "sku": { + "name": "Standard" + }, + "encryption": { + "keyVaultProperties": { + "keyUri": 'https://testservicevault.vault.azure.net/keys/testServiceVault' + }, + } + + } +]; + +const createCache = (listServiceVault, getServiceVault) => { + const id = (listServiceVault && listServiceVault.length) ? listServiceVault[0].id : null; + return { + recoveryServiceVaults: { + listBySubscriptionId: { + 'eastus': { data: listServiceVault } + }, + getRecoveryServiceVault: { + 'eastus': { + [id]: { + data: getServiceVault + } + } + } + + }, + }; +}; + +describe('recoveryVaultByokEncrypted', function() { + describe('run', function() { + it('should give passing result if no Recovery Service vault found', function(done) { + const cache = createCache([], null); + recoveryVaultByokEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Recovery Service Vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for list Recovery Service vault', function(done) { + const cache = createCache(null, null); + recoveryVaultByokEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list Recovery Service Vaults:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for get Recovery Service vault', function(done) { + const cache = createCache([listServiceVaults[0]], null); + recoveryVaultByokEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for get Recovery Service Vault:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if cmk encryption enabled', function(done) { + const cache = createCache([listServiceVaults[0]], getServiceVault[1]); + recoveryVaultByokEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Recovery Service Vault has BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if cmk encryption not enabled', function(done) { + const cache = createCache([listServiceVaults[0]], getServiceVault[0]); + recoveryVaultByokEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Recovery Service Vault does not have BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.js b/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.js new file mode 100644 index 000000000..381829bcf --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Recovery Services Vault Logging Enabled', + category: 'Recovery Service Vault', + domain: 'Backup', + severity: 'Medium', + description: 'Ensure that Azure Recovery Services Vaults have diagnostic logs enabled.', + more_info: 'Diagnostic logs provide valuable insights into the operation and health of the Recovery Services Vault. By enabling diagnostic logs, you can monitor and analysis the insights which can be used for alerting and reporting.', + recommended_action: 'Modify the Recovery Service vault and enable diagnostic logs.', + link: 'https://learn.microsoft.com/en-us/azure/backup/backup-azure-diagnostic-events?tabs=recovery-services-vaults', + apis: ['diagnosticSettings:listByRecoveryServiceVault', 'recoveryServiceVaults:listBySubscriptionId'], + realtime_triggers: ['microsoftrecoveryservices:vaults:write', 'microsoftrecoveryservices:vaults:delete', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.recoveryServiceVaults, (location, rcb) => { + const serviceVaults = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'listBySubscriptionId', location]); + + if (!serviceVaults) return rcb(); + + if (serviceVaults.err || !serviceVaults.data) { + helpers.addResult(results, 3, + 'Unable to list Recovery Service Vaults: ' + helpers.addError(serviceVaults), location); + return rcb(); + } + + if (!serviceVaults.data.length) { + helpers.addResult(results, 0, 'No Recovery Service Vaults found', location); + return rcb(); + } + + for (let vault of serviceVaults.data) { + if (!vault.id) continue; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByRecoveryServiceVault', location, vault.id]); + + if (!diagnosticSettings || !diagnosticSettings.data || diagnosticSettings.err) { + helpers.addResult(results, 3, + 'Unable to query for Recovery Service Vault diagnostic settings: ' + helpers.addError(diagnosticSettings), location, vault.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Recovery Service Vault has diagnostic logs enabled', location, vault.id); + } else { + helpers.addResult(results, 2, 'Recovery Service Vault does not have diagnostic logs enabled', location, vault.id); + } + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.spec.js b/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.spec.js new file mode 100644 index 000000000..3256b3fb0 --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var recoveryVaultLoggingEnabled = require('./recoveryVaultLoggingEnabled'); + +const listServiceVaults = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/77777777-b0c6-47a2-b37c-d8e65a629c18/resourceGroups/HelloWorld/providers/Microsoft.RecoveryServices/vaults/today1', + 'type': "Microsoft.RecoveryServices/vaults", + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/77777777-b0c6-47a2-b37c-d8e65a629c18/resourceGroups/HelloWorld/providers/Microsoft.RecoveryServices/vaults/today1/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + metrics: [], + logs: [ + { + category: 'RecoveryServiceVault', + categoryGroup: null, + enabled: true, + }, + { + category: 'RecoveryServiceVault', + categoryGroup: null, + enabled: true, + } + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/77777777-b0c6-47a2-b37c-d8e65a629c18/resourceGroups/HelloWorld/providers/Microsoft.RecoveryServices/vaults/today1/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + metrics: [], + logs: [], + logAnalyticsDestinationType: null + }, +]; + +const createCache = (listServiceVault, ds) => { + const id = (listServiceVault && listServiceVault.length) ? listServiceVault[0].id : null; + return { + recoveryServiceVaults: { + listBySubscriptionId: { + 'eastus': { data: listServiceVault } + }, + }, + diagnosticSettings: { + listByRecoveryServiceVault: { + 'eastus': { + [id]: { + data: ds + } + } + } + } + }; +}; + +describe('recoveryVaultLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if no Recovery Service vault found', function(done) { + const cache = createCache([], null); + recoveryVaultLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Recovery Service Vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for list Recovery Service vault', function(done) { + const cache = createCache(null, null); + recoveryVaultLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list Recovery Service Vaults:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([listServiceVaults[0]], null); + recoveryVaultLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Recovery Service Vault diagnostic settings: '); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([listServiceVaults[0]], [diagnosticSettings[0]]); + recoveryVaultLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Recovery Service Vault has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([listServiceVaults[0]], [diagnosticSettings[1]]); + recoveryVaultLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Recovery Service Vault does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/minimumTlsVersion.js b/plugins/azure/redisCache/minimumTlsVersion.js new file mode 100644 index 000000000..d02f37f18 --- /dev/null +++ b/plugins/azure/redisCache/minimumTlsVersion.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Minimum TLS Version', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Azure Cache for Redis is using the latest TLS version.', + more_info: 'TLS versions 1.0 and 1.1 are known to be susceptible to attacks, and to have other Common Vulnerabilities and Exposures (CVE) weaknesses.So there\'s an industry- wide push toward the exclusive use of Transport Layer Security(TLS) version 1.2 or later.', + recommended_action: 'Ensure that Azure cache for Redis is using the latest TLS version', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-remove-tls-10-11', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (!cache.minimumTlsVersion) { + helpers.addResult(results, 2, 'Redis Cache is using the default TLS Version', location, cache.id); + } else if (cache.minimumTlsVersion && (cache.minimumTlsVersion === '1.0' || cache.minimumTlsVersion === '1.1')) { + helpers.addResult(results, 2, 'Redis Cache is not using the latest TLS Version', location, cache.id); + } else { + helpers.addResult(results, 0, 'Redis Cache is using the latest TLS Version', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/minimumTlsVersion.spec.js b/plugins/azure/redisCache/minimumTlsVersion.spec.js new file mode 100644 index 000000000..b6925d1a8 --- /dev/null +++ b/plugins/azure/redisCache/minimumTlsVersion.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var minimumTlsVersion = require('./minimumTlsVersion'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'minimumTlsVersion': '1.2', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'minimumTlsVersion': '1.1', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'minimumTlsVersion': '1.0', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis' + } +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('minimumTlsVersion', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache is using latest TLS version', function(done) { + const cache = createCache([redisCaches[0]]); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache is using the latest TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache is using latest TLS version 1.1', function(done) { + const cache = createCache([redisCaches[1]]); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache is not using the latest TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache is using latest TLS version 1.0', function(done) { + const cache = createCache([redisCaches[2]]); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache is not using the latest TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache is using default TLS version', function (done) { + const cache = createCache([redisCaches[3]]); + minimumTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache is using the default TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheDiagnosticLogs.js b/plugins/azure/redisCache/redisCacheDiagnosticLogs.js new file mode 100644 index 000000000..fba4296a9 --- /dev/null +++ b/plugins/azure/redisCache/redisCacheDiagnosticLogs.js @@ -0,0 +1,87 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache Diagnostic Logs Enabled', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures diagnostic logging is enabled for Azure Cache for Redis.', + more_info: 'Enabling diagnostic setting helps you understand who is connecting to your caches and the timestamp of those connections. The log data could be used to identify the scope of a security breach and for security auditing purposes.', + recommended_action: 'Enable diagnostic logging for all Redis Caches.', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-monitor-diagnostic-settings?tabs=basic-standard-premium', + apis: ['redisCaches:listBySubscription', 'diagnosticSettings:listByRedisCache'], + settings: { + diagnostic_logs: { + name: 'Diagnostic Logs Enabled', + description: 'Comma separated list of diagnostic logs that should be enabled at minimum i.e. ConnectedClientList. If you have enabled allLogs, then resource produces pass result. If you only want to check if logging is enabled or not, irrespecitve of log type, then add * in setting.', + regex: '^.*$', + default: 'ConnectedClientList' + }, + }, + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + diagnostic_logs: settings.diagnostic_logs || this.settings.diagnostic_logs.default, + }; + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No existing Redis Caches found', location); + return rcb(); + } + + caches.data.forEach(function(redisCache) { + if (!redisCache.id) return; + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByRedisCache', location, redisCache.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Redis Cache diagnostics settings: ' + helpers.addError(diagnosticSettings), location, redisCache.id); + } else { + var found = true; + var missingLogs = []; + if (config.diagnostic_logs == '*') { + found = diagnosticSettings.data.some(ds => ds.logs && ds.logs.length); + } else { + config.diagnostic_logs = config.diagnostic_logs.replace(/\s/g, ''); + missingLogs = config.diagnostic_logs.toLowerCase().split(','); + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category && log.category.toLowerCase() === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + } + if (!missingLogs.length && found) { + helpers.addResult(results, 0, 'Redis Cache has diagnostic logs enabled', location, redisCache.id); + } else { + helpers.addResult(results, 2, `Redis Cache does not have diagnostic logs enabled ${missingLogs.length? `for following: ${missingLogs}`: ''}`, location, redisCache.id); + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheDiagnosticLogs.spec.js b/plugins/azure/redisCache/redisCacheDiagnosticLogs.spec.js new file mode 100644 index 000000000..8130e8ef0 --- /dev/null +++ b/plugins/azure/redisCache/redisCacheDiagnosticLogs.spec.js @@ -0,0 +1,297 @@ +var expect = require('chai').expect; +var redisCacheDiagnosticLogs = require('./redisCacheDiagnosticLogs.js'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'minimumTlsVersion': '1.2', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'minimumTlsVersion': '1.1', + }, +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cache/redis/omerredistest/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/test/providers/microsoft.operationalinsights/workspaces/ctolabsanalytics', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [ [Object] ], + logs: [ + { + category: null, + categoryGroup: 'allLogs', + enabled: true, + retentionPolicy: { enabled: false, days: 0 } + }, + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cache/redis/omerredistest/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/test/providers/microsoft.operationalinsights/workspaces/ctolabsanalytics', + eventHubAuthorizationRuleId: null, + eventHubName: null, + logs: [ + { + category: 'ConnectedClientList', + categoryGroup: null, + enabled: false, + retentionPolicy: { enabled: false, days: 0 } + }, + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cache/redis/omerredistest/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/test/providers/microsoft.operationalinsights/workspaces/ctolabsanalytics', + eventHubAuthorizationRuleId: null, + eventHubName: null, + logs: [ + { + category: 'ConnectedClientList', + categoryGroup: null, + enabled: true, + retentionPolicy: { enabled: false, days: 0 } + }, + ], + logAnalyticsDestinationType: null + }, + {}, + { + id: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/cloudexploit-dev/providers/microsoft.cache/redis/omerredistest/providers/microsoft.insights/diagnosticSettings/test', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: null, + kind: null, + tags: null, + identity: null, + storageAccountId: null, + serviceBusRuleId: null, + workspaceId: '/subscriptions/26a1a07e-06dd-4892-92c9-e4996b0fc546/resourcegroups/test/providers/microsoft.operationalinsights/workspaces/ctolabsanalytics', + eventHubAuthorizationRuleId: null, + eventHubName: null, + metrics: [ [Object] ], + logs: [ + ], + logAnalyticsDestinationType: null + + } +] + +const createCache = (redisCaches, diagnostics) => { + let diagnostic = {}; + if (redisCaches.length) { + diagnostic[redisCaches[0].id] = { + data: diagnostics + }; + } + + + return { + redisCaches: { + listBySubscription: { + 'eastus': { + data: redisCaches + } + } + }, + diagnosticSettings: { + listByRedisCache: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'redisCache') { + return { + redisCaches: { + listBySubscription: { + 'eastus': {} + } + } + }; + } else if (key === 'nocache'){ + return { + redisCaches: { + listBySubscription: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + redisCaches: { + listBySubscription: { + 'eastus': { + data: [redisCaches[0]] + } + } + }, + diagnosticSettings: { + listByRedisCache: { + 'eastus': {} + } + } + }; + } else { + const redisId = (redisCaches && redisCaches.length) ? redisCaches[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + redisCaches: { + listBySubscription: { + 'eastus': { + data: [redisCaches[0]] + } + } + }, + diagnosticSettings: { + listByRedisCache: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('redisCacheDiagnosticLogs', function () { + describe('run', function () { + + it('should give pass result if No existing Redis Caches found', function (done) { + const cache = createErrorCache('nocache'); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query Redis Caches:', function (done) { + const cache = createErrorCache('redisCache'); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query diagnostics settings', function (done) { + const cache = createErrorCache('settings'); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Cache diagnostics settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has diagnostic logs enabled', function (done) { + const cache = createCache([redisCaches[0]], [diagnosticSettings[2]]); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has diagnostic logs enabled with all Logs', function (done) { + const cache = createCache([redisCaches[0]], [diagnosticSettings[0]]); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Redis Cache does not have diagnostic logs enabled', function (done) { + const cache = createCache([redisCaches[1]], [diagnosticSettings[1]]); + redisCacheDiagnosticLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have diagnostic logs enabled for following:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Redis Cache does not have diagnostic logs enabled with settings', function (done) { + const cache = createCache([redisCaches[1]], [diagnosticSettings[1]]); + redisCacheDiagnosticLogs.run(cache, {diagnostic_logs: 'testsetting'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have diagnostic logs enabled for following:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Redis Cache has diagnostic logs enabled with * setting', function (done) { + const cache = createCache([redisCaches[1]], [diagnosticSettings[1]]); + redisCacheDiagnosticLogs.run(cache, {diagnostic_logs: '*'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Redis Cache has diagnostic logs enabled with * setting but there are not logs', function (done) { + const cache = createCache([redisCaches[1]], [diagnosticSettings[4]]); + redisCacheDiagnosticLogs.run(cache, {diagnostic_logs: '*'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheHasTags.js b/plugins/azure/redisCache/redisCacheHasTags.js new file mode 100644 index 000000000..2eec73812 --- /dev/null +++ b/plugins/azure/redisCache/redisCacheHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache Has Tags', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Azure Cache for Redis have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Azure Cache for Redis and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (!cache.id) continue; + + if (cache.tags && Object.entries(cache.tags).length > 0){ + helpers.addResult(results, 0, 'Redis Cache has tags associated', location, cache.id); + } else { + helpers.addResult(results, 2, 'Redis Cache does not have tags associated', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheHasTags.spec.js b/plugins/azure/redisCache/redisCacheHasTags.spec.js new file mode 100644 index 000000000..8c3e531bf --- /dev/null +++ b/plugins/azure/redisCache/redisCacheHasTags.spec.js @@ -0,0 +1,81 @@ +var expect = require('chai').expect; +var redisCacheHasTags = require('./redisCacheHasTags'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'tags': { "key": "value" }, + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'tags': {}, + }, +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('redisCacheHasTags', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + redisCacheHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + redisCacheHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has tags associated', function(done) { + const cache = createCache([redisCaches[0]]); + redisCacheHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache does not have tags associated', function(done) { + const cache = createCache([redisCaches[1]]); + redisCacheHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheManagedIdentity.js b/plugins/azure/redisCache/redisCacheManagedIdentity.js new file mode 100644 index 000000000..f6a3947af --- /dev/null +++ b/plugins/azure/redisCache/redisCacheManagedIdentity.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache Managed Identity Enabled', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Azure Cache for Redis have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Azure Cache for Redis and add managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-managed-identity#enable-managed-identity', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (!cache.id) continue; + + if (cache.identity){ + helpers.addResult(results, 0, 'Redis Cache has managed identity enabled', location, cache.id); + } else { + helpers.addResult(results, 2, 'Redis Cache does not have managed identity enabled', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheManagedIdentity.spec.js b/plugins/azure/redisCache/redisCacheManagedIdentity.spec.js new file mode 100644 index 000000000..fcbe2a5eb --- /dev/null +++ b/plugins/azure/redisCache/redisCacheManagedIdentity.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var redisCacheManagedIdentity = require('./redisCacheManagedIdentity'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'tags': { "key": "value" }, + "identity": { + "type": "SystemAssigned", + "principalId": "1ca87ddb-6850-430d-9800-08be5b6b33bf", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8" + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'tags': {}, + }, + +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('redisCacheManagedIdentity', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + redisCacheManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + redisCacheManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has managed identity enabled associated', function(done) { + const cache = createCache([redisCaches[0]]); + redisCacheManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache does not have managed identity enabled', function(done) { + const cache = createCache([redisCaches[1]]); + redisCacheManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCachePrivateEndpoint.js b/plugins/azure/redisCache/redisCachePrivateEndpoint.js new file mode 100644 index 000000000..20b826fc3 --- /dev/null +++ b/plugins/azure/redisCache/redisCachePrivateEndpoint.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache Private Endpoint', + category: 'Redis Cache', + domain: 'Databases', + severity: 'High', + description: 'Ensures that Azure Cache for Redis is only accessible through private endpoints.', + more_info: 'Enabling a private endpoint for Azure Cache for Redis enhances security by isolating the cache from the public internet and providing controlled access within a private network.', + recommended_action: 'Ensure that Azure Cache for Redis has public network access disabled.', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-network-isolation#azure-private-link-recommended', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (cache.publicNetworkAccess && cache.publicNetworkAccess.toLowerCase() === 'enabled') { + helpers.addResult(results, 2, 'Redis Cache is publicly accessible', location, cache.id); + } else { + helpers.addResult(results, 0, 'Redis Cache is only accessible through private endpoints', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCachePrivateEndpoint.spec.js b/plugins/azure/redisCache/redisCachePrivateEndpoint.spec.js new file mode 100644 index 000000000..13f961074 --- /dev/null +++ b/plugins/azure/redisCache/redisCachePrivateEndpoint.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var plugin = require('./redisCachePrivateEndpoint'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'publicNetworkAccess': 'Disabled', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'publicNetworkAccess': 'Enabled', + }, + +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('redisCachePrivateEndpoint', function() { + describe('run', function() { + it('should give passing result if there are no redis caches', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache(null); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if redis cache is only accessible through private endpoint', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache is only accessible through private endpoints'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([redisCaches[0]]); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if redis cache is publicly accessible', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([redisCaches[1]]); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheScheduledUpdates.js b/plugins/azure/redisCache/redisCacheScheduledUpdates.js new file mode 100644 index 000000000..55f04a618 --- /dev/null +++ b/plugins/azure/redisCache/redisCacheScheduledUpdates.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache Scheduled Updates', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Azure Cache for Redis has scheduled updates enabled.', + more_info: 'Enabling schedule updates allows you to choose a maintenance window for your cache instance. A maintenance window allows you to control the day(s) and time(s) of a week during which the VM(s) hosting your cache can be updated. Azure Cache for Redis will make a best effort to start and finish updating Redis server software within the specified time window you define.', + recommended_action: 'Enable schedule updates for Redis Cache.', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-administration#update-channel-and-schedule-updates', + apis: ['redisCaches:listBySubscription', 'patchSchedules:listByRedisCache'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete','microsoftcache:redis:patchschedules:write','microsoftcache:redis:patchschedules:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No existing Redis Caches found', location); + return rcb(); + } + + caches.data.forEach(function(redisCache) { + if (!redisCache.id) return; + const patchSchedules = helpers.addSource(cache, source, + ['patchSchedules', 'listByRedisCache', location, redisCache.id]); + + if (!patchSchedules || (patchSchedules && patchSchedules.err)) { + if (patchSchedules.err && patchSchedules.err.includes('There are no patch schedules found for redis cache')) { + helpers.addResult(results, 2, 'Redis Cache does not have scheduled updates enabled', location, redisCache.id); + } else { + helpers.addResult(results, 3, 'Unable to query Redis Cache scheduled updates ' + helpers.addError(patchSchedules), location, redisCache.id); + } + } else { + helpers.addResult(results, 0, 'Redis Cache has scheduled updates enabled', location, redisCache.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheScheduledUpdates.spec.js b/plugins/azure/redisCache/redisCacheScheduledUpdates.spec.js new file mode 100644 index 000000000..961bb7caf --- /dev/null +++ b/plugins/azure/redisCache/redisCacheScheduledUpdates.spec.js @@ -0,0 +1,178 @@ +var expect = require('chai').expect; +var redisCacheScheduledUpdates = require('./redisCacheScheduledUpdates'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisCacheScheduledUpdates': '1.2', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisCacheScheduledUpdates': '1.1', + } +]; + +const patchSchedules = { + "id": "/subscriptions/123/resourceGroups/cloudexploit-dev/providers/Microsoft.Cache/Redis/omerredistest/patchSchedules/default", + "location": "East US", + "name": "omerredistest/default", + "type": "Microsoft.Cache/Redis/PatchSchedules", + "properties": { + "scheduleEntries": [ + { + "dayOfWeek": "Sunday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Monday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Tuesday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Wednesday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Thursday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Friday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + }, + { + "dayOfWeek": "Saturday", + "startHourUtc": 0, + "maintenanceWindow": "PT5H" + } + ] + } +}; + +const createCache = (redisCaches, patchSchedules) => { + let redis = {}; + let patch = {}; + + if (redisCaches) { + redis['data'] = redisCaches; + if (redisCaches && redisCaches.length) { + patch[redisCaches[0].id] = { + 'data': patchSchedules + }; + } + } + + return { + redisCaches: { + listBySubscription: { + 'eastus': redis + } + }, + patchSchedules: { + listByRedisCache: { + 'eastus': patch + } + } + }; +}; + +const createErrorCache = (redisCaches, message) => { + let redis = {}; + let patch = {}; + + if (redisCaches) { + redis['data'] = redisCaches; + if (redisCaches && redisCaches.length) { + patch[redisCaches[0].id] = { + 'err': message + }; + } + } + + return { + redisCaches: { + listBySubscription: { + 'eastus': redis + }, + }, + patchSchedules: { + listByRedisCache: { + 'eastus': patch + } + } + }; +}; + +describe('redisCacheScheduledUpdates', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + redisCacheScheduledUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + redisCacheScheduledUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query redis cache patch schedules', function(done) { + const cache = createErrorCache([redisCaches[1]], 'notFound'); + redisCacheScheduledUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Cache scheduled updates'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache does not have scheduled updates enabled', function(done) { + const cache = createErrorCache([redisCaches[1]],'There are no patch schedules found for redis cache'); + redisCacheScheduledUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have scheduled updates enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has scheduled updates enabled', function(done) { + const cache = createCache([redisCaches[1]], patchSchedules); + redisCacheScheduledUpdates.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has scheduled updates enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/redisCache/redisCacheVNetIntegrated.js b/plugins/azure/redisCache/redisCacheVNetIntegrated.js new file mode 100644 index 000000000..0d1d7c765 --- /dev/null +++ b/plugins/azure/redisCache/redisCacheVNetIntegrated.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Cache VNet Integrated', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that premium Redis Cache has VNet integrated.', + more_info: 'Ensuring VNet deployment for Redis Cache provides enhanced security and isolation. When VNet is combined with restricted NSG policies, it helps reducing the risk of data exfiltration.', + recommended_action: 'Ensure VNet (private access) is integrated for premium Redis Cache.', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-network-isolation', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (!cache.id) continue; + + if (cache.sku && cache.sku.name && cache.sku.name.toLowerCase()!='premium') { + helpers.addResult(results, 0, 'VNet Integration is only available for premium tier Redis Caches', location, cache.id); + } else if (cache.subnetId) { + helpers.addResult(results, 0, 'Redis Cache has VNet integrated', location, cache.id); + } else { + helpers.addResult(results, 2, 'Redis Cache does not have VNet integrated', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheVNetIntegrated.spec.js b/plugins/azure/redisCache/redisCacheVNetIntegrated.spec.js new file mode 100644 index 000000000..5b585688c --- /dev/null +++ b/plugins/azure/redisCache/redisCacheVNetIntegrated.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var redisCacheVNetIntegrated = require('./redisCacheVNetIntegrated'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisCacheVNetIntegrated': '1.2', + 'sku':{ + 'name':'Basic' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisCacheVNetIntegrated': '1.1', + 'sku':{ + 'name':'Premium' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisCacheVNetIntegrated': '1.1', + 'sku':{ + 'name':'Premium' + }, + 'subnetId': '/subscriptions/123/resourceGroups/khulnasoft/providers/Microsoft.Network/virtualNetworks/khulnasoft/subnets/khulnasoft' + } +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('redisCacheVNetIntegrated', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + redisCacheVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + redisCacheVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache has Vnet integrated', function(done) { + const cache = createCache([redisCaches[2]]); + redisCacheVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache has VNet integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache does not have Vnet integrated', function(done) { + const cache = createCache([redisCaches[1]]); + redisCacheVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache does not have VNet integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache is not premium', function(done) { + const cache = createCache([redisCaches[0]]); + redisCacheVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VNet Integration is only available for premium tier Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisVersion.js b/plugins/azure/redisCache/redisVersion.js new file mode 100644 index 000000000..851426d0d --- /dev/null +++ b/plugins/azure/redisCache/redisVersion.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Redis Version', + category: 'Redis Cache', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that Azure Cache for Redis is using the latest redis version.', + more_info: 'Using the latest Redis Version ensures access to the latest features, improvements, and security patches, enhancing performance and reducing vulnerabilities.', + recommended_action: 'Ensure that Azure cache for Redis is using the latest version', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-overview#redis-versions', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (!cache.id || !cache.redisVersion) return; + + let version = parseFloat(cache.redisVersion); + if (version && version >= 6) { + helpers.addResult(results, 0, 'Redis Cache is using the latest redis version', location, cache.id); + } else { + helpers.addResult(results, 2, 'Redis Cache is not using the latest redis version', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/redisCache/redisVersion.spec.js b/plugins/azure/redisCache/redisVersion.spec.js new file mode 100644 index 000000000..e7b58bd13 --- /dev/null +++ b/plugins/azure/redisCache/redisVersion.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var redisVersion = require('./redisVersion'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisVersion': '6.0', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'redisVersion': '5.1', + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis' + } +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('redisVersion', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + redisVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + redisVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache is using latest redis version', function(done) { + const cache = createCache([redisCaches[0]]); + redisVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Redis Cache is using the latest redis version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache is not using latest redis version', function(done) { + const cache = createCache([redisCaches[1]]); + redisVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Redis Cache is not using the latest redis version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/sslAccessOnlyEnabled.js b/plugins/azure/redisCache/sslAccessOnlyEnabled.js new file mode 100644 index 000000000..3b61a7568 --- /dev/null +++ b/plugins/azure/redisCache/sslAccessOnlyEnabled.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SSL Access Only Enabled', + category: 'Redis Cache', + domain: 'Databases', + severity: 'High', + description: 'Ensures that SSL Access Only feature is enabled for Azure Redis Caches.', + more_info: 'SSL Access only should be enabled for Azure Cache for Redis to meet the organization\'s security compliance requirements.', + recommended_action: 'Enable SSL Access Only for Azure cache for Redis', + link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-management-faq#when-should-i-enable-the-non-tlsssl-port-for-connecting-to-redis', + apis: ['redisCaches:listBySubscription'], + realtime_triggers: ['microsoftcache:redis:write','microsoftcache:redis:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.redisCaches, function(location, rcb) { + const caches = helpers.addSource(cache, source, + ['redisCaches', 'listBySubscription', location]); + + if (!caches) return rcb(); + + if (caches.err || !caches.data) { + helpers.addResult(results, 3, 'Unable to query Redis Caches: ' + helpers.addError(caches), location); + return rcb(); + } + + if (!caches.data.length) { + helpers.addResult(results, 0, 'No Redis Caches found', location); + return rcb(); + } + + for (let cache of caches.data) { + if (cache.enableNonSslPort) { + helpers.addResult(results, 2, 'SSL Access Only is not enabled for Azure Cache for Redis', location, cache.id); + } else { + helpers.addResult(results, 0, 'SSL Access Only is enabled for Azure Cache for Redis', location, cache.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/redisCache/sslAccessOnlyEnabled.spec.js b/plugins/azure/redisCache/sslAccessOnlyEnabled.spec.js new file mode 100644 index 000000000..43cefed78 --- /dev/null +++ b/plugins/azure/redisCache/sslAccessOnlyEnabled.spec.js @@ -0,0 +1,81 @@ +var expect = require('chai').expect; +var sslAccessOnlyEnabled = require('./sslAccessOnlyEnabled'); + +const redisCaches = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'enableNonSslPort': false, + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Cache/Redis/test-cache', + 'location': 'East US', + 'name': 'test-cache', + 'type': 'Microsoft.Cache/Redis', + 'enableNonSslPort': true, + } +]; + +const createCache = (redisCaches) => { + let caches = {}; + if (redisCaches) { + caches['data'] = redisCaches; + } + return { + redisCaches: { + listBySubscription: { + 'eastus': caches + } + }, + }; +}; + +describe('sslAccessOnlyEnabled', function() { + describe('run', function() { + it('should give passing result if no redis caches', function(done) { + const cache = createCache([]); + sslAccessOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Redis Caches found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for redis caches', function(done) { + const cache = createCache(null); + sslAccessOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Redis Caches'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redis cache is accessible through SSL only', function(done) { + const cache = createCache([redisCaches[0]]); + sslAccessOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SSL Access Only is enabled for Azure Cache for Redis'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if redis cache is using non SSL port', function(done) { + const cache = createCache([redisCaches[1]]); + sslAccessOnlyEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SSL Access Only is not enabled for Azure Cache for Redis'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/resourceGroup/rgHasTags.js b/plugins/azure/resourceGroup/rgHasTags.js new file mode 100644 index 000000000..857380341 --- /dev/null +++ b/plugins/azure/resourceGroup/rgHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Resource Group Has Tags', + category: 'Resource Group', + domain: 'Management', + severity: 'Low', + description: 'Ensures that Azure resource groups have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify affected resource group and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources-portal', + apis: ['resourceGroups:list'], + realtime_triggers: ['microsoftresources:subscriptions:resourcegroups:write','microsoftresources:subscriptions:resourcegroups:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.resourceGroups, function(location, rcb){ + + var resourceGroups = helpers.addSource(cache, source, + ['resourceGroups', 'list', location]); + + if (!resourceGroups) return rcb(); + + if (resourceGroups.err || !resourceGroups.data) { + helpers.addResult(results, 3, 'Unable to query for resource groups: ' + helpers.addError(resourceGroups), location); + return rcb(); + } + if (!resourceGroups.data.length) { + helpers.addResult(results, 0, 'No existing resource groups found', location); + return rcb(); + } + for (let rg of resourceGroups.data) { + if (!rg.id) continue; + + if (rg.tags && Object.keys(rg.tags).length > 0) { + helpers.addResult(results, 0, 'Resource group has tags', location, rg.id); + } else { + helpers.addResult(results, 2, 'Resource group does not have tags', location, rg.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/resourceGroup/rgHasTags.spec.js b/plugins/azure/resourceGroup/rgHasTags.spec.js new file mode 100644 index 000000000..cc821661e --- /dev/null +++ b/plugins/azure/resourceGroup/rgHasTags.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var rgHasTags = require('./rgHasTags'); + +const resourceGroups = [ + { + 'name': 'test-rg', + 'id': '/subscriptions/123/resourceGroups/test1', + 'type': 'Microsoft.Resources/resourceGroups', + 'tags': { 'key': 'value'} + }, + { + 'name': 'test-rg', + 'id': '/subscriptions/123/resourceGroups/test1', + 'type': 'Microsoft.Resources/resourceGroups', + } +]; + +const createCache = (resourceGroups) => { + return { + resourceGroups: { + list: { + 'eastus': { + data: resourceGroups + } + + }, + } + }; +}; + +describe('rgHasTags', function() { + describe('run', function() { + it('should give passing result if no resource group', function(done) { + const cache = createCache([]); + rgHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing resource groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for resource group', function(done) { + const cache = createCache(null); + rgHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for resource groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Resource group has tags', function(done) { + const cache = createCache([resourceGroups[0]]); + rgHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Resource group has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Resource group does not have tags', function(done) { + const cache = createCache([resourceGroups[1]]); + rgHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Resource group does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/resources/managementLockEnabled.js b/plugins/azure/resources/managementLockEnabled.js index cdd2cb4d2..e793b5a49 100644 --- a/plugins/azure/resources/managementLockEnabled.js +++ b/plugins/azure/resources/managementLockEnabled.js @@ -4,16 +4,18 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Management Lock Enabled', category: 'Resources', + domain: 'Application Integration', + severity: 'Medium', description: 'Ensures that resources tagged as locked are actually locked', more_info: 'Enabling Management Locks ensures that critical resources cannot be inadvertently modified or deleted.', - recommended_action: '1. Go to Resources. 2. Select the resource. 3. Select the Locks blade under settings on the left side. 4. Add a lock 5. Enter the Tags Blade and add cloudsploitLock as a tag with true as its value.', - link: 'https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-lock-resources', + recommended_action: '1. Go to Resources. 2. Select the resource. 3. Select the Locks blade under settings on the left side. 4. Add a lock 5. Enter the Tags Blade and add cloudexploitLock as a tag with true as its value.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/resource-group-lock-resources', apis: ['resources:list', 'managementLocks:listAtSubscriptionLevel'], settings: { tag: { name: 'Management Lock Tag', description: 'This tag will be required to indicate that the management lock is enabled.', - default: 'cloudsploitLock', + default: 'cloudexploitLock', regex: '^.{2,125}$' } }, diff --git a/plugins/azure/resources/resourceUsageLimit.js b/plugins/azure/resources/resourceUsageLimit.js index 8f342bc8f..0bb99321a 100644 --- a/plugins/azure/resources/resourceUsageLimit.js +++ b/plugins/azure/resources/resourceUsageLimit.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure/'); module.exports = { title: 'Resources Usage Limits', category: 'Resources', + domain: 'Application Integration', + severity: 'Medium', description: 'Determines if resources are close to the Azure per-account limit', more_info: 'Azure limits accounts to certain numbers of resources. Exceeding those limits could prevent resources from launching.', recommended_action: 'Check if resources are close to the account limit to avoid resource launch failures', - link: 'https://docs.microsoft.com/en-us/azure/azure-subscription-service-limits', + link: 'https://learn.microsoft.com/en-us/azure/azure-subscription-service-limits', apis: ['subscriptions:listLocations', 'usages:list'], run: function(cache, settings, callback) { @@ -33,6 +35,10 @@ module.exports = { } subLocations.data.forEach(function(sloc){ + if (sloc.name !=='global'){ + if (!locations.all.includes(sloc.name)) return; + } + const usages = helpers.addSource(cache, source, ['usages', 'list', location, sloc.id]); diff --git a/plugins/azure/securitycenter/adminSecurityAlertsEnabled.js b/plugins/azure/securitycenter/adminSecurityAlertsEnabled.js index c901338a0..df49b9b53 100644 --- a/plugins/azure/securitycenter/adminSecurityAlertsEnabled.js +++ b/plugins/azure/securitycenter/adminSecurityAlertsEnabled.js @@ -4,12 +4,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Admin Security Alerts Enabled', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that security alerts are configured to be sent to admins', more_info: 'Enabling security alerts to be sent to admins ensures that detected vulnerabilities and security issues are sent to the subscription admins for quick remediation.', recommended_action: 'Ensure that security alerts are configured to be sent to subscription owners.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details', + realtime_triggers: ['microsoftsecurity:securitycontacts:write','microsoftsecurity:securitycontacts:delete'], + apis: ['securityContacts:list'], - run: function(cache, settings, callback) { const results = []; const source = {}; diff --git a/plugins/azure/securitycenter/adminSecurityAlertsEnabled.spec.js b/plugins/azure/securitycenter/adminSecurityAlertsEnabled.spec.js new file mode 100644 index 000000000..2a9545414 --- /dev/null +++ b/plugins/azure/securitycenter/adminSecurityAlertsEnabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var adminSecurityAlertsEnabled = require('./adminSecurityAlertsEnabled'); + +const securityContacts = [ + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'On' + }, + { + 'id': '/subscriptions/123/providers/Microsoft.Security/securityContacts/contact1', + 'name': 'contact1', + 'alertsToAdmins': 'Off' + } +]; + +const createCache = (securityContacts) => { + return { + securityContacts: { + list: { + global:{ + data: securityContacts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + securityContacts: { + list: { + global: {} + } + } + }; +}; + +describe('adminSecurityAlertsEnabled', function() { + describe('run', function() { + it('should give failing result if no security contacts', function(done) { + const cache = createCache([]); + adminSecurityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No existing security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for security contacts', function(done) { + const cache = createErrorCache(); + adminSecurityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security contacts'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if security alerts are being sent to admins', function(done) { + const cache = createCache([securityContacts[0]]); + adminSecurityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security alerts for the subscription are configured to be sent to admins'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if security alerts are not being sent to admins', function(done) { + const cache = createCache([securityContacts[1]]); + adminSecurityAlertsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security alerts for the subscription are not configured to be sent to admins'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/highSeverityAlertsEnabled.js b/plugins/azure/securitycenter/highSeverityAlertsEnabled.js deleted file mode 100644 index a699eff55..000000000 --- a/plugins/azure/securitycenter/highSeverityAlertsEnabled.js +++ /dev/null @@ -1,51 +0,0 @@ -const async = require('async'); -const helpers = require('../../../helpers/azure'); - -module.exports = { - title: 'High Severity Alerts Enabled', - category: 'Security Center', - description: 'Ensures that high severity alerts are properly configured.', - more_info: 'Enabling high severity alerts ensures that microsoft alerts for potential security issues are sent and allows for quick mitigation of the associated risks.', - recommended_action: 'Ensure that high severity alerts are configured to be sent.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details', - apis: ['securityContacts:list'], - - run: function(cache, settings, callback) { - const results = []; - const source = {}; - const locations = helpers.locations(settings.govcloud); - - async.each(locations.securityContacts, (location, rcb) => { - - var securityContacts = helpers.addSource(cache, source, - ['securityContacts', 'list', location]); - - if (!securityContacts) return rcb(); - - if (securityContacts.err || !securityContacts.data) { - helpers.addResult(results, 3, - 'Unable to query for security contacts: ' + helpers.addError(securityContacts), location); - return rcb(); - } - - if (!securityContacts.data.length) { - helpers.addResult(results, 2, 'No existing security contacts', location); - return rcb(); - } - - securityContacts.data.forEach(securityContact => { - if (securityContact.alertNotifications && - securityContact.alertNotifications.toLowerCase() == 'on') { - helpers.addResult(results, 0, 'High severity alerts for the subscription are configured', location, securityContact.id); - } else { - helpers.addResult(results, 2, 'High severity alerts for the subscription are not configured', location, securityContacts.id); - } - }); - - rcb(); - }, function(){ - // Global checking goes here - callback(null, results, source); - }); - } -}; diff --git a/plugins/azure/securitycenter/monitorBlobEncryption.js b/plugins/azure/securitycenter/monitorBlobEncryption.js index 7bf463107..b45bc46a4 100644 --- a/plugins/azure/securitycenter/monitorBlobEncryption.js +++ b/plugins/azure/securitycenter/monitorBlobEncryption.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor Blob Encryption', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that Blob Storage Encryption monitoring is enabled', more_info: 'When this setting is enabled, Security Center audits blob encryption in all storage accounts to enhance data at rest protection.', recommended_action: 'Enable Adaptive Application Controls for Storage Accounts from the Azure Security Center by ensuring AuditIfNotExists setting is used for blob encryption.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policies', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policies', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorBlobEncryption.spec.js b/plugins/azure/securitycenter/monitorBlobEncryption.spec.js new file mode 100644 index 000000000..08acb5271 --- /dev/null +++ b/plugins/azure/securitycenter/monitorBlobEncryption.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorBlobEncryption = require('./monitorBlobEncryption'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorBlobEncryption', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorBlobEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorBlobEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorBlobEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Storage Blob Encryption enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorBlobEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Storage Blob Encryption disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorBlobEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorDiskEncryption.js b/plugins/azure/securitycenter/monitorDiskEncryption.js index 86b75b674..e1c1bdffa 100644 --- a/plugins/azure/securitycenter/monitorDiskEncryption.js +++ b/plugins/azure/securitycenter/monitorDiskEncryption.js @@ -4,15 +4,18 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor Disk Encryption', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Disk Encryption monitoring is enabled in Security Center', more_info: 'When this setting is enabled, Security Center audits disk encryption in all virtual machines to enhance data at rest protection.', recommended_action: 'Enable Adaptive Application Controls for Disk Encryption from the Azure Security Center by ensuring AuditIfNotExists setting is used for virtual machines.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', apis: ['policyAssignments:list'], compliance: { hipaa: 'HIPAA requires data to be encrypted at rest. Enabling disk encryption ' + 'monitoring ensures this configuration is not modified undetected.' }, + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorDiskEncryption.spec.js b/plugins/azure/securitycenter/monitorDiskEncryption.spec.js new file mode 100644 index 000000000..74bb9f00a --- /dev/null +++ b/plugins/azure/securitycenter/monitorDiskEncryption.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorDiskEncryption = require('./monitorDiskEncryption'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorDiskEncryption', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorDiskEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorDiskEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorDiskEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Disk Encryption enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorDiskEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Disk Encryption disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorDiskEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorNsgEnabled.js b/plugins/azure/securitycenter/monitorNsgEnabled.js index 270da0bf5..620163650 100644 --- a/plugins/azure/securitycenter/monitorNsgEnabled.js +++ b/plugins/azure/securitycenter/monitorNsgEnabled.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor NSG Enabled', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures Network Security Groups monitoring is enabled in Security Center', more_info: 'When this setting is enabled, Security Center will audit the Network Security Groups that are enabled on the VM for permissive rules.', recommended_action: 'Ensure Network Security Group monitoring is configured from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorNsgEnabled.spec.js b/plugins/azure/securitycenter/monitorNsgEnabled.spec.js new file mode 100644 index 000000000..7acf7b4fb --- /dev/null +++ b/plugins/azure/securitycenter/monitorNsgEnabled.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorNsgEnabled = require('./monitorNsgEnabled'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorNsgEnabled', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorNsgEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorNsgEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorNsgEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Network Security Groups enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorNsgEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Network Security Groups disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorNsgEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorSqlAuditing.js b/plugins/azure/securitycenter/monitorSqlAuditing.js index 3a0ed0358..1dcd760d0 100644 --- a/plugins/azure/securitycenter/monitorSqlAuditing.js +++ b/plugins/azure/securitycenter/monitorSqlAuditing.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor SQL Auditing', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that Monitor SQL Auditing is enabled in Security Center', more_info: 'When this setting is enabled, Security Center will monitor SQL databases.', recommended_action: 'Ensure SQL auditing monitoring is configured for SQL databases from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', apis: ['policyAssignments:list'], + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorSqlAuditing.spec.js b/plugins/azure/securitycenter/monitorSqlAuditing.spec.js new file mode 100644 index 000000000..cd5f4bb64 --- /dev/null +++ b/plugins/azure/securitycenter/monitorSqlAuditing.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorSqlAuditing = require('./monitorSqlAuditing'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Audit" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorSqlAuditing', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorSqlAuditing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorSqlAuditing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorSqlAuditing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor SQL Auditing enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorSqlAuditing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor SQL Auditing disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorSqlAuditing.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorSqlEncryption.js b/plugins/azure/securitycenter/monitorSqlEncryption.js index 98be9d687..76cd11b84 100644 --- a/plugins/azure/securitycenter/monitorSqlEncryption.js +++ b/plugins/azure/securitycenter/monitorSqlEncryption.js @@ -4,15 +4,18 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor SQL Encryption', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that Monitor SQL Encryption is enabled in Security Center', more_info: 'When this setting is enabled, Security Center will monitor for unencrypted SQL databases, associated backups, and transaction log files.', recommended_action: 'Ensure SQL encryption monitoring is configured for SQL databases from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', apis: ['policyAssignments:list'], compliance: { hipaa: 'HIPAA requires data to be encrypted at rest. Enabling SQL encryption ' + 'monitoring ensures this configuration is not modified undetected.' }, + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorSqlEncryption.spec.js b/plugins/azure/securitycenter/monitorSqlEncryption.spec.js new file mode 100644 index 000000000..9f12c675b --- /dev/null +++ b/plugins/azure/securitycenter/monitorSqlEncryption.spec.js @@ -0,0 +1,266 @@ +var expect = require('chai').expect; +var monitorSqlEncryption = require('./monitorSqlEncryption'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Audit" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Audit" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorSqlEncryption', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorSqlEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorSqlEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorSqlEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor SQL Encryption enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorSqlEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor SQL Encryption disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorSqlEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/securitycenter/monitorVMVulnerability.js b/plugins/azure/securitycenter/monitorVMVulnerability.js index 9e579ce44..b446fea8c 100644 --- a/plugins/azure/securitycenter/monitorVMVulnerability.js +++ b/plugins/azure/securitycenter/monitorVMVulnerability.js @@ -4,16 +4,19 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Monitor VM Vulnerability', category: 'Security Center', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that Monitor Vulnerability Assessment is enabled in Security Center.', more_info: 'When this setting is enabled, Security Center will monitor virtual machines for detected vulnerabilities.', recommended_action: 'Ensure VM Vulnerability monitoring is configured for virtual machines from the Azure Security Center.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-policy-definitions', apis: ['policyAssignments:list'], compliance: { pci: 'PCI requires reviewing systems for vulnerabilities on an ongoing basis ' + 'to protect cardholder data. These requirements include manual or automated ' + 'vulnerability testing.' }, + realtime_triggers: ['microsoftauthorization:policyassignments:write','microsoftauthorization:policyassignments:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/securitycenter/monitorVMVulnerability.spec.js b/plugins/azure/securitycenter/monitorVMVulnerability.spec.js new file mode 100644 index 000000000..53f5c3889 --- /dev/null +++ b/plugins/azure/securitycenter/monitorVMVulnerability.spec.js @@ -0,0 +1,272 @@ +var expect = require('chai').expect; +var monitorVMVulnerability = require('./monitorVMVulnerability'); + +const policyAssignments = [ + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Audit" + }, + "diskEncryptionMonitoringEffect": { + "value": "Audit" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Audit" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Audit" + }, + "storageEncryptionMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Audit" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": "Disabled" + }, + "sqlAuditingMonitoringEffect": { + "value": "Audit" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Audit" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/7a9dabe0d6244e9683d56a79", + "type": "Microsoft.Authorization/policyAssignments", + "name": "7a9dabe0d6244e9683d56a79", + "location": "eastus", + "displayName": "Monitor unencrypted SASASA", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0961003e-5a0a-4549-abde-af6a37f2724d", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "effect": { + "value": "Disabled" + } + }, + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "d0222e76-19f3-46f1-8705-af53043c1ff8", + "createdOn": "2019-04-18T00:34:32.2202483Z", + "updatedBy": null, + "updatedOn": null + }, + "enforcementMode": "Default" + }, + { + "sku": { + "name": "A0", + "tier": "Free" + }, + "id": "/subscriptions/1234/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn", + "type": "Microsoft.Authorization/policyAssignments", + "name": "SecurityCenterBuiltIn", + "location": "eastus", + "displayName": "ASC Default (subscription: 1234)", + "policyDefinitionId": "/providers/Microsoft.Authorization/policySetDefinitions/1f3afdf9-d0c9-4c3d-847f-89da613e70a8", + "scope": "/subscriptions/1234", + "notScopes": [], + "parameters": { + "vmssOsVulnerabilitiesMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "systemUpdatesMonitoringEffect": { + "value": "Disabled" + }, + "systemConfigurationsMonitoringEffect": { + "value": "AuditIfNotExists" + }, + "endpointProtectionMonitoringEffect": { + "value": "Disabled" + }, + "diskEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "networkSecurityGroupsMonitoringEffect": { + "value": "Disabled" + }, + "nextGenerationFirewallMonitoringEffect": { + "value": "Disabled" + }, + "vulnerabilityAssesmentMonitoringEffect": { + "value": "Disabled" + }, + "storageEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "jitNetworkAccessMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsMonitoringEffect": { + "value": "Disabled" + }, + "adaptiveApplicationControlsUpdateMonitoringEffect": { + "value": '' + }, + "sqlAuditingMonitoringEffect": { + "value": "Disabled" + }, + "sqlEncryptionMonitoringEffect": { + "value": "Disabled" + }, + "sqlServerAuditingMonitoringEffect": { + "value": "Disabled" + }, + "secureTransferToStorageAccountMonitoringEffect": { + "value": "Disabled" + }, + "identityDesignateLessThanOwnersMonitoringEffect": { + "value": "Disabled" + }, + "identityRemoveExternalAccountWithWritePermissionsMonitoringEffect": { + "value": "Disabled" + }, + "disableIPForwardingMonitoringEffect": { + "value": "Disabled" + } + }, + "description": "This is the default set of policies monitored by Azure Security Center. It was automatically assigned as part of onboarding to Security Center. The default assignment contains only audit policies. For more information please visit https://aka.ms/ascpolicies", + "metadata": { + "assignedBy": "cariel@cloudexploit.com ", + "parameterScopes": {}, + "createdBy": "709d03b9-72f9-4c49-ba6e-935fd066886f", + "createdOn": "2019-02-22T01:37:48.8576719Z", + "updatedBy": "ef21d0c2-9e1a-422c-b324-c39f584fd4b9", + "updatedOn": "2021-06-30T01:58:11.9141222Z" + }, + "enforcementMode": "Default" + }, +]; + +const createCache = (policyAssignment) => { + let settings = {}; + if (policyAssignment) { + settings['data'] = policyAssignment; + } + return { + policyAssignments: { + list: { + 'eastus': settings + } + } + }; +}; + +describe('monitorVMVulnerability', function() { + describe('run', function() { + it('should give failing result if No existing Policy Assignments found', function(done) { + const cache = createCache([]); + monitorVMVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Policy Assignments found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if There are no ASC Default Policy Assignments', function(done) { + const cache = createCache([policyAssignments[1]]); + monitorVMVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('There are no ASC Default Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Policy Assignments', function(done) { + const cache = createCache(); + monitorVMVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Policy Assignments'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Monitor Vulnerability Assessment enabled', function(done) { + const cache = createCache([policyAssignments[0]]); + monitorVMVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Monitor Vulnerability Assessment disabled', function(done) { + const cache = createCache([policyAssignments[2]]); + monitorVMVulnerability.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceEncryptionAtRest.js b/plugins/azure/servicebus/namespaceEncryptionAtRest.js new file mode 100644 index 000000000..1955f56a1 --- /dev/null +++ b/plugins/azure/servicebus/namespaceEncryptionAtRest.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Encryption At Rest with CMK', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Azure Service Bus namespaces are encrypted with CMK.', + more_info: 'Azure Service Bus allows you to encrypt data in your namespaces using customer-managed keys (CMK) instead of using platform-managed keys, which are enabled by default. Using CMK encryption offers enhanced security and compliance, allowing centralized management and control of encryption keys through Azure Key Vault.', + recommended_action: 'Ensure that Azure Service Bus namespaces have CMK encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/configure-customer-managed-key', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + + if (namespace.sku && namespace.sku.tier && namespace.sku.tier.toLowerCase() !== 'premium') { + helpers.addResult(results, 0, 'Service Bus Namespace is not a premium namespace', location, namespace.id); + } else if (namespace.encryption && Object.keys(namespace.encryption).length) { + helpers.addResult(results, 0, 'Service Bus Namespace is encrypted using CMK', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus Namespace is not encrypted using CMK', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceEncryptionAtRest.spec.js b/plugins/azure/servicebus/namespaceEncryptionAtRest.spec.js new file mode 100644 index 000000000..1c43c0922 --- /dev/null +++ b/plugins/azure/servicebus/namespaceEncryptionAtRest.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var namespaceEncryptionAtRest = require('./namespaceEncryptionAtRest.js'); + +const namespaces = [ + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active' + }, + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test2', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active', + encryption: { + keySource: 'Microsoft.KeyVault', + requireInfrastructureEncryption: false + }, + }, + { + sku: { name: 'Basic', tier: 'Basic' }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test3', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceEncryptionAtRest', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if namespace is not using premium tier', function (done) { + const cache = createCache([namespaces[2]], null); + namespaceEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace is not a premium namespace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if namespace is using CMK encryption', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace is encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if namespace is not using CMK encryption', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceEncryptionAtRest.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus Namespace is not encrypted using CMK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceHasTags.js b/plugins/azure/servicebus/namespaceHasTags.js new file mode 100644 index 000000000..d6ddd3ddf --- /dev/null +++ b/plugins/azure/servicebus/namespaceHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Service Bus Namespace Has Tags', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that Azure Service Bus namespaces have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Service Bus namespace and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + if (!namespace.id) continue; + + if (namespace.tags && Object.entries(namespace.tags).length > 0) { + helpers.addResult(results, 0, 'Service Bus Namespace has tags', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus Namespace does not have tags', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceHasTags.spec.js b/plugins/azure/servicebus/namespaceHasTags.spec.js new file mode 100644 index 000000000..d71692549 --- /dev/null +++ b/plugins/azure/servicebus/namespaceHasTags.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var namespaceHasTags = require('./namespaceHasTags.js'); + +const namespaces = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + tags: {} + }, + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + tags:{ + abc: "1234" + } + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceHasTags', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if Service Bus namespace has tags', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Service Bus namespace does not have tags', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus Namespace does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceInfraEncryption.js b/plugins/azure/servicebus/namespaceInfraEncryption.js new file mode 100644 index 000000000..6c668bc0d --- /dev/null +++ b/plugins/azure/servicebus/namespaceInfraEncryption.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Infrastructure Encryption Enabled', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Azure Service Bus namespaces have infrastructure level encryption enabled.', + more_info: 'Enabling infrastructure level encryption for Azure Service Bus namespaces allows their data to be encrypted twice, once at the service level and once at the infrastructure level, using two different encryption algorithms and two different keys and provides an extra layer of protection and security in case one of the keys is compromised.', + recommended_action: 'Enable infrastructure level encryption for all Azure Service Bus namespaces.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/configure-customer-managed-key#enable-infrastructure-double-encryption-of-data', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + + if (namespace.sku && namespace.sku.tier && namespace.sku.tier.toLowerCase() !== 'premium') { + helpers.addResult(results, 0, 'Service Bus Namespace is not a premium namespace', location, namespace.id); + } else if (namespace.encryption && Object.keys(namespace.encryption).length && namespace.encryption.requireInfrastructureEncryption) { + helpers.addResult(results, 0, 'Service Bus Namespace has infrastructure level encryption enabled', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus Namespace does not have infrastructure level encryption enabled', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceInfraEncryption.spec.js b/plugins/azure/servicebus/namespaceInfraEncryption.spec.js new file mode 100644 index 000000000..e4ce33ad0 --- /dev/null +++ b/plugins/azure/servicebus/namespaceInfraEncryption.spec.js @@ -0,0 +1,122 @@ +var expect = require('chai').expect; +var namespaceInfraEncryption = require('./namespaceInfraEncryption.js'); + +const namespaces = [ + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active', + encryption: { + keySource: 'Microsoft.KeyVault', + requireInfrastructureEncryption: false + } + }, + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test2', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active', + encryption: { + keySource: 'Microsoft.KeyVault', + requireInfrastructureEncryption: true + } + }, + { + sku: { name: 'Basic', tier: 'Basic' }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test3', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceInfraEncryption', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if namespace is not using premium tier', function (done) { + const cache = createCache([namespaces[2]], null); + namespaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace is not a premium namespace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if namespace has infrastructure level encryption enabled', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace has infrastructure level encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if namespace does not have infrastructure level encryption enabled', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceInfraEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus Namespace does not have infrastructure level encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceLocalAuth.js b/plugins/azure/servicebus/namespaceLocalAuth.js new file mode 100644 index 000000000..c85df38ba --- /dev/null +++ b/plugins/azure/servicebus/namespaceLocalAuth.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Local Authentication Disabled', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensures local authentication is disabled for Service Bus namespaces.', + more_info: 'For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication (shared access policies) in Azure Service Bus namespaces.', + recommended_action: 'Ensure that Azure Service Bus namespaces have local authentication disabled.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/disable-local-authentication', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + if (namespace.disableLocalAuth) { + helpers.addResult(results, 0, 'Service Bus Namespace has local authentication disabled', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus Namespace has local authentication enabled', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceLocalAuth.spec.js b/plugins/azure/servicebus/namespaceLocalAuth.spec.js new file mode 100644 index 000000000..e48d05f7f --- /dev/null +++ b/plugins/azure/servicebus/namespaceLocalAuth.spec.js @@ -0,0 +1,90 @@ +var expect = require('chai').expect; +var namespaceLocalAuth = require('./namespaceLocalAuth.js'); + +const namespaces = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active' + }, + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceLocalAuth', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceLocalAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceLocalAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if local authenication is disabled for namespace', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceLocalAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace has local authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if local authentication is enabled for namespace', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceLocalAuth.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus Namespace has local authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceLoggingEnabled.js b/plugins/azure/servicebus/namespaceLoggingEnabled.js new file mode 100644 index 000000000..1f80ae34e --- /dev/null +++ b/plugins/azure/servicebus/namespaceLoggingEnabled.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Logging Enabled', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Azure Service Bus namespaces have diagnostic logs enabled.', + more_info: 'Diagnostic logs provide valuable insights into the operation and health of Service Bus namespaces. By enabling diagnostic logs, you can enhance visibility, easily monitor and troubleshoot and optimize messaging performance.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/monitor-service-bus-reference', + recommended_action: 'Modify the namespace settings and enable diagnostic logs.', + apis: ['serviceBus:listNamespacesBySubscription', 'diagnosticSettings:listByServiceBusNamespaces'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No Service Bus namespaces found', location); + return rcb(); + } + for (let namespace of namespaces.data) { + if (!namespace.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByServiceBusNamespaces', location, namespace.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for namespace diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, namespace.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Service Bus namespace has diagnostic logs enabled', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus namespace does not have diagnostic logs enabled', location, namespace.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/servicebus/namespaceLoggingEnabled.spec.js b/plugins/azure/servicebus/namespaceLoggingEnabled.spec.js new file mode 100644 index 000000000..84427ef11 --- /dev/null +++ b/plugins/azure/servicebus/namespaceLoggingEnabled.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +var namespaceLoggingEnabled = require('./namespaceLoggingEnabled'); + +const namespaces = [ + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active' + }, + + +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'servicebus-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (namespaces, ds) => { + const id = namespaces && namespaces.length ? namespaces[0].id : null; + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces + } + } + }, + diagnosticSettings: { + listByServiceBusNamespaces: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('namespaceLoggingEnabled', function() { + describe('run', function() { + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([namespaces[0]], null); + namespaceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for namespace diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([namespaces[0]], [diagnosticSettings[0]]); + namespaceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus namespace has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([namespaces[0]], [[]]); + namespaceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus namespace does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/servicebus/namespaceManagedIdentity.js b/plugins/azure/servicebus/namespaceManagedIdentity.js new file mode 100644 index 000000000..a6018ac70 --- /dev/null +++ b/plugins/azure/servicebus/namespaceManagedIdentity.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Managed Identity', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Azure Service Bus namespaces have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Service Bus namespace and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-managed-service-identity', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + if (namespace.sku && namespace.sku.tier && namespace.sku.tier.toLowerCase() !== 'premium') { + helpers.addResult(results, 0, 'Service Bus Namespace is not a premium namespace', location, namespace.id); + } else if (namespace.identity && namespace.identity.type) { + helpers.addResult(results, 0, 'Service bus namespace has managed identity enabled', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service bus namespace does not have managed identity enabled', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceManagedIdentity.spec.js b/plugins/azure/servicebus/namespaceManagedIdentity.spec.js new file mode 100644 index 000000000..66b05120b --- /dev/null +++ b/plugins/azure/servicebus/namespaceManagedIdentity.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var namespaceManagedIdentity = require('./namespaceManagedIdentity'); + +const namespaces = [ + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active' + }, + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test2', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Disabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active', + identity : { + "type": 'SystemAssigned' + }, + encryption: { + keySource: 'Microsoft.KeyVault', + requireInfrastructureEncryption: false + }, + }, + { + sku: { name: 'Basic', tier: 'Basic' }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test3', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceManagedIdentity', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if namespace is not using premium tier', function (done) { + const cache = createCache([namespaces[2]], null); + namespaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace is not a premium namespace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if namespace has managed identity', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service bus namespace has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if namespace doesnot have managed identity', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service bus namespace does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespacePublicAccess.js b/plugins/azure/servicebus/namespacePublicAccess.js new file mode 100644 index 000000000..dcf210591 --- /dev/null +++ b/plugins/azure/servicebus/namespacePublicAccess.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Public Access', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that Azure Service Bus namespaces are not publicly accessible.', + more_info: 'Using private endpoints for Azure Service Bus namespace improve security by enabling private network access, encrypting communication, and enhancing performance. They seamlessly integrate with virtual networks, ensuring compliance and suitability for hybrid cloud scenarios.', + recommended_action: 'Ensure that Azure Service Bus namespaces are only accessible through private endpoints.', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/private-link-service', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + if (namespace.sku && namespace.sku.tier && namespace.sku.tier.toLowerCase() !== 'premium') { + helpers.addResult(results, 0, 'Service Bus Namespace is not a premium namespace', location, namespace.id); + } else if (namespace.publicNetworkAccess && namespace.publicNetworkAccess.toLowerCase() === 'enabled') { + helpers.addResult(results, 2, 'Service bus namespace is publicly accessible', location, namespace.id); + } else { + helpers.addResult(results, 0, 'Service bus namespace is only accessible through private endpoints', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespacePublicAccess.spec.js b/plugins/azure/servicebus/namespacePublicAccess.spec.js new file mode 100644 index 000000000..4caed1c4e --- /dev/null +++ b/plugins/azure/servicebus/namespacePublicAccess.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var namespacePublicAccess = require('./namespacePublicAccess.js'); + +const namespaces = [ + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active' + }, + { + sku: { name: 'Premium', tier: 'Premium', capacity: 1 }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test2', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Disabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active', + encryption: { + keySource: 'Microsoft.KeyVault', + requireInfrastructureEncryption: false + }, + }, + { + sku: { name: 'Basic', tier: 'Basic' }, + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test3', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespacePublicAccess', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if namespace is not using premium tier', function (done) { + const cache = createCache([namespaces[2]], null); + namespacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus Namespace is not a premium namespace'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if namespace is not publicly accessible', function (done) { + const cache = createCache([namespaces[1]], null); + namespacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service bus namespace is only accessible through private endpoints'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if namespace is publicly accessible', function (done) { + const cache = createCache([namespaces[0]], null); + namespacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service bus namespace is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceTlsVersion.js b/plugins/azure/servicebus/namespaceTlsVersion.js new file mode 100644 index 000000000..cf1aac979 --- /dev/null +++ b/plugins/azure/servicebus/namespaceTlsVersion.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Namespace Minimum TLS Version', + category: 'Service Bus', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensures that Azure Service Bus namespace is using the latest TLS version.', + more_info: 'TLS versions 1.0 and 1.1 are known to be susceptible to attacks, and to have other Common Vulnerabilities and Exposures (CVE) weaknesses. So there\'s an industry-wide push toward the exclusive use of Transport Layer Security(TLS) version 1.2 or later.', + recommended_action: 'Ensure that Azure Srvice Bus namespaces are using the latest TLS version', + link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/transport-layer-security-enforce-minimum-version', + apis: ['serviceBus:listNamespacesBySubscription'], + realtime_triggers: ['microsoftservicebus:namespaces:write','microsoftservicebus:namespaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.serviceBus, function(location, rcb) { + const namespaces = helpers.addSource(cache, source, + ['serviceBus', 'listNamespacesBySubscription', location]); + + if (!namespaces) return rcb(); + + + if (namespaces.err || !namespaces.data) { + helpers.addResult(results, 3, 'Unable to query Service Bus namespaces: ' + helpers.addError(namespaces), location); + return rcb(); + } + + if (!namespaces.data.length) { + helpers.addResult(results, 0, 'No existing Service Bus namespaces found', location); + return rcb(); + } + + for (let namespace of namespaces.data) { + if (namespace.minimumTlsVersion && (parseFloat(namespace.minimumTlsVersion) >= 1.2)) { + helpers.addResult(results, 0, 'Service Bus namespace is using the latest TLS Version', location, namespace.id); + } else { + helpers.addResult(results, 2, 'Service Bus namespace is not using the latest TLS Version', location, namespace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/servicebus/namespaceTlsVersion.spec.js b/plugins/azure/servicebus/namespaceTlsVersion.spec.js new file mode 100644 index 000000000..28042a559 --- /dev/null +++ b/plugins/azure/servicebus/namespaceTlsVersion.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +var namespaceTlsVersion = require('./namespaceTlsVersion.js'); + +const namespaces = [ + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: false, + provisioningState: 'Succeeded', + status: 'Active', + minimumTlsVersion: '1.1' + }, + { + id: '/subscriptions/234/myrg/providers/Microsoft.ServiceBus/namespaces/test', + name: 'test2', + type: 'Microsoft.ServiceBus/Namespaces', + location: 'East US', + publicNetworkAccess: 'Enabled', + disableLocalAuth: true, + provisioningState: 'Succeeded', + status: 'Active', + minimumTlsVersion: '1.2' + }, +]; + + +const createCache = (namespaces, err) => { + + return { + serviceBus: { + listNamespacesBySubscription: { + 'eastus': { + data: namespaces, + err: err + } + } + } + }; +}; + +describe('namespaceTlsVersion', function () { + describe('run', function () { + + it('should give a passing result if no Service Bus namespaces are found', function (done) { + const cache = createCache([], null); + namespaceTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Service Bus namespaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Service Bus namespaces', function (done) { + const cache = createCache(null, ['error']); + namespaceTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Service Bus namespaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + + it('should give passing result if namespace is using the latest tls version', function (done) { + const cache = createCache([namespaces[1]], null); + namespaceTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Bus namespace is using the latest TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if namespace is not using the latest tls version', function (done) { + const cache = createCache([namespaces[0]], null); + namespaceTlsVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Bus namespace is not using the latest TLS Version'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqldatabases/dbAuditingEnabled.js b/plugins/azure/sqldatabases/dbAuditingEnabled.js index 74c6fc022..9080abb4a 100644 --- a/plugins/azure/sqldatabases/dbAuditingEnabled.js +++ b/plugins/azure/sqldatabases/dbAuditingEnabled.js @@ -4,16 +4,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Database Auditing Enabled', category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL Database Auditing is enabled', more_info: 'Enabling SQL Database Auditing ensures that all database activities are being logged properly, including potential malicious activity.', recommended_action: 'Ensure that auditing is enabled for each SQL database.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-enable-auditing-on-sql-databases', - apis: ['servers:listSql', 'databases:listByServer', 'databaseBlobAuditingPolicies:get'], + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-enable-auditing-on-sql-databases', + apis: ['servers:listSql', 'databases:listByServer', 'databaseBlobAuditingPolicies:get', 'serverBlobAuditingPolicies:get'], compliance: { hipaa: 'HIPAA requires that a secure audit record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:auditingsettings:write','microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:auditingsettings:write', 'microsoftsql:servers:databases:delete'], run: function(cache, settings, callback) { var results = []; @@ -39,41 +42,56 @@ module.exports = { // Loop through servers and check databases servers.data.forEach(function(server){ - var databases = helpers.addSource(cache, source, - ['databases', 'listByServer', location, server.id]); - - if (!databases || databases.err || !databases.data) { + var serverBlobAuditingPolicies = helpers.addSource(cache, source, + ['serverBlobAuditingPolicies', 'get', location, server.id]); + if (!serverBlobAuditingPolicies || serverBlobAuditingPolicies.err || !serverBlobAuditingPolicies.data) { helpers.addResult(results, 3, - 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + 'Unable to query for SQL server auditing policies: ' + helpers.addError(serverBlobAuditingPolicies), location, server.id); } else { - if (!databases.data.length) { - helpers.addResult(results, 0, - 'No databases found for SQL server', location, server.id); + let serverEnabled = serverBlobAuditingPolicies.data.length ? serverBlobAuditingPolicies.data.find(auditPolicy => auditPolicy.state && auditPolicy.state.toLowerCase() == 'enabled') : false; + if (serverEnabled) { + helpers.addResult(results, 0, 'Server Auditing is enabled on the SQL server', location, serverEnabled.id); } else { - // Loop through databases and add policies - databases.data.forEach(function(database){ - var databaseBlobAuditingPolicies = helpers.addSource(cache, source, - ['databaseBlobAuditingPolicies', 'get', location, database.id]); + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); - if (!databaseBlobAuditingPolicies || databaseBlobAuditingPolicies.err || !databaseBlobAuditingPolicies.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL server database auditing policies: ' + helpers.addError(databaseBlobAuditingPolicies), location, database.id); + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); } else { - if (!databaseBlobAuditingPolicies.data.length) { - helpers.addResult(results, 2, - 'SQL server database does not contain auditing policies', location, database.id); - } else { - databaseBlobAuditingPolicies.data.forEach(function(policy){ - if (policy.state && - policy.state.toLowerCase() == 'enabled') { - helpers.addResult(results, 0, 'Database Auditing is enabled on the SQL database', location, policy.id); + // Loop through databases and add policies + databases.data.forEach(function(database){ + + if (database.name && database.name.toLowerCase() !== 'master') { + var databaseBlobAuditingPolicies = helpers.addSource(cache, source, + ['databaseBlobAuditingPolicies', 'get', location, database.id]); + + if (!databaseBlobAuditingPolicies || databaseBlobAuditingPolicies.err || !databaseBlobAuditingPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server database auditing policies: ' + helpers.addError(databaseBlobAuditingPolicies), location, database.id); } else { - helpers.addResult(results, 2, 'Database Auditing is not enabled on the SQL database', location, policy.id); + if (!databaseBlobAuditingPolicies.data.length) { + helpers.addResult(results, 2, + 'SQL server database does not contain auditing policies', location, database.id); + } else { + databaseBlobAuditingPolicies.data.forEach(function(policy){ + if (policy.state && + policy.state.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Database Auditing is enabled on the SQL database', location, policy.id); + } else { + helpers.addResult(results, 2, 'Database Auditing is not enabled on the SQL database', location, policy.id); + } + }); + } } - }); - } + } + }); } - }); + } } } }); diff --git a/plugins/azure/sqldatabases/dbAuditingEnabled.spec.js b/plugins/azure/sqldatabases/dbAuditingEnabled.spec.js new file mode 100644 index 000000000..2ef138551 --- /dev/null +++ b/plugins/azure/sqldatabases/dbAuditingEnabled.spec.js @@ -0,0 +1,263 @@ +var expect = require('chai').expect; +var dbAuditingEnabled = require('./dbAuditingEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database', + "name": "test-database", + } +]; + +const databaseBlobAuditingPolicies = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/databases/auditingSettings", + "retentionDays": 9, + "isAzureMonitorTargetEnabled": true, + "state": "Enabled", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/databases/auditingSettings", + "retentionDays": 9, + "isAzureMonitorTargetEnabled": true, + "state": "Disabled", + } +]; + +const serverBlobAuditingPolicies = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "isDevopsAuditEnabled": false, + "retentionDays": 0, + "auditActionsAndGroups": [ + "SUCCESSFUL_DATABASE_AUTHENTICATION_GROUP", + "FAILED_DATABASE_AUTHENTICATION_GROUP", + "BATCH_COMPLETED_GROUP" + ], + "isStorageSecondaryKeyInUse": false, + "isAzureMonitorTargetEnabled": false, + "state": "Enabled", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "isDevopsAuditEnabled": false, + "retentionDays": 0, + "auditActionsAndGroups": [ + "SUCCESSFUL_DATABASE_AUTHENTICATION_GROUP", + "FAILED_DATABASE_AUTHENTICATION_GROUP", + "BATCH_COMPLETED_GROUP" + ], + "isStorageSecondaryKeyInUse": false, + "isAzureMonitorTargetEnabled": false, + "state": "Disabled", + } +] + +const createCache = (servers, serverBlobAuditingPolicies, databases, databaseBlobAuditingPolicies, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const dbId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + databaseBlobAuditingPolicies: { + get: { + 'eastus': { + [dbId]: { + data: databaseBlobAuditingPolicies + } + } + } + }, + serverBlobAuditingPolicies: { + get: { + 'eastus': { + [serverId]: { + data: serverBlobAuditingPolicies + } + } + } + } + } +}; + +describe('dbAuditingEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + [] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give failing result if SQL server database does not contain auditing policies', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL server database does not contain auditing policies'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + databases, + [] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give failing result if Database Auditing is not enabled on the SQL database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Database Auditing is not enabled on the SQL database'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + databases, + [databaseBlobAuditingPolicies[1]] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if Database Auditing is enabled on the SQL database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Database Auditing is enabled on the SQL database'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + databases, + [databaseBlobAuditingPolicies[0]] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if server auditing is enabled on the SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Server Auditing is enabled on the SQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [serverBlobAuditingPolicies[0]] + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + [], + [], + [], + { message: 'unable to query servers'} + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + [], + [], + null, + { message: 'unable to query databases'} + ); + + dbAuditingEnabled.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/azure/sqldatabases/dbDataDiscoveryClassification.js b/plugins/azure/sqldatabases/dbDataDiscoveryClassification.js new file mode 100644 index 000000000..d615ee762 --- /dev/null +++ b/plugins/azure/sqldatabases/dbDataDiscoveryClassification.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Database Data Discovery and Classification', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Low', + description: 'Ensures that data discovery and classification is implemented for SQL databases', + more_info: 'Enabling Data Discovery & Classification for Azure SQL Server databases helps identify, classify, and protect sensitive data, ensuring compliance with privacy standards and regulatory requirements. It enhances security by providing insights into data exposure risks.', + recommended_action: 'Implement data discovery and appropriate classifications for SQL databases.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/data-discovery-and-classification-overview?view=azuresql', + apis: ['servers:listSql', 'databases:listByServer', 'currentSensitivityLabels:list'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server => { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(function(database) { + + if (database.name && database.name.toLowerCase() !== 'master') { + var currentSensitivityLabels = helpers.addSource(cache, source, ['currentSensitivityLabels', 'list', location, database.id]); + + if (!currentSensitivityLabels || !currentSensitivityLabels.data || currentSensitivityLabels.err) { + helpers.addResult(results, 2, 'Unable to query data discovery and classification information: ' + helpers.addError(currentSensitivityLabels), location, database.id); + } else { + if (currentSensitivityLabels.data.length) { + helpers.addResult(results, 0, 'SQL Database is using data discovery and classification', location, database.id); + } else { + helpers.addResult(results, 2, 'SQL Database is not using data discovery and classification', location, database.id); + } + } + } + }); + + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbDataDiscoveryClassification.spec.js b/plugins/azure/sqldatabases/dbDataDiscoveryClassification.spec.js new file mode 100644 index 000000000..a10453362 --- /dev/null +++ b/plugins/azure/sqldatabases/dbDataDiscoveryClassification.spec.js @@ -0,0 +1,195 @@ +var expect = require('chai').expect; +var dataDiscoveryAndClassification = require('./dbDataDiscoveryClassification'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + } +]; + +const sensitivityLabels = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/currentSensitivityLabels/1", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/currentSensitivityLabels/2", + }, +]; + +const createCache = (servers, databases, sensitivityLabels, serversErr, databasesErr, sensitivityLabelsErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const databaseId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + currentSensitivityLabels: { + list: { + 'eastus': { + [databaseId]: { + err: sensitivityLabelsErr, + data: sensitivityLabels + } + } + } + } + }; +}; + +describe('dataDiscoveryAndClassification', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + sensitivityLabels + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + sensitivityLabels + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give passing result if Data discovery and classification is being used for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Database is using data discovery and classification'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + sensitivityLabels + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give failing result if Data discovery and classification is not being used for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Database is not using data discovery and classification'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [] + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + sensitivityLabels, + { message: 'unable to query servers' } + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + sensitivityLabels, + null, + { message: 'unable to query databases' } + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + + it('should give unknown result if unable to check data discovery and classification', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Unable to query data discovery and classification information: '); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + sensitivityLabels, + null, + null, + { message: 'unable to check sensitivity labels' } + ); + + dataDiscoveryAndClassification.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbDataMaskingEnabled.js b/plugins/azure/sqldatabases/dbDataMaskingEnabled.js new file mode 100644 index 000000000..3577843f1 --- /dev/null +++ b/plugins/azure/sqldatabases/dbDataMaskingEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Databases Data Masking Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures dynamic data masking is enabled for all SQL databases.', + more_info: 'Dynamic data masking helps prevent unauthorized access to sensitive data by enabling customers to specify how much sensitive data to reveal with minimal effect on the application layer. DDM can be configured on designated database fields to hide sensitive data in the result sets of queries.', + recommended_action: 'Enable dynamic data masking for SQL databases.', + link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-dynamic-data-masking-get-started-portal', + apis: ['servers:listSql', 'databases:listByServer', 'dataMaskingPolicies:get'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + // Loop through servers and check databases + servers.data.forEach(server => { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + if (database.name && database.name.toLowerCase() === 'master' || (database.sku && database.sku.tier && database.sku.tier.toLowerCase() === 'datawarehouse')) return; + + var dataMaskingPolicies = helpers.addSource(cache, source, ['dataMaskingPolicies', 'get', location, database.id]); + + if (!dataMaskingPolicies || dataMaskingPolicies.err || !dataMaskingPolicies.data) { + helpers.addResult(results, 3, 'Unable to query dynamic data masking policies: ' + helpers.addError(dataMaskingPolicies), location, database.id); + } else { + if (dataMaskingPolicies.data.dataMaskingState && dataMaskingPolicies.data.dataMaskingState.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Dynamic data masking is enabled for SQL database', location, database.id); + } else { + helpers.addResult(results, 2, 'Dynamic data masking is not enabled for SQL database', location, database.id); + } + } + }); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/azure/sqldatabases/dbDataMaskingEnabled.spec.js b/plugins/azure/sqldatabases/dbDataMaskingEnabled.spec.js new file mode 100644 index 000000000..dfa586705 --- /dev/null +++ b/plugins/azure/sqldatabases/dbDataMaskingEnabled.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var setDynamicDataMasking = require('./dbDataMaskingEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + } +]; + +const dataMaskingPolicies = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/datamaskingpolicies/default", + "name": "default", + "type": "Microsoft.Sql/servers/databases/datamaskingpolicies", + "dataMaskingState": "Enabled", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/datamaskingpolicies/default", + "name": "default", + "type": "Microsoft.Sql/servers/databases/datamaskingpolicies", + "dataMaskingState": "Disabled", + } +]; + +const createCache = (servers, databases, dataMaskingPolicies, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const dbId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + dataMaskingPolicies: { + get: { + 'eastus': { + [dbId]: { + data: dataMaskingPolicies + } + } + } + } + }; +}; + +describe('setDynamicDataMasking', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [] + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [] + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + + it('should give passing result if Dynamic data masking is enabled for SQL database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Dynamic data masking is enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + dataMaskingPolicies[0] + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + + it('should give failing result if Dynamic data masking is not enabled for SQL database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Dynamic data masking is not enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + dataMaskingPolicies[1] + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('unable to query servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + [], + [], + { message: 'unable to query servers' } + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('unable to query databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + null, + { message: 'unable to query databases' } + ); + + setDynamicDataMasking.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.js b/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.js new file mode 100644 index 000000000..a5f4dcfe3 --- /dev/null +++ b/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.js @@ -0,0 +1,128 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Database Diagnostic Logging Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures diagnostic logging is enabled for SQL databases.', + more_info: 'Enabling diagnostic logging provides valuable insights into SQL database that helps to monitor resources for their availability, performance, and operation.', + recommended_action: 'Enable diagnostic logging for all SQL databases.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/monitoring-sql-database-azure-monitor?view=azuresql', + apis: ['servers:listSql', 'databases:listByServer', 'diagnosticSettings:listByDatabase'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + settings: { + sqldb_diagnostic_logs: { + name: 'Diagnostic Logs Enabled', + description: 'Comma separated list of diagnostic logs that should be enabled at minimum i.e. SQLInsights, AutomaticTuning, Errors etc. If you have enabled allLogs, then resource produces pass result. If you only want to check if logging is enabled or not, irrespecitve of log type, then add * in setting.', + regex: '^.*$', + default: 'SQLInsights, Errors, Timeouts, Blocks, Deadlocks' + + }, + sqldb_diagnostic_metrics: { + name: 'Diagnostic Metrics Enabled', + description: 'Comma separated list of diagnostic metrics that should be enabled at minimum i.e. Basic, InstanceAndAppAdvanced, WorkloadManagement. If you only want to check if mertics is enabled or not, irrespecitve of metric type, then add * in setting.', + regex: '^.*$', + default: 'Basic, InstanceAndAppAdvanced, WorkloadManagement' + + }, + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var logsConfig = settings.sqldb_diagnostic_logs || this.settings.sqldb_diagnostic_logs.default; + var metricsConfig = settings.sqldb_diagnostic_metrics || this.settings.sqldb_diagnostic_metrics.default; + + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + return; + } + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + return; + + } else { + databases.data.forEach(database=> { + + if (database.name && database.name.toLowerCase() !== 'master') { + + var diagnosticSettings = helpers.addSource(cache, source, ['diagnosticSettings', 'listByDatabase', location, database.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query SQL database diagnostic settings: ' + helpers.addError(diagnosticSettings), location, database.id); + return; + + } + var foundLogs = true; + var foundMetrics = true; + + var missingLogs = []; + var missingMetrics = []; + var missingConfig = []; + + if (logsConfig == '*') { + foundLogs = diagnosticSettings.data.some(ds => ds.logs && ds.logs.length && ds.logs.some(log=>log.enabled)); + } else { + logsConfig = logsConfig.replace(/\s/g, ''); + missingLogs = logsConfig.toLowerCase().split(','); + diagnosticSettings.data.forEach(settings => { + missingLogs = missingLogs.filter(requiredCategory => + !settings.logs.some(log => (log.category && log.category.toLowerCase() === requiredCategory && log.enabled) || log.categoryGroup && log.categoryGroup.toLowerCase() === 'alllogs' && log.enabled) + ); + }); + } + if (metricsConfig == '*') { + foundMetrics = diagnosticSettings.data.some(ds => ds.metrics && ds.metrics.length && ds.metrics.some(metrics=>metrics.enabled)); + } else { + metricsConfig = metricsConfig.replace(/\s/g, ''); + missingMetrics = metricsConfig.toLowerCase().split(','); + diagnosticSettings.data.forEach(settings => { + missingMetrics = missingMetrics.filter(requiredCategory => + !settings.metrics.some(metric => (metric.category && metric.category.toLowerCase() === requiredCategory && metric.enabled)) + ); + }); + } + missingConfig = [...missingLogs, ...missingMetrics]; + + if (!missingConfig.length && foundLogs && foundMetrics) { + helpers.addResult(results, 0, 'SQL database has diagnostic logs/metrics enabled', location, database.id); + + } else { + helpers.addResult(results, 2, `SQL database does not have diagnostic logs/metrics enabled ${missingConfig.length ? `for following: ${missingConfig.join(',')}` : ''}`, location, database.id); + } + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.spec.js b/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.spec.js new file mode 100644 index 000000000..14401052c --- /dev/null +++ b/plugins/azure/sqldatabases/dbDiagnosticLoggingEnabled.spec.js @@ -0,0 +1,235 @@ +var expect = require('chai').expect; +var dbDiagnosticLoggingEnabled = require('./dbDiagnosticLoggingEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database" + } +]; + +const diagnosticSettings = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/diagnosticSettings/1", + "logs": [ + { "category": "SQLInsights", "enabled": true }, + { "category": "Errors", "enabled": true }, + { "category": "Timeouts", "enabled": true }, + { "category": "Blocks", "enabled": true }, + { "category": "Deadlocks", "enabled": true } + ], + "metrics": [ + { "category": "Basic", "enabled": true }, + { "category": "InstanceAndAppAdvanced", "enabled": true }, + { "category": "WorkloadManagement", "enabled": true }, + + ] + } +]; + +const createCache = (servers, databases, diagnosticSettings, serversErr, databasesErr, diagnosticSettingsErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const databaseId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + diagnosticSettings: { + listByDatabase: { + 'eastus': { + [databaseId]: { + err: diagnosticSettingsErr, + data: diagnosticSettings + } + } + } + } + }; +}; + +describe('dbDiagnosticLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + diagnosticSettings + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + diagnosticSettings + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give passing result if diagnostic settings configured with minimum requirements', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL database has diagnostic logs/metrics enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + diagnosticSettings + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give failing result if diagnostic settings not configured for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL database does not have diagnostic logs/metrics enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [] + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give failing result if diagnostic settings not configured with minimum requirements', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL database does not have diagnostic logs/metrics enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/diagnosticSettings/1", + "metrics": [ + { "category": "SQLInsights", "enabled": true }, + { "category": "Errors", "enabled": false } // Errors is required, but not enabled + ], + "logs": [ + { "category": "Timeouts", "enabled": true }, + { "category": "Blocks", "enabled": true } + ] + } + ] + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + diagnosticSettings, + { message: 'unable to query servers' } + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + diagnosticSettings, + null, + { message: 'unable to query databases' } + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query diagnostic settings', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL database diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [], + null, + null, + { message: 'unable to query diagnostic settings' } + ); + + dbDiagnosticLoggingEnabled.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbEnableSecureEnclaves.js b/plugins/azure/sqldatabases/dbEnableSecureEnclaves.js new file mode 100644 index 000000000..5d4c15329 --- /dev/null +++ b/plugins/azure/sqldatabases/dbEnableSecureEnclaves.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Database Secure Enclaves Encryption Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure secure enclaves encryption is enabled for SQL databases.', + more_info: 'Secure enclaves encryption protects the data by encrypting it on the client side and never allowing the data or the corresponding cryptographic keys to appear in plaintext inside the Database Engine. As a result, the functionality on encrypted columns inside the database is severely restricted.', + recommended_action: 'Enable secure enclaves encryption for all SQL databases.', + link: 'https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-enclaves?view=sql-server-ver16', + apis: ['servers:listSql', 'databases:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server=> { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + if (!database.preferredEnclaveType) { + helpers.addResult(results, 2, 'Secure enclaves encryption is disabled for SQL database', location, database.id); + } else { + helpers.addResult(results, 0, 'Secure enclaves encryption is enabled for SQL database', location, database.id); + } + } + }); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbEnableSecureEnclaves.spec.js b/plugins/azure/sqldatabases/dbEnableSecureEnclaves.spec.js new file mode 100644 index 000000000..aadb49234 --- /dev/null +++ b/plugins/azure/sqldatabases/dbEnableSecureEnclaves.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var enableAlwaysEncrypted = require('./dbEnableSecureEnclaves'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "preferredEnclaveType": "VBS", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database" + }, +]; + +const createCache = (servers, databases, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + } + }; +}; + +describe('enableAlwaysEncrypted', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [] + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [] + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if Always Encrypted with secure enclaves is enabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secure enclaves encryption is enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [databases[0]] + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + + it('should give failing result if Always Encrypted with secure enclaves is disabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secure enclaves encryption is disabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [databases[1]] + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + [], + { message: 'unable to query servers' } + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + null, + { message: 'unable to query databases' } + ); + + enableAlwaysEncrypted.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.js b/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.js new file mode 100644 index 000000000..467079b56 --- /dev/null +++ b/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Ledger Automatic Digest Storage', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that ledger automatic digest storage is enabled.', + more_info: 'Configuring Ledger automatic digest storage allows the generation and storage of digests for later verification. Automatic database digests are generated on a predefined interval of 30 seconds and uploaded to the selected storage service.', + recommended_action: 'Modify SQL database ledger and add storage account for automatic digest storage.', + link: 'https://learn.microsoft.com/en-us/sql/relational-databases/security/ledger/ledger-overview?view=sql-server-ver16', + apis: ['servers:listSql', 'databases:listByServer', 'ledgerDigestUploads:list'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:ledgerdigestuploads:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + // Loop through servers and check databases + servers.data.forEach(function(server) { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + + var ledgerDigestUploads = helpers.addSource(cache, source, ['ledgerDigestUploads', 'list', location, database.id]); + if (!ledgerDigestUploads || ledgerDigestUploads.err || !ledgerDigestUploads.data) { + helpers.addResult(results, 3, 'Unable to query for Ledger Digest Uploads for SQL database: ' + helpers.addError(ledgerDigestUploads), location, database.id); + } else { + if (ledgerDigestUploads.data.length && ledgerDigestUploads.data[0].state && ledgerDigestUploads.data[0].state.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Ledger automatic digest storage is enabled for SQL database', location, database.id); + } else { + helpers.addResult(results, 2, 'Ledger automatic digest storage is not enabled for SQL database', location, database.id); + } + + } + } + }); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.spec.js b/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.spec.js new file mode 100644 index 000000000..03f6aed90 --- /dev/null +++ b/plugins/azure/sqldatabases/dbLedgerDigestStorageEnabled.spec.js @@ -0,0 +1,197 @@ +var expect = require('chai').expect; +var enableAutomaticLedgerDigestStorage = require('./dbLedgerDigestStorageEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + } +]; + +const ledgerDigestUploads = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/ledgerDigestUploads/1", + "state": "Enabled", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/ledgerDigestUploads/2", + "state": "Disabled", + }, +]; + +const createCache = (servers, databases, ledgerDigestUploads, serversErr, databasesErr, ledgerDigestUploadsErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const databaseId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + ledgerDigestUploads: { + list: { + 'eastus': { + [databaseId]: { + err: ledgerDigestUploadsErr, + data: ledgerDigestUploads + } + } + } + } + }; +}; + +describe('enableAutomaticLedgerDigestStorage', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + ledgerDigestUploads + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + ledgerDigestUploads + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give passing result if Automatic Ledger digest storage is enabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Ledger automatic digest storage is enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [ledgerDigestUploads[0]] + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give failing result if Automatic Ledger digest storage is disabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Ledger automatic digest storage is not enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [ledgerDigestUploads[1]] + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + ledgerDigestUploads, + { message: 'unable to query servers' } + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + ledgerDigestUploads, + null, + { message: 'unable to query databases' } + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Azure ledger', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Ledger Digest Uploads for SQL database:'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + null, + null, + null, + { message: 'unable to query ledger' } + ); + + enableAutomaticLedgerDigestStorage.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbLedgerEnabled.js b/plugins/azure/sqldatabases/dbLedgerEnabled.js new file mode 100644 index 000000000..b4b93adaa --- /dev/null +++ b/plugins/azure/sqldatabases/dbLedgerEnabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Database Ledger Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Low', + description: 'Ensure ledger is enabled for SQL databases.', + more_info: 'Azure ledger helps protect the integrity of data by enabling customers to use cryptographic seals on their data. The database ledger incrementally captures the state of a database as the database evolves over time, while updates occur on ledger tables', + recommended_action: 'Enable Azure ledger for all SQL databases.', + link: 'https://learn.microsoft.com/en-us/sql/relational-databases/security/ledger/ledger-overview?view=sql-server-ver16', + apis: ['servers:listSql', 'databases:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + // Loop through servers and check databases + servers.data.forEach(server=> { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + // Loop through databases + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + + if (database.isLedgerOn) { + helpers.addResult(results, 0, 'Ledger is enabled for SQL database', location, database.id); + } else { + helpers.addResult(results, 2, 'Ledger is not enabled for SQL database', location, database.id); + } + } + }); + } + + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbLedgerEnabled.spec.js b/plugins/azure/sqldatabases/dbLedgerEnabled.spec.js new file mode 100644 index 000000000..7354e910d --- /dev/null +++ b/plugins/azure/sqldatabases/dbLedgerEnabled.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var enableAzureLedger = require('./dbLedgerEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "isLedgerOn": true, + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "isLedgerOn": false, + } +]; + +const createCache = (servers, databases, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + } + }; +}; + +describe('enableAzureLedger', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [] + ); + + enableAzureLedger.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [] + ); + + enableAzureLedger.run(cache, {}, callback); + }); + + it('should give passing result if Ledger is enabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Ledger is enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [databases[0]] + ); + + enableAzureLedger.run(cache, {}, callback); + }); + + it('should give failing result if Ledger is not enabled for the database', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Ledger is not enabled for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [databases[1]] + ); + + enableAzureLedger.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + [], + { message: 'unable to query servers' } + ); + + enableAzureLedger.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + null, + { message: 'unable to query databases' } + ); + + enableAzureLedger.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbRestorable.js b/plugins/azure/sqldatabases/dbRestorable.js index f619736c8..8513696b7 100644 --- a/plugins/azure/sqldatabases/dbRestorable.js +++ b/plugins/azure/sqldatabases/dbRestorable.js @@ -4,15 +4,18 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'DB Restorable', category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', description: 'Ensures SQL Database instances can be restored to a recent point', more_info: 'Automated backups of SQL databases with recent restore points help ensure that database recovery operations can occur without significant data loss.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-recovery-using-backups', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-recovery-using-backups', recommended_action: 'Ensure that each SQL database has automated backups configured with a sufficient retention period and that the last known backup operation completes successfully.', apis: ['servers:listSql', 'databases:listByServer'], compliance: { hipaa: 'HIPAA requires backups of all user data ' + 'and inventory to ensure future availability.' }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], run: function(cache, settings, callback) { const results = []; @@ -51,12 +54,15 @@ module.exports = { } else { // Loop through databases databases.data.forEach(function(database) { - if (database.earliestRestoreDate) { - helpers.addResult(results, 0, - 'SQL Database is restorable', location, database.id); - } else { - helpers.addResult(results, 2, - 'SQL Database is not restorable', location, database.id); + if (database.name && database.name.toLowerCase() !== 'master') { + + if (database.earliestRestoreDate) { + helpers.addResult(results, 0, + 'SQL Database is restorable', location, database.id); + } else { + helpers.addResult(results, 2, + 'SQL Database is not restorable', location, database.id); + } } }); } diff --git a/plugins/azure/sqldatabases/dbRestorable.spec.js b/plugins/azure/sqldatabases/dbRestorable.spec.js new file mode 100644 index 000000000..bb81c2a41 --- /dev/null +++ b/plugins/azure/sqldatabases/dbRestorable.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var dbRestorable = require('./dbRestorable'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "earliestRestoreDate": "2021-03-05T16:01:34Z", + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database" + } +]; + + +const createCache = (servers, databases, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + } + } +}; + +describe('dbRestorable', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + dbRestorable.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + dbRestorable.run(cache, {}, callback); + }); + + it('should give failing result if SQL Database is not restorable', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Database is not restorable'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [databases[1]], + ); + + dbRestorable.run(cache, {}, callback); + }); + + it('should give passing result if SQL Database is restorable', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Database is restorable'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [databases[0]] + ); + + dbRestorable.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + [], + { message: 'unable to query servers'} + ); + + dbRestorable.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'unable to query databases'} + ); + + dbRestorable.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.js b/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.js new file mode 100644 index 000000000..5c12348c9 --- /dev/null +++ b/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Database Private Link Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL Database sync groups are configured to use private link.', + more_info: 'Private link feature allows you to choose a service managed private endpoint to establish a secure connection between the sync service and your member/hub databases during the data synchronization process. A service managed private endpoint is a private IP address within a specific virtual network and subnet.', + recommended_action: 'Configure SQL Database sync groups to use private link and mandate manual approval for private endpoint connections.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-data-sync-data-sql-server-sql-database?view=azuresql', + apis: ['servers:listSql', 'databases:listByServer', 'syncGroups:list'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:syncgroups:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + // Loop through servers and check databases + servers.data.forEach(function(server) { + + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + + var syncGroups = helpers.addSource(cache, source, ['syncGroups', 'list', location, database.id]); + + if (!syncGroups || syncGroups.err || !syncGroups.data) { + helpers.addResult(results, 3, 'Unable to query for SQL Database sync groups: ' + helpers.addError(syncGroups), location, database.id); + return; + } + if (!syncGroups.data.length) { + helpers.addResult(results, 0, + 'No sync groups found for SQL database', location, database.id); + } else { + var missingPrivateConfigGrps = syncGroups.data.filter((e) => !e.usePrivateLinkConnection).map((e) => e.name); + + if (missingPrivateConfigGrps.length) { + helpers.addResult(results, 2, `Following SQL Database sync groups are not configured to use private link: ${missingPrivateConfigGrps.join(', ')} `, location, database.id); + + } else { + helpers.addResult(results, 0, 'All SQL Database sync groups are configured to use private link', location, database.id); + } + } + } + }); + } + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.spec.js b/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.spec.js new file mode 100644 index 000000000..eaeae69d8 --- /dev/null +++ b/plugins/azure/sqldatabases/dbSyncGroupPrivateLink.spec.js @@ -0,0 +1,198 @@ +var expect = require('chai').expect; +var sqlDatabaseSyncGroups = require('./dbSyncGroupPrivateLink'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database" + } +]; + +const syncGroups = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/syncGroups/1", + "usePrivateLinkConnection": true + } +]; + +const createCache = (servers, databases, syncGroups, serversErr, databasesErr, syncGroupsErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const databaseId = (databases && databases.length) ? databases[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + syncGroups: { + list: { + 'eastus': { + [databaseId]: { + err: syncGroupsErr, + data: syncGroups + } + } + } + } + }; +}; + +describe('sqlDatabaseSyncGroups', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + syncGroups + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + syncGroups + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give passing result if SQL Database sync group uses private link', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('All SQL Database sync groups are configured to use private link'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + syncGroups + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give failing result if SQL Database sync group does not use private link', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Following SQL Database sync groups are not configured to use private link:'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/syncGroups/1", + "usePrivateLinkConnection": false + } + ] + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + databases, + syncGroups, + { message: 'unable to query servers' } + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + [], + syncGroups, + null, + { message: 'unable to query databases' } + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL Database sync groups', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL Database sync groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, + databases, + [], + null, + null, + { message: 'unable to query sync groups' } + ); + + sqlDatabaseSyncGroups.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/dbTDEEnabled.js b/plugins/azure/sqldatabases/dbTDEEnabled.js new file mode 100644 index 000000000..f8a929b4c --- /dev/null +++ b/plugins/azure/sqldatabases/dbTDEEnabled.js @@ -0,0 +1,127 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Transparent Data Encryption Enabled', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Transparent Data Encryption (TDE) is enabled for SQL databases.', + more_info: 'Transparent data encryption (TDE) helps protect Azure SQL Databases, Managed Instances, and Synapse Analytics against the threat of malicious offline activity by encrypting data at rest. It performs real-time encryption and decryption of the database, associated backups, and transaction log files at rest without requiring changes to the application.', + recommended_action: 'Modify SQL database and enable Transparent Data Encryption (TDE).', + link: 'https://docs.microsoft.com/en-us/sql/relational-databases/security/encryption/transparent-data-encryption?view=sql-server-ver15', + apis: ['servers:listSql', 'databases:listByServer', 'transparentDataEncryption:list', 'managedInstances:list', 'managedDatabases:listByInstance'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:transparentdataencryption:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + async.parallel([ + // Check SQL Server Databases + function(cb) { + const servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return cb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return cb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return cb(); + } + + servers.data.forEach(server => { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + var transparentDataEncryption = helpers.addSource(cache, source, + ['transparentDataEncryption', 'list', location, database.id]); + + if (!transparentDataEncryption || transparentDataEncryption.err || + !transparentDataEncryption.data || !transparentDataEncryption.data.length) { + helpers.addResult(results, 3, 'Unable to query transparent data encryption for SQL Database: ' + helpers.addError(transparentDataEncryption), location, database.id); + return; + } + var encryption = transparentDataEncryption.data[0]; + if (encryption.state && encryption.state.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, + 'SQL Database: Transparent data encryption is enabled', location, database.id); + } else { + helpers.addResult(results, 2, + 'SQL Database: Transparent data encryption is not enabled', location, database.id); + } + } + }); + } + } + + }); + + cb(); + }, + // Check Managed Instances + function(cb) { + const managedInstances = helpers.addSource(cache, source, + ['managedInstances', 'list', location]); + + if (!managedInstances) return cb(); + + if (managedInstances.err || !managedInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instances: ' + helpers.addError(managedInstances), location); + return cb(); + } + + if (!managedInstances.data.length) { + helpers.addResult(results, 0, 'No managed instances found', location); + return cb(); + } + + managedInstances.data.forEach(instance => { + const managedDatabases = helpers.addSource(cache, source, + ['managedDatabases', 'listByInstance', location, instance.id]); + + if (!managedDatabases || managedDatabases.err || !managedDatabases.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instance databases: ' + helpers.addError(managedDatabases), location, instance.id); + } else if (!managedDatabases.data.length) { + helpers.addResult(results, 0, + 'No databases found for managed instance', location, instance.id); + } else { + managedDatabases.data.forEach(database => { + if (database.name && database.name.toLowerCase() !== 'master') { + // Managed instances have TDE enabled by default and cannot be disabled + helpers.addResult(results, 0, + 'Managed Instance Database: Transparent data encryption is enabled', location, database.id); + } + }); + } + }); + + cb(); + } + ], function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/dbTDEEnabled.spec.js b/plugins/azure/sqldatabases/dbTDEEnabled.spec.js new file mode 100644 index 000000000..a21a6218e --- /dev/null +++ b/plugins/azure/sqldatabases/dbTDEEnabled.spec.js @@ -0,0 +1,293 @@ +const expect = require('chai').expect; +const enableTransparentDataEncryption = require('./dbTDEEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const managedInstances = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance", + "name": "test-instance" + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + } +]; + +const managedDatabases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance/databases/test-database", + "name": "test-database" + } +]; + +const transparentDataEncryptionEnabled = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/transparentDataEncryption/1", + "state": "Enabled" + } +]; + +const createCache = (servers, databases, transparentDataEncryption, managedInstances, managedDatabases, serversErr, databasesErr, transparentDataEncryptionErr, managedInstancesErr, managedDatabasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const databaseId = (databases && databases.length) ? databases[0].id : null; + const managedInstanceId = (managedInstances && managedInstances.length) ? managedInstances[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + }, + transparentDataEncryption: { + list: { + 'eastus': { + [databaseId]: { + err: transparentDataEncryptionErr, + data: transparentDataEncryption + } + } + } + }, + managedInstances: { + list: { + 'eastus': { + err: managedInstancesErr, + data: managedInstances + } + } + }, + managedDatabases: { + listByInstance: { + 'eastus': { + [managedInstanceId]: { + err: managedDatabasesErr, + data: managedDatabases + } + } + } + } + }; +}; + +describe('enableTransparentDataEncryption', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], databases, transparentDataEncryptionEnabled, + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, [], transparentDataEncryptionEnabled, + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if SQL Database transparent data encryption is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Database: Transparent data encryption is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, databases, transparentDataEncryptionEnabled, + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give failing result if SQL Database transparent data encryption is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Database: Transparent data encryption is not enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, databases, + [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/transparentDataEncryption/1", + "state": "Disabled" + } + ], + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if no managed instances found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No managed instances found'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for managed instance', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No databases found for managed instance'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + managedInstances, [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result for managed instance database (TDE always enabled)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance Database: Transparent data encryption is enabled'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + managedInstances, managedDatabases + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + [], [], + { message: 'unable to query servers' } + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers, [], [], + [], [], + null, { message: 'unable to query databases' } + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for managed instances', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instances'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + [], [], + null, null, null, + { message: 'unable to query managed instances' } + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for managed instance databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instance databases'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + managedInstances, [], + null, null, null, null, + { message: 'unable to query managed databases' } + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqldatabases/pitrBackupEnabled.js b/plugins/azure/sqldatabases/pitrBackupEnabled.js new file mode 100644 index 000000000..18215ebaf --- /dev/null +++ b/plugins/azure/sqldatabases/pitrBackupEnabled.js @@ -0,0 +1,108 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Point in Time Restore Backup Retention', + category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Microsoft Azure SQL databases have a sufficient Point in Time Restore (PITR) backup retention period configured', + more_info: 'Point-in-time restore is a self-service capability, which enables you to restore a database from backups to any point within the retention period. Point-in-time restore is useful in recovery scenarios, such as incidents caused by errors, incorrectly loaded data, or deletion of crucial data.', + recommended_action: 'Ensure that an optimal backup retention period is set for Azure SQL databases.', + link: 'https://azure.microsoft.com/en-us/blog/azure-sql-database-point-in-time-restore/', + apis: ['servers:listSql', 'databases:listByServer', 'backupShortTermRetentionPolicies:listByDatabase'], + settings: { + pitr_backup_retention_period: { + name: 'Point in Time Restore Backup Retention Period', + default: '7', + description: 'Desired number of days for which backups will be retained.', + regex: '^(3[0-5]|2[0-9]|1[0-9]|[1-9])$' + } + }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write','microsoftsql:servers:databases:backupshorttermretentionpolicies:write', 'microsoftsql:servers:databases:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + retentionDays: parseInt(settings.pitr_backup_retention_period || this.settings.pitr_backup_retention_period.default) + }; + + async.each(locations.servers, function(location, rcb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + } + + async.each(servers.data, function(server, scb) { + const databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + return scb(); + } + + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + return scb(); + } + + for (const database of databases.data) { + if (database.name && database.name.toLowerCase() !== 'master') { + const policies = helpers.addSource(cache, source, + ['backupShortTermRetentionPolicies', 'listByDatabase', location, database.id]); + + if (!policies || policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL database retention policies: ' + helpers.addError(policies), location, database.id); + continue; + } + + if (!policies.data.length) { + helpers.addResult(results, 0, + 'No retention policies found for SQL database', location, database.id); + continue; + } + + for (const policy of policies.data) { + let retentionDays = 0; + if (policy.retentionDays){ + retentionDays = policy.retentionDays; + } + + if (retentionDays >= config.retentionDays) { + helpers.addResult(results, 0, + `SQL Database is configured to retain backups for ${retentionDays} of ${config.retentionDays} days desired limit`, + location, database.id); + } else { + helpers.addResult(results, 2, + `SQL Database is configured to retain backups for ${retentionDays} of ${config.retentionDays} days desired limit`, + location, database.id); + } + } + } + } + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqldatabases/pitrBackupEnabled.spec.js b/plugins/azure/sqldatabases/pitrBackupEnabled.spec.js new file mode 100644 index 000000000..cfec557eb --- /dev/null +++ b/plugins/azure/sqldatabases/pitrBackupEnabled.spec.js @@ -0,0 +1,208 @@ +var expect = require('chai').expect; +var pitrBackupEnabled = require('./pitrBackupEnabled'); + +const servers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server' + } +]; + +const databases = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/databases/test-db', + 'name': 'test-db' + } +]; + +const policies = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/databases/test-db/backupShortTermRetentionPolicies/default', + 'name': 'default', + 'retentionDays': 2 + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/databases/test-db/backupShortTermRetentionPolicies/default', + 'name': 'default', + 'retentionDays': 14 + } +]; + + +const createCache = (servers, databases, policies) => { + let db = {}; + if (servers.length > 0) { + db[servers[0].id] = { + data: databases + }; + } + + let retentionPolicy = {}; + if (databases.length > 0) { + retentionPolicy[databases[0].id] = { + data: policies + }; + } + + return { + servers: { + listSql: { + 'eastus': { + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': db + } + }, + backupShortTermRetentionPolicies:{ + listByDatabase: { + 'eastus': retentionPolicy + } + } + }; +}; + +const createErrorCache = (key) => { + if (key === 'server') { + return { + servers: { + listSql: { + 'eastus': {} + } + } + }; + } else if (key === 'database') { + return { + servers: { + listSql: { + 'eastus': { + data: [servers[0]] + } + } + }, + databases: { + listByServer: { + 'eastus': {} + } + } + }; + } else { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + data: [servers[0]] + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + data: [databases[0]] + } + } + }, + shortTermRetentionPolicy:{ + 'eastus': {} + } + } + }; + } +}; + +describe('pitrBackupEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers', function(done) { + const cache = createCache([], [], []); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no SQL databases', function(done) { + const cache = createCache([servers[0]], [], []); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no retention policies', function(done) { + const cache = createCache([servers[0]], [databases[0]], []); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No retention policies found for SQL database'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const cache = createErrorCache('server'); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL databases', function(done) { + const cache = createErrorCache('database'); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for retention policies', function(done) { + const cache = createErrorCache('policy'); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL database retention policies:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if database has desired retention period set', function(done) { + const cache = createCache([servers[0]], [databases[0]], [policies[1]]); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Database is configured to retain backups for 14 of 7 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if database does not have desired retention period set', function(done) { + const cache = createCache([servers[0]], [databases[0]], [policies[0]]); + pitrBackupEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Database is configured to retain backups for 2 of 7 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqldatabases/sqlDbMultiAz.js b/plugins/azure/sqldatabases/sqlDbMultiAz.js index 50cbf8de8..27dba4c76 100644 --- a/plugins/azure/sqldatabases/sqlDbMultiAz.js +++ b/plugins/azure/sqldatabases/sqlDbMultiAz.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'SQL DB Multiple AZ', category: 'SQL Databases', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL Database instances are created to be cross-AZ for high availability', more_info: 'Creating SQL Database instances in a single availability zone creates a single point of failure for all systems relying on that database. All SQL Database instances should be created in multiple availability zones to ensure proper failover.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-high-availability#zone-redundant-configuration', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-high-availability#zone-redundant-configuration', recommended_action: 'Ensure that each SQL Database is configured to be zone redundant.', apis: ['servers:listSql', 'databases:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:delete'], run: function(cache, settings, callback) { const results = []; @@ -47,12 +50,14 @@ module.exports = { } else { // Loop through databases databases.data.forEach(function(database) { - if (database.zoneRedundant) { - helpers.addResult(results, 0, - 'SQL Database has zone redundancy enabled', location, database.id); - } else { - helpers.addResult(results, 2, - 'SQL Database does not have zone redundancy enabled', location, database.id); + if (database.name && database.name.toLowerCase() !== 'master') { + if (database.zoneRedundant) { + helpers.addResult(results, 0, + 'SQL Database has zone redundancy enabled', location, database.id); + } else { + helpers.addResult(results, 2, + 'SQL Database does not have zone redundancy enabled', location, database.id); + } } }); } @@ -64,4 +69,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/azure/sqldatabases/sqlDbMultiAz.spec.js b/plugins/azure/sqldatabases/sqlDbMultiAz.spec.js new file mode 100644 index 000000000..b2cd085c5 --- /dev/null +++ b/plugins/azure/sqldatabases/sqlDbMultiAz.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var sqlDbMultiAz = require('./sqlDbMultiAz'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const databases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "zoneRedundant": true, + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", + "name": "test-database", + "zoneRedundant": false, + } +]; + + +const createCache = (servers, databases, serversErr, databasesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + databases: { + listByServer: { + 'eastus': { + [serverId]: { + err: databasesErr, + data: databases + } + } + } + } + } +}; + +describe('sqlDbMultiAz', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for SQL server', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No databases found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + + it('should give failing result if SQL Database does not have zone redundancy enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Database does not have zone redundancy enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [databases[1]], + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + + it('should give passing result if SQL Database has zone redundancy enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Database has zone redundancy enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [databases[0]] + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [], + [], + { message: 'unable to query servers'} + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL server databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server databases'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'unable to query databases'} + ); + + sqlDbMultiAz.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/azure/sqlserver/advancedDataSecurityEnabled.js b/plugins/azure/sqlserver/advancedDataSecurityEnabled.js index c182c15d8..fa81e6aa4 100644 --- a/plugins/azure/sqlserver/advancedDataSecurityEnabled.js +++ b/plugins/azure/sqlserver/advancedDataSecurityEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Advanced Data Security Enabled', category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that Advanced Data Security is enabled for SQL Servers', more_info: 'Enabling Advanced Data Security on all SQL Servers ensures that SQL server data is encrypted and monitored for unusual activity, vulnerabilities, and threats.', recommended_action: 'Ensure that Advanced Data Security is enabled for all SQL Servers.', - link: 'https://docs.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', + link: 'https://learn.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', apis: ['servers:listSql', 'serverSecurityAlertPolicies:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsecurity:pricings:write', 'microsoftsql:servers:advancedthreatprotectionsettings:write', 'microsoftsql:servers:securityalertpolicies:write'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/sqlserver/advancedDataSecurityEnabled.spec.js b/plugins/azure/sqlserver/advancedDataSecurityEnabled.spec.js new file mode 100644 index 000000000..640ce399d --- /dev/null +++ b/plugins/azure/sqlserver/advancedDataSecurityEnabled.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var advancedDataSecurityEnabled = require('./advancedDataSecurityEnabled'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const serverSecurityAlertPolicies = [ + { + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/securityAlertPolicies/Default', + "name": 'Default', + "type": 'Microsoft.Sql/servers/securityAlertPolicies', + "state": 'Enabled' + }, + { + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/securityAlertPolicies/Default', + "name": 'Default', + "type": 'Microsoft.Sql/servers/securityAlertPolicies', + "state": 'Disabled' + } +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + serverSecurityAlertPolicies: { + listByServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('advancedDataSecurityEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + + it('should give failing result if no Database Threat Detection policies found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Database Threat Detection policies found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + + it('should give failing result if Advanced Data Security for the SQL server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Advanced Data Security for the SQL server is disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [serverSecurityAlertPolicies[1]] + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + + it('should give passing result if Advanced Data Security for the SQL server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Advanced Data Security for the SQL server is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [serverSecurityAlertPolicies[0]] + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Database Threat Detection Policies', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Database Threat Detection Policies'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Database Threat Detection Policies'} + ); + + advancedDataSecurityEnabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/sqlserver/auditActionGroupsEnabled.js b/plugins/azure/sqlserver/auditActionGroupsEnabled.js index 8a853cfb6..b054bbd73 100644 --- a/plugins/azure/sqlserver/auditActionGroupsEnabled.js +++ b/plugins/azure/sqlserver/auditActionGroupsEnabled.js @@ -4,16 +4,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Audit Action Groups Enabled', category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL Server Audit Action and Groups is configured properly', more_info: 'SQL Server Audit Action and Groups should be configured to at least include SUCCESSFUL_DATABASE_AUTHENTICATION_GROUP, FAILED_DATABASE_AUTHENTICATION_GROUP and BATCH_COMPLETED_GROUP.', recommended_action: 'If SQL Server Audit Action and Groups is not configured properly when enabling Auditing, these settings must be configured in Powershell.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-auditing', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-auditing', apis: ['servers:listSql', 'serverBlobAuditingPolicies:get'], compliance: { hipaa: 'HIPAA requires that a secure audit record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:auditingsettings:write'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/sqlserver/auditOperationsEnabled.js b/plugins/azure/sqlserver/auditOperationsEnabled.js new file mode 100644 index 000000000..b35b4f50b --- /dev/null +++ b/plugins/azure/sqlserver/auditOperationsEnabled.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Microsoft Support Operations Auditing Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure auditing of Microsoft support operations is enabled on SQL server.', + more_info: 'Auditing Microsoft support operations for your Azure SQL Database server enhances transparency during support requests. This feature, combined with your existing auditing, facilitates anomaly detection, trend visualization, and data loss prevention.', + recommended_action: 'Enable the option to capture Microsoft support operations and write them to a selected Storage account, Log Analytics workspace, or Event Hub.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/auditing-microsoft-support-operations?view=azuresql', + apis: ['servers:listSql', 'devOpsAuditingSettings:list'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:devopsauditingsettings:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server => { + const devOpsAuditingSettings = helpers.addSource(cache, source, + ['devOpsAuditingSettings', 'list', location, server.id]); + + if (!devOpsAuditingSettings || devOpsAuditingSettings.err || !devOpsAuditingSettings.data || !devOpsAuditingSettings.data.length) { + helpers.addResult(results, 3, + 'Unable to query Auditing Policies: ' + helpers.addError(devOpsAuditingSettings), location, server.id); + } else { + if (devOpsAuditingSettings.data.length) { + if ( devOpsAuditingSettings.data[0].state && devOpsAuditingSettings.data[0].state.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Microsoft support operations auditing is enabled on SQL server', location, server.id); + } else { + helpers.addResult(results, 2, 'Microsoft support operations auditing is not enabled on SQL server', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/auditOperationsEnabled.spec.js b/plugins/azure/sqlserver/auditOperationsEnabled.spec.js new file mode 100644 index 000000000..1a7b61a15 --- /dev/null +++ b/plugins/azure/sqlserver/auditOperationsEnabled.spec.js @@ -0,0 +1,120 @@ +// Import necessary modules and the code to be tested +var assert = require('assert'); +var expect = require('chai').expect; +var auditSupportOperations = require('./auditOperationsEnabled'); + +// Function to create a sample cache +const createCache = (err, list, get) => { + return { + servers: { + listSql: { + 'eastus': { + err: err, + data: list + } + } + }, + devOpsAuditingSettings: { + list: { + 'eastus': get + } + } + } +}; + +// Test suite +describe('Auditing of Microsoft Support Operations', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [], + {} + ); + + auditSupportOperations.run(cache, {}, callback); + }); + + it('should give passing result if auditing of support operations is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Microsoft support operations auditing is enabled on SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server", + "name": "sql-server", + "type": "Microsoft.Sql/servers" + } + ], + { + '/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server': { + data: [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "state": "Enabled", + "error": false, + "location": "eastus" + } + ] + } + } + ); + + auditSupportOperations.run(cache, {}, callback); + }); + + it('should give failing result if auditing of support operations is not enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Microsoft support operations auditing is not enabled on SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server", + "name": "sql-server", + "type": "Microsoft.Sql/servers" + } + ], + { + '/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server': { + data: [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "state": "Disabled", + "error": false, + "location": "eastus" + } + ] + } + } + ); + + auditSupportOperations.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqlserver/auditRetentionPolicy.js b/plugins/azure/sqlserver/auditRetentionPolicy.js index 2fb549f72..9e62756cd 100644 --- a/plugins/azure/sqlserver/auditRetentionPolicy.js +++ b/plugins/azure/sqlserver/auditRetentionPolicy.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Audit Retention Policy', category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL Server Auditing retention policy is set to greater than 90 days', more_info: 'Enabling SQL Server Auditing ensures that all activities are being logged properly, including potentially-malicious activity. Having a long retention policy ensures that all logs are kept for auditing and legal purposes.', recommended_action: 'Ensure that the storage account retention policy for each SQL server is set to greater than 90 days.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-auditing', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-auditing', apis: ['servers:listSql', 'serverBlobAuditingPolicies:get'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:auditingsettings:write'], run: function(cache, settings, callback) { var results = []; @@ -47,7 +50,7 @@ module.exports = { } else { serverBlobAuditingPolicies.data.forEach(serverBlobAuditingPolicy => { if (serverBlobAuditingPolicy.retentionDays && - serverBlobAuditingPolicy.retentionDays > 90) { + serverBlobAuditingPolicy.retentionDays >= 90) { helpers.addResult(results, 0, 'Server Auditing retention is greater than 90 days', location, server.id); } else if (serverBlobAuditingPolicy.retentionDays && serverBlobAuditingPolicy.retentionDays < 90) { diff --git a/plugins/azure/sqlserver/auditStorageAuthType.js b/plugins/azure/sqlserver/auditStorageAuthType.js new file mode 100644 index 000000000..dd2b2f5b6 --- /dev/null +++ b/plugins/azure/sqlserver/auditStorageAuthType.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Auditing Storage Authentication Type', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that managed identity is configured as authentication type for SQL Server storage account audit logs.', + more_info: 'Enabling managed identity as authentication type enhances security when using a storage account as the destination for audit logs. Managed Identity can be a system-assigned managed identity or user-assigned managed identity.', + recommended_action: 'Ensure managed identity is configured as authentication type when choosing a storage account as the destination for audit logs on SQL server.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/auditing-managed-identity?view=azuresql&tabs=azure-portal', + apis: ['servers:listSql', 'serverBlobAuditingPolicies:get'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:auditingsettings:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server => { + const serverBlobAuditingPolicies = helpers.addSource(cache, source, + ['serverBlobAuditingPolicies', 'get', location, server.id]); + + if (!serverBlobAuditingPolicies || serverBlobAuditingPolicies.err || !serverBlobAuditingPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query Auditing Policies: ' + helpers.addError(serverBlobAuditingPolicies), location, server.id); + } else { + if (!serverBlobAuditingPolicies.data.length) { + helpers.addResult(results, 0, 'No Server Auditing policies found', location, server.id); + } else { + let serverAuditingEnabled = serverBlobAuditingPolicies.data.length && serverBlobAuditingPolicies.data.find(auditPolicy => auditPolicy.state && auditPolicy.state.toLowerCase() == 'enabled'); + if (serverAuditingEnabled) { + if (serverAuditingEnabled.storageAccountSubscriptionId !== '00000000-0000-0000-0000-000000000000') { + if (serverAuditingEnabled.isManagedIdentityInUse) { + helpers.addResult(results, 0, 'SQL Server is using managed identity authentication for storage account audit logs', location, server.id); + } else { + helpers.addResult(results, 2, 'SQL Server is not using managed identity authentication for storage account audit logs', location, server.id); + } + } else { + helpers.addResult(results, 0, 'SQL Server is not using a storage account as destination for audit logs', location, server.id); + } + } else { + helpers.addResult(results, 0, 'Auditing is not enabled for SQL server', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/auditStorageAuthType.spec.js b/plugins/azure/sqlserver/auditStorageAuthType.spec.js new file mode 100644 index 000000000..f32f1a580 --- /dev/null +++ b/plugins/azure/sqlserver/auditStorageAuthType.spec.js @@ -0,0 +1,181 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var auditLogsAuthentication = require('./auditStorageAuthType'); + +const createCache = (err, list, get) => { + return { + servers: { + listSql: { + 'eastus': { + err: err, + data: list + } + } + }, + serverBlobAuditingPolicies: { + get: { + 'eastus': get + } + } + } +}; + +describe('Storage Authentication Type for Audit Logs', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [], + {} + ); + + auditLogsAuthentication.run(cache, {}, callback); + }); + + it('should give passing result if Azure SQL Auditing is not using account storage', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server is not using a storage account as destination for audit logs'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server", + "name": "sql-server", + "type": "Microsoft.Sql/servers" + } + ], + { + '/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server': { + data: [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "state": "Enabled", + "storageEndpoint": "", + "retentionDays": 0, + "auditActionsAndGroups": [], + "storageAccountSubscriptionId": "00000000-0000-0000-0000-000000000000", + "isManagedIdentityInUse": false, + "isAzureMonitorTargetEnabled": true, + "error": false, + "location": "eastus", + "storageAccount": { + "name": "sqlserverstorage" + } + } + ] + } + } + ); + + auditLogsAuthentication.run(cache, {}, callback); + }); + + it('should give passing result if managed identity is configured for audit logs storage', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server is using managed identity authentication for storage account audit logs'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server", + "name": "sql-server", + "type": "Microsoft.Sql/servers" + } + ], + { + '/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server': { + data: [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "state": "Enabled", + "storageEndpoint": "", + "retentionDays": 0, + "auditActionsAndGroups": [], + "storageAccountSubscriptionId": "storage-account-subscription-id", + "isManagedIdentityInUse": true, + "isAzureMonitorTargetEnabled": true, + "error": false, + "location": "eastus", + "storageAccount": { + "name": "sqlserverstorage" + } + } + ] + } + } + ); + + auditLogsAuthentication.run(cache, {}, callback); + }); + + it('should give failing result if managed identity is not configured for audit logs storage', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server is not using managed identity authentication for storage account audit logs'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server", + "name": "sql-server", + "type": "Microsoft.Sql/servers" + } + ], + { + '/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server': { + data: [ + { + "id": "/subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Sql/servers/sql-server/auditingSettings/Default", + "name": "Default", + "type": "Microsoft.Sql/servers/auditingSettings", + "state": "Enabled", + "storageEndpoint": "", + "retentionDays": 0, + "auditActionsAndGroups": [], + "storageAccountSubscriptionId": "storage-account-subscription-id", + "isManagedIdentityInUse": false, + "isAzureMonitorTargetEnabled": true, + "error": false, + "location": "eastus", + "storageAccount": { + "name": "sqlserverstorage" + } + } + ] + } + } + ); + + auditLogsAuthentication.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqlserver/autoFailoverGroupsEnabled.js b/plugins/azure/sqlserver/autoFailoverGroupsEnabled.js new file mode 100644 index 000000000..4985a8c0b --- /dev/null +++ b/plugins/azure/sqlserver/autoFailoverGroupsEnabled.js @@ -0,0 +1,62 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Auto-Failover Groups Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that auto-failover groups are configured for Azure SQL database servers.', + more_info: 'In case of any outage that impacts one or more SQL databases, automatic failover process switches all secondary databases in the group to primary databases to ensure high availability.', + recommended_action: 'Ensure that auto-failover Groups are configured for Azure SQL database servers', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/auto-failover-group-overview', + apis: ['servers:listSql', 'failoverGroups:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:failovergroups:write', 'microsoftsql:servers:failovergroups:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + for (const server of servers.data) { + const failoverGroups = helpers.addSource(cache, source, + ['failoverGroups', 'listByServer', location, server.id]); + + if (!failoverGroups || failoverGroups.err || !failoverGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for auto-failover groups: ' + helpers.addError(failoverGroups), location, server.id); + continue; + } + + if (failoverGroups.data.length) { + helpers.addResult(results, 0, 'Auto-failover groups are configured for the SQL server', location, server.id); + } else { + helpers.addResult(results, 2, 'Auto-failover groups are not configured for the SQL server', location, server.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/autoFailoverGroupsEnabled.spec.js b/plugins/azure/sqlserver/autoFailoverGroupsEnabled.spec.js new file mode 100644 index 000000000..94123d227 --- /dev/null +++ b/plugins/azure/sqlserver/autoFailoverGroupsEnabled.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var autoFailoverGroupsEnabled = require('./autoFailoverGroupsEnabled'); + +const servers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'location': 'eastus' + } +]; + +const failoverGroups = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/failoverGroups/test-group', + 'name': 'test-group', + 'type': 'Microsoft.Sql/servers/failoverGroups' + } +]; + + +const createCache = (servers, failoverGroups) => { + let server = {}; + let groups = {}; + if (servers) { + server['data'] = servers; + if (servers.length > 0 && failoverGroups) { + groups[servers[0].id] = { + data: failoverGroups + }; + } + } + return { + servers: { + listSql: { + 'eastus': server + } + }, + failoverGroups: { + listByServer: { + 'eastus': groups + } + } + }; +}; + +describe('autoFailoverGroupsEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers', function(done) { + const cache = createCache([], null); + autoFailoverGroupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const cache = createCache(null); + autoFailoverGroupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for failover groups', function(done) { + const cache = createCache([servers[0]], null); + autoFailoverGroupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for auto-failover groups:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if auto-failover groups are configured', function(done) { + const cache = createCache([servers[0]], [failoverGroups[0]]); + autoFailoverGroupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Auto-failover groups are configured for the SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no auto-failover groups found', function(done) { + const cache = createCache([servers[0]], []); + autoFailoverGroupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Auto-failover groups are not configured for the SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/automaticTuningEnabled.js b/plugins/azure/sqlserver/automaticTuningEnabled.js new file mode 100644 index 000000000..8b2c52ca4 --- /dev/null +++ b/plugins/azure/sqlserver/automaticTuningEnabled.js @@ -0,0 +1,87 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Automatic Tuning Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Microsoft Azure SQL servers have automatic tuning configured.', + more_info: 'Automatic tuning is a fully managed intelligent performance service that uses built-in intelligence to continuously monitor queries executed on a database, and it automatically improves their performance.', + recommended_action: 'Modify SQL server to enable automatic tuning', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/automatic-tuning-overview', + apis: ['servers:listSql', 'serverAutomaticTuning:get'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:automatictuning:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + async.each(servers.data, function(server, scb) { + const configs = helpers.addSource(cache, source, + ['serverAutomaticTuning', 'get', location, server.id]); + + if (!configs || configs.err || !configs.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server automatic tuning configurations: ' + helpers.addError(servers), location); + return scb(); + } + + const config = configs.data.options; + if (!config) { + helpers.addResult(results, 2, + 'No automatic tuning configurations found for SQL server', location, server.id); + return scb(); + } + + let configuredFlagsCount = 0; + + if (config.createIndex && config.createIndex.reasonDesc && + config.createIndex.reasonDesc.toLowerCase() === 'autoconfigured') { + configuredFlagsCount++; + } + + if (config.dropIndex && config.dropIndex.reasonDesc && + config.dropIndex.reasonDesc.toLowerCase() === 'autoconfigured') { + configuredFlagsCount++; + } + + if (config.forceLastGoodPlan && config.forceLastGoodPlan.reasonDesc && + config.forceLastGoodPlan.reasonDesc.toLowerCase() === 'autoconfigured') { + configuredFlagsCount++; + } + + if (configuredFlagsCount === 3) { + helpers.addResult(results, 0, + 'SQL server has Azure automatic tuning enabled', location, server.id); + } else { + helpers.addResult(results, 2, + 'SQL server does not have Azure automatic tuning enabled', location, server.id); + } + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/automaticTuningEnabled.spec.js b/plugins/azure/sqlserver/automaticTuningEnabled.spec.js new file mode 100644 index 000000000..f812c3f51 --- /dev/null +++ b/plugins/azure/sqlserver/automaticTuningEnabled.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var automaticTuningEnabled = require('./automaticTuningEnabled'); + +const servers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'location': 'eastus' + } +]; + +const automaticTuningSetting = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/automaticTuning/current', + 'name': 'current', + 'type': 'Microsoft.Sql/servers/automaticTuning', + 'desiredState': 'Auto', + 'actualState': 'Auto', + 'options': { + 'createIndex': { + 'desiredState': 'Default', + 'actualState': 'Off', + 'reasonCode': 2, + 'reasonDesc': 'AutoConfigured' + }, + 'dropIndex': { + 'desiredState': 'Default', + 'actualState': 'Off', + 'reasonCode': 2, + 'reasonDesc': 'AutoConfigured' + }, + 'forceLastGoodPlan': { + 'desiredState': 'Default', + 'actualState': 'On', + 'reasonCode': 2, + 'reasonDesc': 'AutoConfigured' + }, + 'maintainIndex': { + 'desiredState': 'Off', + 'actualState': 'Off' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/automaticTuning/current', + 'name': 'current', + 'type': 'Microsoft.Sql/servers/automaticTuning', + 'desiredState': 'Auto', + 'actualState': 'Auto', + 'options': { + 'createIndex': { + 'desiredState': 'On', + 'actualState': 'On' + }, + 'dropIndex': { + 'desiredState': 'On', + 'actualState': 'On' + }, + 'forceLastGoodPlan': { + 'desiredState': 'On', + 'actualState': 'On' + }, + 'maintainIndex': { + 'desiredState': 'Off', + 'actualState': 'Off' + } + } + } +]; + + +const createCache = (servers, tuningConfigs) => { + let server = {}; + let configs = {}; + if (servers) { + server['data'] = servers; + if (servers.length > 0 && tuningConfigs) { + configs[servers[0].id] = { + data: tuningConfigs + }; + } + } + return { + servers: { + listSql: { + 'eastus': server + } + }, + serverAutomaticTuning: { + get: { + 'eastus': configs + } + } + }; +}; + +describe('automaticTuningEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers', function(done) { + const cache = createCache([]); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const cache = createCache(null); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no automatic tuning configurations found for SQL server', function(done) { + const cache = createCache([servers[0]], {}); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No automatic tuning configurations found for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL server automatic tuning configurations', function(done) { + const cache = createCache([servers[0]], null); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL server automatic tuning configurations'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if SQL server has Azure automatic tuning enabled', function(done) { + const cache = createCache([servers[0]], automaticTuningSetting[0]); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL server has Azure automatic tuning enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if SQL server does not have Azure automatic tuning enabled', function(done) { + const cache = createCache([servers[0]], automaticTuningSetting[1]); + automaticTuningEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL server does not have Azure automatic tuning enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/azureADAdminEnabled.js b/plugins/azure/sqlserver/azureADAdminEnabled.js index 3dc399754..9425656ab 100644 --- a/plugins/azure/sqlserver/azureADAdminEnabled.js +++ b/plugins/azure/sqlserver/azureADAdminEnabled.js @@ -2,13 +2,16 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Azure Active Directory Admin Enabled', + title: 'Azure Entra ID Admin Enabled', category: 'SQL Server', - description: 'Ensures that Active Directory admin is enabled on all SQL servers.', - more_info: 'Enabling Active Directory admin allows users to manage account admins in a central location, allowing key rotation and permission management to be managed in one location for all servers and databases.', - recommended_action: 'Ensure Azure Active Directory admin is enabled on all SQL servers.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-aad-authentication-configure', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Entra ID admin is enabled on all SQL servers.', + more_info: 'Enabling Entra ID admin allows users to manage account admins in a central location, allowing key rotation and permission management to be managed in one location for all servers and databases.', + recommended_action: 'Ensure Azure Entra ID admin is enabled on all SQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-aad-authentication-configure', apis: ['servers:listSql', 'serverAzureADAdministrators:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:administrators:write', 'microsoftsql:servers:administrators:delete'], run: function(cache, settings, callback) { const results = []; @@ -39,25 +42,25 @@ module.exports = { if (!serverAzureADAdministrators || serverAzureADAdministrators.err || !serverAzureADAdministrators.data) { helpers.addResult(results, 3, - 'Unable to query for Active Directory admins: ' + helpers.addError(serverAzureADAdministrators), location, server.id); + 'Unable to query for Entra ID admins: ' + helpers.addError(serverAzureADAdministrators), location, server.id); } else { if (!serverAzureADAdministrators.data.length) { - helpers.addResult(results, 2, 'Active Directory admin is not enabled on the server', location, server.id); + helpers.addResult(results, 2, 'Entra ID admin is not enabled on the server', location, server.id); } else { - var adAdminEnabled = false; + var entraIdAdminEnabled = false; serverAzureADAdministrators.data.forEach(serverAzureADAdministrator => { if (serverAzureADAdministrator.name && serverAzureADAdministrator.name.toLowerCase() === 'activedirectory') { - adAdminEnabled = true; + entraIdAdminEnabled = true; } }); - if (adAdminEnabled) { + if (entraIdAdminEnabled) { helpers.addResult(results, 0, - 'Active Directory admin is enabled on the SQL server', location, server.id); + 'Entra ID admin is enabled on the SQL server', location, server.id); } else { helpers.addResult(results, 2, - 'Active Directory admin is not enabled on the SQL server', location, server.id); + 'Entra ID admin is not enabled on the SQL server', location, server.id); } } } diff --git a/plugins/azure/sqlserver/azureADAdminEnabled.spec.js b/plugins/azure/sqlserver/azureADAdminEnabled.spec.js index 077b79d2e..026d4610b 100644 --- a/plugins/azure/sqlserver/azureADAdminEnabled.spec.js +++ b/plugins/azure/sqlserver/azureADAdminEnabled.spec.js @@ -44,7 +44,7 @@ describe('azureADAdminEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Active Directory admin is not enabled on the server'); + expect(results[0].message).to.include('Entra ID admin is not enabled on the server'); expect(results[0].region).to.equal('eastus'); done() }; @@ -73,7 +73,7 @@ describe('azureADAdminEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Active Directory admin is enabled on the SQL server'); + expect(results[0].message).to.include('Entra ID admin is enabled on the SQL server'); expect(results[0].region).to.equal('eastus'); done() }; @@ -96,7 +96,7 @@ describe('azureADAdminEnabled', function() { "name": "ActiveDirectory", "type": "Microsoft.Sql/servers/administrators", "administratorType": "ActiveDirectory", - "login": "giovanni@cloudsploit.com", + "login": "giovanni@cloudexploit.com", "sid": "3fc56a96-2173-49c5-b915-08886e7fafa3", "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", "location": "eastus", diff --git a/plugins/azure/sqlserver/emailAccountAdminsEnabled.js b/plugins/azure/sqlserver/emailAccountAdminsEnabled.js index d1df8d079..bf9de49cd 100644 --- a/plugins/azure/sqlserver/emailAccountAdminsEnabled.js +++ b/plugins/azure/sqlserver/emailAccountAdminsEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Email Account Admins Enabled', category: 'SQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures that email account admins is enabled in advanced data security for SQL servers.', more_info: 'Enabling email account admins in advanced data security on all SQL servers ensures that monitored data for unusual activity, vulnerabilities, and threats get sent to the account admins and subscription owners.', recommended_action: 'Ensure that also send email notification to admins and subscription owners is enabled in advanced threat protections for all SQL servers.', - link: 'https://docs.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', + link: 'https://learn.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', apis: ['servers:listSql', 'serverSecurityAlertPolicies:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:securityalertpolicies:write', 'microsoftsql:servers:advancedthreatprotectionsettings:write'], run: function(cache, settings, callback) { const results = []; @@ -44,17 +47,17 @@ module.exports = { if (!serverSecurityAlertPolicies.data.length) { helpers.addResult(results, 2, 'Database Threat Detection Policies are not enabled on the server', location, server.id); } else { - serverSecurityAlertPolicies.data.forEach(serverSecurityAlertPolicy => { - if (serverSecurityAlertPolicy.state && - serverSecurityAlertPolicy.state.toLowerCase() == 'enabled' && - serverSecurityAlertPolicy.emailAccountAdmins) { - helpers.addResult(results, 0, - 'Email Account Admins is enabled on the SQL server', location, server.id); - } else { - helpers.addResult(results, 2, - 'Email Account Admins is not enabled on the SQL server', location, server.id); - } - }); + let emailAccountsAdmins = serverSecurityAlertPolicies.data.find(serverSecurityAlertPolicy => + serverSecurityAlertPolicy.state && + serverSecurityAlertPolicy.state.toLowerCase() == 'enabled' && + serverSecurityAlertPolicy.emailAccountAdmins); + if (emailAccountsAdmins) { + helpers.addResult(results, 0, + 'Email Account Admins is enabled on the SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Email Account Admins is not enabled on the SQL server', location, server.id); + } } } }); diff --git a/plugins/azure/sqlserver/enableATP.js b/plugins/azure/sqlserver/enableATP.js new file mode 100644 index 000000000..9069ad0b5 --- /dev/null +++ b/plugins/azure/sqlserver/enableATP.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Advanced Threat Protection Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Advanced Threat Protection is enabled on SQL Servers.', + more_info: 'Azure Defender for SQL is a unified package for advanced SQL security capabilities.', + recommended_action: 'Ensure that ThreatDetectionState is set to Enabled', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql', + apis: ['servers:listSql', 'advancedThreatProtectionSettings:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsecurity:pricings:write', 'microsoftsql:servers:advancedthreatprotectionsettings:write', 'microsoftsql:servers:securityalertpolicies:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + const advancedThreatProtectionSettings = helpers.addSource(cache, source, + ['advancedThreatProtectionSettings', 'listByServer', location, server.id]); + + if (!advancedThreatProtectionSettings || advancedThreatProtectionSettings.err || !advancedThreatProtectionSettings.data) { + helpers.addResult(results, 3, + 'Unable to query for Advanced Threat Protection settings: ' + helpers.addError(advancedThreatProtectionSettings), location, server.id); + } else { + if (!advancedThreatProtectionSettings.data.length) { + helpers.addResult(results, 2, 'No Advanced Threat Protection setting found', location, server.id); + } else { + let atpEnabled = advancedThreatProtectionSettings.data.find(threadProtectionSetting => + threadProtectionSetting.state && + threadProtectionSetting.state.toLowerCase() == 'enabled'); + if (atpEnabled) { + helpers.addResult(results, 0, + 'Advanced Threat Protection for the SQL server is enabled', location, server.id); + } else { + helpers.addResult(results, 2, + 'Advanced Threat Protection for the SQL server is disabled', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/enableATP.spec.js b/plugins/azure/sqlserver/enableATP.spec.js new file mode 100644 index 000000000..ba55cefdd --- /dev/null +++ b/plugins/azure/sqlserver/enableATP.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var enableATP = require('./enableATP'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const advancedThreatProtectionSettings = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/advancedThreatProtectionSettings/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/advancedThreatProtectionSettings', + state: 'Enabled', + creationTime: '2022-05-12T10:08:23.127Z' + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/advancedThreatProtectionSettings/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/advancedThreatProtectionSettings', + state: 'Disabled', + creationTime: '2022-05-12T10:08:23.127Z' + }, +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + advancedThreatProtectionSettings: { + listByServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('enableATP', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give failing result if no Database Advanced Threat Protection settings found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Advanced Threat Protection setting found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give failing result if Advanced Threat Protection for the SQL server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Advanced Threat Protection for the SQL server is disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [advancedThreatProtectionSettings[1]] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give passing result if Advanced Threat Protection for the SQL server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Advanced Threat Protection for the SQL server is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [advancedThreatProtectionSettings[0]] + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + enableATP.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Database Advanced Threat Protection settings', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Database Advanced Threat Protection settings'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Database Advanced Threat Protection settings'} + ); + + enableATP.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/azure/sqlserver/noPublicAccess.js b/plugins/azure/sqlserver/noPublicAccess.js index 7ea24f44b..63b253096 100644 --- a/plugins/azure/sqlserver/noPublicAccess.js +++ b/plugins/azure/sqlserver/noPublicAccess.js @@ -4,17 +4,33 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'SQL Server Public Access', category: 'SQL Server', + domain: 'Databases', + severity: 'High', description: 'Ensures that SQL Servers do not allow public access', more_info: 'Unless there is a specific business requirement, SQL Server instances should not have a public endpoint and should only be accessed from within a VNET.', recommended_action: 'Ensure that the firewall of each SQL Server is configured to prohibit traffic from the public 0.0.0.0 global IP address.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-security-overview/', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-security-overview/', apis: ['servers:listSql','firewallRules:listByServer'], + settings: { + server_firewall_end_ip: { + name: 'SQL Server Firewall Rule End IP', + description: 'Comma separated list of IP addresses which cannot be end IPs for firewall rule', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:firewallrules:write'], run: function(cache, settings, callback) { var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); + var config = { + server_firewall_end_ip: settings.server_firewall_end_ip || this.settings.server_firewall_end_ip.default + }; + var checkEndIp = (config.server_firewall_end_ip.length > 0); + async.each(locations.servers, function(location, rcb) { var servers = helpers.addSource(cache, source, @@ -34,36 +50,46 @@ module.exports = { } servers.data.forEach(function(server) { - const firewallRules = helpers.addSource(cache, source, - ['firewallRules', 'listByServer', location, server.id]); + + if (server.publicNetworkAccess && server.publicNetworkAccess === 'Disabled') { + helpers.addResult(results, 0, 'The SQL server has public network access disabled', location, server.id); - if (!firewallRules || firewallRules.err || !firewallRules.data) { - helpers.addResult(results, 3, - 'Unable to query for Database Threat Detection Policies: ' + helpers.addError(firewallRules), location, server.id); } else { - if (!firewallRules.data.length) { - helpers.addResult(results, 0, 'No existing SQL Server Firewall Rules found', location, server.id); + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByServer', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query SQL Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); } else { - var publicAccess = false; - - firewallRules.data.forEach(firewallRule => { - const startIpAddr = firewallRule['startIpAddress']; - const endIpAddr = firewallRule['endIpAddress']; - - if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { - publicAccess = true; - } else if (endIpAddr && endIpAddr.toString().indexOf('0.0.0.0') > -1) { - publicAccess = true; - } - }); - - if (publicAccess) { - helpers.addResult(results, 2, 'The SQL Server is open to outside traffic', location, server.id); + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing SQL Server Firewall Rules found', location, server.id); } else { - helpers.addResult(results, 0, 'The SQL server is protected from outside traffic', location, server.id); + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + + if (checkEndIp) { + const endIpAddr = firewallRule['endIpAddress']; + if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1 && + endIpAddr && config.server_firewall_end_ip.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { + publicAccess = true; + } + }); + + if (publicAccess) { + helpers.addResult(results, 2, 'The SQL Server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The SQL server is protected from outside traffic', location, server.id); + } } } - } + + } }); rcb(); diff --git a/plugins/azure/sqlserver/noPublicAccess.spec.js b/plugins/azure/sqlserver/noPublicAccess.spec.js new file mode 100644 index 000000000..869d3a722 --- /dev/null +++ b/plugins/azure/sqlserver/noPublicAccess.spec.js @@ -0,0 +1,219 @@ +var expect = require('chai').expect; +var noPublicAccess = require('./noPublicAccess'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + "publicNetworkAccess" : "Disabled" + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": { 'key': 'value' }, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "minimalTlsVersion": "1.1", + "publicNetworkAccess": "Enabled" + }, +]; + +const firewallRules = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllWindowsAzureIps", + "type": "Microsoft.Sql/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "startIpAddress": "72.255.51.41", + "endIpAddress": "72.255.51.41" + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllWindowsAzureIps", + "type": "Microsoft.Sql/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllWindowsAzureIps", + "type": "Microsoft.Sql/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + } +]; + +const createCache = (servers, rules, serversErr, rulesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + firewallRules: { + listByServer: { + 'eastus': { + [serverId]: { + err: rulesErr, + data: rules + } + } + } + } + } +}; + +describe('noPublicAccess', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + noPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if no existing SQL Server Firewall Rules found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing SQL Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + [] + ); + + noPublicAccess.run(cache, {}, callback); + }); + it('should give passing result if SQL Server has prive netwrok access disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The SQL server has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[0]], + [] + ); + + noPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if SQL Server is open to outside traffic', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + [firewallRules[1]] + ); + + noPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if SQL Server firewall end IP setting is enabled and firewall end IP matches the set value', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + [firewallRules[2]] + ); + + noPublicAccess.run(cache, { server_firewall_end_ip: '255.255.255.255' }, callback); + }); + + it('should give passing result if The SQL server is protected from outside traffic', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The SQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + [firewallRules[0]] + ); + + noPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + noPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for server firewall rules', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for server firewall rules'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + [], + null, + { message: 'Unable to query for server firewall rules'} + ); + + noPublicAccess.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/sqlserver/restrictOutboundNetworking.js b/plugins/azure/sqlserver/restrictOutboundNetworking.js new file mode 100644 index 000000000..d9f665969 --- /dev/null +++ b/plugins/azure/sqlserver/restrictOutboundNetworking.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Server Outbound Networking Restricted', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure outbound networking restrictions are configured for the Azure SQL logical server.', + more_info: 'Outbound firewall rules limit network traffic from the Azure SQL logical server to a customer-defined list of Azure Storage accounts and Azure SQL logical servers. Any attempt to access storage accounts or databases not in this list is denied.', + recommended_action: 'Configure outbound networking restrictions to allow access only to specified Azure Storage accounts and Azure SQL logical servers.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/outbound-firewall-rule-overview?view=azuresql', + apis: ['servers:listSql'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server=> { + if (server.restrictOutboundNetworkAccess && server.restrictOutboundNetworkAccess.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, 'Outbound networking restrictions are configured for SQL server', location, server.id); + } else { + helpers.addResult(results, 2, 'Outbound networking restrictions are not configured for SQL server', location, server.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/restrictOutboundNetworking.spec.js b/plugins/azure/sqlserver/restrictOutboundNetworking.spec.js new file mode 100644 index 000000000..30fcacf95 --- /dev/null +++ b/plugins/azure/sqlserver/restrictOutboundNetworking.spec.js @@ -0,0 +1,80 @@ +var expect = require('chai').expect; +var restrictOutboundNetworking = require('./restrictOutboundNetworking'); + +const servers = [ + { + "name": "test-server", + "restrictOutboundNetworkAccess": "Enabled" + } +]; + +const createCache = (servers, serversErr) => { + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + } + }; +}; + +describe('restrictOutboundNetworking', function () { + describe('run', function () { + it('should give passing result if outbound networking restrictions are configured for the SQL server', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Outbound networking restrictions are configured for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + servers + ); + + restrictOutboundNetworking.run(cache, {}, callback); + }); + + it('should give failing result if outbound networking restrictions are not configured for SQL server', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Outbound networking restrictions are not configured for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [ + { + "name": "test-server", + "restrictOutboundNetworkAccess": "Disabled" + } + ] + ); + + restrictOutboundNetworking.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], + { message: 'unable to query servers' } + ); + + restrictOutboundNetworking.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqlserver/sendAlertsEnabled.js b/plugins/azure/sqlserver/sendAlertsEnabled.js index 59aa640f4..610b28d50 100644 --- a/plugins/azure/sqlserver/sendAlertsEnabled.js +++ b/plugins/azure/sqlserver/sendAlertsEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Send Alerts Enabled', category: 'SQL Server', + domain: 'Databases', + severity: 'Low', description: 'Ensures that send alerts is enabled in advanced data security for SQL servers.', more_info: 'Enabling send alerts in advanced data security on all SQL servers ensures that monitored data for unusual activity, vulnerabilities, and threats get sent to the email addresses configured in advanced data protections.', recommended_action: 'Ensure that an email address is activated under send alerts in advanced data security for all SQL servers.', - link: 'https://docs.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', + link: 'https://learn.microsoft.com/en-gb/azure/sql-database/sql-database-advanced-data-security', apis: ['servers:listSql', 'serverSecurityAlertPolicies:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:securityalertpolicies:write'], run: function(cache, settings, callback) { const results = []; @@ -44,19 +47,19 @@ module.exports = { if (!serverSecurityAlertPolicies.data.length) { helpers.addResult(results, 2, 'Database Threat Detection Policies are not enabled on the server', location, server.id); } else { - serverSecurityAlertPolicies.data.forEach(serverSecurityAlertPolicy => { - if (serverSecurityAlertPolicy.state && - serverSecurityAlertPolicy.state.toLowerCase() == 'enabled' && - serverSecurityAlertPolicy.emailAddresses && - serverSecurityAlertPolicy.emailAddresses[0] && - serverSecurityAlertPolicy.emailAddresses[0] != '') { - helpers.addResult(results, 0, - 'Send alerts is enabled on the SQL server', location, server.id); - } else { - helpers.addResult(results, 2, - 'Send alerts is not enabled on the SQL server', location, server.id); - } - }); + let alertsEnabled = serverSecurityAlertPolicies.data.find(serverSecurityAlertPolicy => + serverSecurityAlertPolicy.state && + serverSecurityAlertPolicy.state.toLowerCase() == 'enabled' && + serverSecurityAlertPolicy.emailAddresses && + serverSecurityAlertPolicy.emailAddresses[0] && + serverSecurityAlertPolicy.emailAddresses[0] != ''); + if (alertsEnabled) { + helpers.addResult(results, 0, + 'Send alerts is enabled on the SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Send alerts is not enabled on the SQL server', location, server.id); + } } } }); diff --git a/plugins/azure/sqlserver/serverAuditingEnabled.js b/plugins/azure/sqlserver/serverAuditingEnabled.js index a8a771634..1155b8fb9 100644 --- a/plugins/azure/sqlserver/serverAuditingEnabled.js +++ b/plugins/azure/sqlserver/serverAuditingEnabled.js @@ -4,16 +4,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Server Auditing Enabled', category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL Server Auditing is enabled for SQL servers', more_info: 'Enabling SQL Server Auditing ensures that all activities are being logged properly, including potentially-malicious activity.', recommended_action: 'Ensure that auditing is enabled for each SQL server.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/sql-database-auditing', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-auditing', apis: ['servers:listSql', 'serverBlobAuditingPolicies:get'], compliance: { hipaa: 'HIPAA requires that a secure audit record for ' + 'write read and delete is created for all ' + 'activities in the system.' }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:auditingsettings:write'], run: function(cache, settings, callback) { var results = []; @@ -49,14 +52,14 @@ module.exports = { if (!serverBlobAuditingPolicies.data.length) { helpers.addResult(results, 2, 'No Server Auditing policies found', location, server.id); } else { - serverBlobAuditingPolicies.data.forEach(serverBlobAuditingPolicy => { - if (serverBlobAuditingPolicy.state && - serverBlobAuditingPolicy.state.toLowerCase() === 'enabled') { - helpers.addResult(results, 0, 'Server auditing is enabled on the SQL Server', location, server.id); - } else { - helpers.addResult(results, 2, 'Server auditing is not enabled on the SQL Server', location, server.id); - } - }); + let auditingEnabled = serverBlobAuditingPolicies.data.find(serverBlobAuditingPolicy => + serverBlobAuditingPolicy.state && + serverBlobAuditingPolicy.state.toLowerCase() === 'enabled'); + if (auditingEnabled) { + helpers.addResult(results, 0, 'Server auditing is enabled on the SQL Server', location, server.id); + } else { + helpers.addResult(results, 2, 'Server auditing is not enabled on the SQL Server', location, server.id); + } } } }); diff --git a/plugins/azure/sqlserver/serverConnectionPolicy.js b/plugins/azure/sqlserver/serverConnectionPolicy.js new file mode 100644 index 000000000..ac91d8a68 --- /dev/null +++ b/plugins/azure/sqlserver/serverConnectionPolicy.js @@ -0,0 +1,67 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Connection Policy', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that the connection policy is set to "Redirect" for SQL server.', + more_info: 'Redirect policy means that after the TCP session is established to Azure SQL Database, the client session is then redirected to the right database cluster with a change to the destination virtual IP from that of the Azure SQL Database gateway to that of the cluster. This establishes connections directly to the node hosting the database, leading to reduced latency and improved throughput.', + recommended_action: 'Ensure that connection policy is set to "Redirect" for each SQL server.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/connectivity-architecture?view=azuresql', + apis: ['servers:listSql', 'connectionPolicies:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:connectionpolicies:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(server => { + const connectionPolicies = helpers.addSource(cache, source, + ['connectionPolicies', 'listByServer', location, server.id]); + + if (!connectionPolicies || connectionPolicies.err || !connectionPolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL Server connection policies: ' + helpers.addError(connectionPolicies), location, server.id); + } else { + if (connectionPolicies.data.length) { + if (connectionPolicies.data[0].connectionType && connectionPolicies.data[0].connectionType.toLowerCase() == 'redirect') { + helpers.addResult(results, 0, + 'Connection policy is set to "Redirect" for SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Connection policy is not set to "Redirect" for SQL server', location, server.id); + } + } else { + helpers.addResult(results, 0, + 'No Connection policies found', location, server.id); + } + + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/serverConnectionPolicy.spec.js b/plugins/azure/sqlserver/serverConnectionPolicy.spec.js new file mode 100644 index 000000000..9bc3a4fbf --- /dev/null +++ b/plugins/azure/sqlserver/serverConnectionPolicy.spec.js @@ -0,0 +1,143 @@ +const assert = require('assert'); +const expect = require('chai').expect; +const connectionPolicyPlugin = require('./serverConnectionPolicy'); // Update the path accordingly + +const createCache = (servers, connectionPolicies, serversErr, connectionPoliciesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + connectionPolicies: { + listByServer: { + 'eastus': { + [serverId]: { + err: connectionPoliciesErr, + data: connectionPolicies + } + } + } + } + }; +}; + +describe('sqlServerConnectionPolicy', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + + connectionPolicyPlugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache(null); + + connectionPolicyPlugin.run(cache, {}, callback); + }); + + it('should give passing result if connection policy is set to "Redirect"', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Connection policy is set to "Redirect" for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'location': 'eastus' + } + ], + [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/connectionPolicies/Default', + 'name': 'Default', + 'type': 'Microsoft.Sql/servers/connectionPolicies', + 'connectionType': 'Redirect' + } + ] + ); + + connectionPolicyPlugin.run(cache, {}, callback); + }); + + it('should give failing result if connection policy is not set to "Redirect"', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Connection policy is not set to "Redirect" for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'location': 'eastus' + } + ], + [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/connectionPolicies/Default', + 'name': 'Default', + 'type': 'Microsoft.Sql/servers/connectionPolicies', + 'connectionType': 'NotRedirect' + } + ] + ); + + connectionPolicyPlugin.run(cache, {}, callback); + }); + + it('should give passing result if no Connection policies found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Connection policies found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'location': 'eastus' + } + ], + [] + ); + + connectionPolicyPlugin.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/azure/sqlserver/serverPrivateEndpoints.js b/plugins/azure/sqlserver/serverPrivateEndpoints.js new file mode 100644 index 000000000..1a2b884d9 --- /dev/null +++ b/plugins/azure/sqlserver/serverPrivateEndpoints.js @@ -0,0 +1,53 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Private Endpoints Configured', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that SQL Servers are accessible only through private endpoints', + more_info: 'Azure Private Endpoint is a network interface that connects you privately and securely to a service powered by Azure Private Link. Private Endpoint uses a private IP address from your VNet, effectively bringing the service such as Azure SQL Server into your VNet.', + recommended_action: 'Ensure that Private Endpoints are configured properly and Public Network Access is disabled for SQL Server', + link: 'https://learn.microsoft.com/en-us/azure/private-link/private-link-overview', + apis: ['servers:listSql'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftnetwork:privateendpoints:write', 'microsoftsql:servers:privateendpointconnections:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + for (const server of servers.data) { + if (server.privateEndpointConnections && server.privateEndpointConnections.length) { + helpers.addResult(results, 0, 'Private Endpoints are configured for the SQL Server', location, server.id); + } else { + helpers.addResult(results, 2, 'Private Endpoints are not configured for the SQL Server', location, server.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/serverPrivateEndpoints.spec.js b/plugins/azure/sqlserver/serverPrivateEndpoints.spec.js new file mode 100644 index 000000000..13f833854 --- /dev/null +++ b/plugins/azure/sqlserver/serverPrivateEndpoints.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var serverPrivateEndpoints = require('./serverPrivateEndpoints'); + +const servers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server/privateEndpointConnections/test-endpoint', + 'provisioningState': 'Ready' + } + ], + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Sql/servers/test-server', + 'name': 'test-server', + 'privateEndpointConnections': [], + 'location': 'eastus' + } +]; + + +const createCache = (servers) => { + let server = {}; + if (servers) { + server['data'] = servers; + } + return { + servers: { + listSql: { + 'eastus': server + } + } + }; +}; + +describe('serverPrivateEndpoints', function() { + describe('run', function() { + it('should give passing result if no SQL servers', function(done) { + const cache = createCache([]); + serverPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const cache = createCache(null); + serverPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if private endpoints are configured', function(done) { + const cache = createCache([servers[0]]); + serverPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Private Endpoints are configured for the SQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if private endpoints are not configured', function(done) { + const cache = createCache([servers[1]]); + serverPrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Private Endpoints are not configured for the SQL Server'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/serverSendEmailToAdmins.js b/plugins/azure/sqlserver/serverSendEmailToAdmins.js new file mode 100644 index 000000000..bc8ad4338 --- /dev/null +++ b/plugins/azure/sqlserver/serverSendEmailToAdmins.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Server Send Email to Admin and Owners', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Send Emails to admins and owners is enabled for SQL Servers.', + more_info: 'Vulnerability Assessment (VA) scan reports and alerts will be sent to email addresses configured at "Send scan reports to". This may help in reducing time required for identifying risks and taking corrective measures.', + recommended_action: 'Configure Send scan reports to email addresses of concerned data owners/stakeholders for critical SQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-vulnerability-assessment', + apis: ['servers:listSql', 'vulnerabilityAssessments:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:sqlvulnerabilityassessments:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + const vulnerabilityAssessments = helpers.addSource(cache, source, + ['vulnerabilityAssessments', 'listByServer', location, server.id]); + + if (!vulnerabilityAssessments || vulnerabilityAssessments.err || !vulnerabilityAssessments.data) { + helpers.addResult(results, 3, + 'Unable to query for Vulnerability Assessments setting: ' + helpers.addError(vulnerabilityAssessments), location, server.id); + } else { + if (!vulnerabilityAssessments.data.length) { + helpers.addResult(results, 2, 'No Vulnerability Assessments setting found', location, server.id); + } else { + let emailNotificationEnabled = vulnerabilityAssessments.data.find(vulnerabilityAssessment => + vulnerabilityAssessment.recurringScans && vulnerabilityAssessment.recurringScans.emailSubscriptionAdmins); + if (emailNotificationEnabled) { + helpers.addResult(results, 0, + 'Send Email notifications to admins for the SQL server is enabled', location, server.id); + } else { + helpers.addResult(results, 2, + 'Send Email notifications to admins for the SQL server is disabled', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/serverSendEmailToAdmins.spec.js b/plugins/azure/sqlserver/serverSendEmailToAdmins.spec.js new file mode 100644 index 000000000..fb1c8511f --- /dev/null +++ b/plugins/azure/sqlserver/serverSendEmailToAdmins.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var serverSendEmailToAdmins = require('./serverSendEmailToAdmins'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const vulnerabilityAssessments = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: true, emailSubscriptionAdmins: true, emails: [ 'test@gmail.com' ] } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: false, emailSubscriptionAdmins: false, emails: [] } + } +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + vulnerabilityAssessments: { + listByServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('serverSendEmailToAdmins', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + + it('should give failing result if No Vulnerability Assessments setting found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Vulnerability Assessments setting found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + + it('should give failing result if Send Email notifications to admins for the SQL server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Send Email notifications to admins for the SQL server is disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[1]] + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + + it('should give passing result if Send Email notifications to admins for the SQL server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Send Email notifications to admins for the SQL server is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[0]] + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Vulnerability Assessments setting', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Vulnerability Assessments setting'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Vulnerability Assessments setting'} + ); + + serverSendEmailToAdmins.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.js b/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.js new file mode 100644 index 000000000..b2a78deac --- /dev/null +++ b/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.js @@ -0,0 +1,79 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Services Access Disabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that SQL servers do not allow access to other Azure services.', + more_info: 'To secure your SQL server, it is recommended to disable public network access or access to all Azure services. Instead, configure firewall or VNET rules to allow connections from specific network ranges or from designated virtual networks. This helps prevent unauthorized access from Azure services outside your subscription.', + recommended_action: 'Disable public access and remove "allowallwindowsazureips" firewall rule for all SQL Servers.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/network-access-controls-overview?view=azuresql', + apis: ['servers:listSql','firewallRules:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByServer', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query SQL Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + return; + + } + + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing SQL Server Firewall Rules found', location, server.id); + return; + + } + + let accessToServices = false; + for (let rule of firewallRules.data) { + if (rule.name && rule.name.toLowerCase() === 'allowallwindowsazureips') { + accessToServices = true; + break; + } + } + + if (!accessToServices || (server.publicNetworkAccess && server.publicNetworkAccess.toLowerCase() == 'disabled')) { + helpers.addResult(results, 0, + 'Access to other Azure services is disabled for SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Access to other Azure services is not disabled for SQL server', location, server.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.spec.js b/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.spec.js new file mode 100644 index 000000000..ee7a6bfdd --- /dev/null +++ b/plugins/azure/sqlserver/sqlAzureServicesAccessDisabled.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var sqlServerServicesAccessDisabled = require('./sqlAzureServicesAccessDisabled'); + +const listSql = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.Sql/servers/sql-server-1", + "name": "sql-server-1", + "type": "Microsoft.Sql/servers", + "location": "East US", + "publicNetworkAccess": "Enabled" + }, +]; + +const firewallRules = [ + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.Sql/servers/sql-server-1/firewallRules/TestRule", + "name": "testRule", + "type": "Microsoft.Sql/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + }, + { + "id": "/subscriptions/jk34n234k-dwef/resourceGroups/test-rg/providers/Microsoft.Sql/servers/sql-server-1/firewallRules/AllowAllWindowsAzureIps", + "name": "AllowAllWindowsAzureIps", + "type": "Microsoft.Sql/servers/firewallRules", + "location": "East US", + "kind": "v12.0", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + }, +]; + +const createCache = (list, rules) => { + const serverId = (list && list.length) ? list[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + data: list + } + } + }, + firewallRules: { + listByServer: { + 'eastus': { + [serverId]: { + data: rules + } + } + } + } + }; +}; + +describe('sqlServerServicesAccessDisabled', function () { + describe('run', function () { + it('should give passing result if no SQL servers', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + + sqlServerServicesAccessDisabled.run(cache, {}, callback); + }); + + it('should give failing result if SQL server does not have access disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Access to other Azure services is not disabled for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [listSql[0]], + [firewallRules[1]] + ); + + sqlServerServicesAccessDisabled.run(cache, {}, callback); + }); + + it('should give passing result if SQL server not have access to azure services', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Access to other Azure services is disabled for SQL server'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [listSql[0]], + [firewallRules[0]] + ); + + sqlServerServicesAccessDisabled.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL Servers', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + null + ); + + sqlServerServicesAccessDisabled.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/azure/sqlserver/sqlServerHasTags.js b/plugins/azure/sqlserver/sqlServerHasTags.js new file mode 100644 index 000000000..9b33e42bf --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Has Tags', + category: 'SQL Server', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that Azure SQL servers have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify SQL Server and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['servers:listSql'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + for (let server of servers.data) { + if (!server.id) continue; + + if (server.tags && Object.entries(server.tags).length > 0){ + helpers.addResult(results, 0, 'SQL Server has tags', location, server.id); + } else { + helpers.addResult(results, 2, 'SQL Server does not have tags', location, server.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/sqlServerHasTags.spec.js b/plugins/azure/sqlserver/sqlServerHasTags.spec.js new file mode 100644 index 000000000..73f4062dd --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerHasTags.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var sqlServerHasTags = require('./sqlServerHasTags'); + + const serverslist = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": { 'key': 'value' }, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "minimalTlsVersion": "1.1", + "publicNetworkAccess": "Enabled" + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled" + } + ]; +const createCache = (serversobj) => { + return { + servers: { + listSql: { + 'eastus': { + data: serversobj + } + } + } + }; +}; + +const createErrorCache = () => { + return { + servers: { + listSql: { + 'eastus': {} + } + } + }; +}; + +describe('sqlServerHasTags', function() { + describe('run', function() { + it('should give passing result if no sql server found', function(done) { + const cache = createCache([]); + sqlServerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for sql server', function(done) { + const cache = createErrorCache(); + sqlServerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if sql server has tags', function(done) { + const cache = createCache([serverslist[0]]); + sqlServerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if sql server does not have tags', function(done) { + const cache = createCache([serverslist[1]]); + sqlServerHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerManagedIdentity.js b/plugins/azure/sqlserver/sqlServerManagedIdentity.js new file mode 100644 index 000000000..0d5e6f55d --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerManagedIdentity.js @@ -0,0 +1,51 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Managed Identity Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Azure SQL servers have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Enable system or user-assigned managed identities for sql servers.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-azure-ad-user-assigned-managed-identity?view=azuresql', + apis: ['servers:listSql'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach((server) => { + if (server.identity && server.identity.type && (server.identity.type.toLowerCase() == 'userassigned' || server.identity.type.toLowerCase() == 'systemassigned')) { + helpers.addResult(results, 0, 'SQL Server has managed identity enabled', location, server.id); + } else { + helpers.addResult(results, 2, 'SQL Server does not have managed identity enabled', location, server.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/sqlServerManagedIdentity.spec.js b/plugins/azure/sqlserver/sqlServerManagedIdentity.spec.js new file mode 100644 index 000000000..11fc2643e --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerManagedIdentity.spec.js @@ -0,0 +1,75 @@ +var expect = require('chai').expect; +var sqlServerManagedIdentityEnabled = require('./sqlServerManagedIdentity'); + +const servers = [ + { + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server', + "identity": { + "type": "UserAssigned" + } + }, + { + "id": '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server-no-identity' + } +]; + +const createCache = (servers, serversErr) => { + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + } + }; +}; + +describe('sqlServerManagedIdentityEnabled', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const cache = createCache([]); + sqlServerManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const cache = createCache(null, { message: 'unable to query servers' }); + sqlServerManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if managed identity is enabled', function(done) { + const cache = createCache([servers[0]]); + sqlServerManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if managed identity is not enabled', function(done) { + const cache = createCache([servers[1]]); + sqlServerManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/sqlserver/sqlServerRecurringScans.js b/plugins/azure/sqlserver/sqlServerRecurringScans.js new file mode 100644 index 000000000..c20244568 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerRecurringScans.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Recurring Scans Enabled', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Period Recurring Scans feature is enabled for SQL Servers.', + more_info: 'Setting periodic recurring scans schedules periodic (weekly) vulnerability scanning for the SQL server and corresponding Databases. Periodic and regular vulnerability scanning provides risk visibility based on updated known vulnerability signatures and best practices.', + recommended_action: 'Ensure that recurring scans feature is set to Enabled.', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-vulnerability-assessment', + apis: ['servers:listSql', 'vulnerabilityAssessments:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:sqlvulnerabilityassessments:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + const vulnerabilityAssessments = helpers.addSource(cache, source, + ['vulnerabilityAssessments', 'listByServer', location, server.id]); + + if (!vulnerabilityAssessments || vulnerabilityAssessments.err || !vulnerabilityAssessments.data) { + helpers.addResult(results, 3, + 'Unable to query for Vulnerability Assessments setting: ' + helpers.addError(vulnerabilityAssessments), location, server.id); + } else { + if (!vulnerabilityAssessments.data.length) { + helpers.addResult(results, 2, 'No Vulnerability Assessments setting found', location, server.id); + } else { + let recScansEnabled = vulnerabilityAssessments.data.find(vulnerabilityAssessment => + vulnerabilityAssessment.recurringScans && vulnerabilityAssessment.recurringScans.isEnabled); + + if (recScansEnabled) { + helpers.addResult(results, 0, + 'Recurring Scans for the SQL server is enabled', location, server.id); + } else { + helpers.addResult(results, 2, + 'Recurring Scans for the SQL server is disabled', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerRecurringScans.spec.js b/plugins/azure/sqlserver/sqlServerRecurringScans.spec.js new file mode 100644 index 000000000..35564ee9a --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerRecurringScans.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var sqlServerRecurringScans = require('./sqlServerRecurringScans'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const vulnerabilityAssessments = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: true, emailSubscriptionAdmins: true, emails: [] } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: false, emailSubscriptionAdmins: true, emails: [] } + } +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + vulnerabilityAssessments: { + listByServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('sqlServerRecurringScans', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + + it('should give failing result if No Vulnerability Assessments setting found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Vulnerability Assessments setting found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + + it('should give failing result if Recurring Scans for Sql Server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Recurring Scans for the SQL server is disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[1]] + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + + it('should give passing result if Recurring Scans for the SQL server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Recurring Scans for the SQL server is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[0]] + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Vulnerability Assessments setting', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Vulnerability Assessments setting'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Vulnerability Assessments setting'} + ); + + sqlServerRecurringScans.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerSendScanReports.js b/plugins/azure/sqlserver/sqlServerSendScanReports.js new file mode 100644 index 000000000..781d977b7 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerSendScanReports.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Send Scan Reports', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Send Scan Reports is enabled for SQL Servers.', + more_info: 'Vulnerability Assessment (VA) scan reports and alerts will be sent to email ids configured at Send scan reports to. This may help in reducing time required for identifying risks and taking corrective measures.', + recommended_action: 'Configure Send scan reports to with email ids of concerned data owners/stakeholders for a critical SQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-vulnerability-assessment', + apis: ['servers:listSql', 'vulnerabilityAssessments:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:sqlvulnerabilityassessments:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + const vulnerabilityAssessments = helpers.addSource(cache, source, + ['vulnerabilityAssessments', 'listByServer', location, server.id]); + + if (!vulnerabilityAssessments || vulnerabilityAssessments.err || !vulnerabilityAssessments.data) { + helpers.addResult(results, 3, + 'Unable to query for Vulnerability Assessments setting: ' + helpers.addError(vulnerabilityAssessments), location, server.id); + } else { + if (!vulnerabilityAssessments.data.length) { + helpers.addResult(results, 2, 'No Vulnerability Assessments setting found', location, server.id); + } else { + let scanReports = vulnerabilityAssessments.data.find(vulnerabilityAssessment => + vulnerabilityAssessment.recurringScans && + vulnerabilityAssessment.recurringScans.emails&& + vulnerabilityAssessment.recurringScans.emails.length); + if (scanReports) { + helpers.addResult(results, 0, + 'Send Scan Reports for the SQL server is enabled', location, server.id); + } else { + helpers.addResult(results, 2, + 'Send Scan Reports for the SQL server is disabled', location, server.id); + } + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerSendScanReports.spec.js b/plugins/azure/sqlserver/sqlServerSendScanReports.spec.js new file mode 100644 index 000000000..b565f2234 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerSendScanReports.spec.js @@ -0,0 +1,157 @@ +var expect = require('chai').expect; +var sqlServerSendScanReports = require('./sqlServerSendScanReports'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + } +]; + +const vulnerabilityAssessments = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: true, emailSubscriptionAdmins: true, emails: [ 'test@gmail.com' ] } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/vulnerabilityAssessments/Default', + name: 'Default', + type: 'Microsoft.Sql/servers/vulnerabilityAssessments', + storageContainerPath: 'https://sqlvadfi44mwgvnjki.blob.core.windows.net/vulnerability-assessment/', + recurringScans: { isEnabled: false, emailSubscriptionAdmins: true, emails: [] } + } +]; + +const createCache = (servers, policies, serversErr, policiesErr) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + }, + vulnerabilityAssessments: { + listByServer: { + 'eastus': { + [serverId]: { + err: policiesErr, + data: policies + } + } + } + } + } +}; + +describe('sqlServerSendScanReports', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + + it('should give failing result if No Vulnerability Assessments setting found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Vulnerability Assessments setting found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [] + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + + it('should give failing result if Send Scan Reports for the SQL server is disabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Send Scan Reports for the SQL server is disabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[1]] + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + + it('should give passing result if Send Scan Reports for the SQL server is enabled', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Send Scan Reports for the SQL server is enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [vulnerabilityAssessments[0]] + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + { message: 'unable to query servers'} + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + + it('should give unknown result if Unable to query for Vulnerability Assessments setting', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Vulnerability Assessments setting'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + [], + null, + { message: 'Unable to query for Vulnerability Assessments setting'} + ); + + sqlServerSendScanReports.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerTlsVersion.js b/plugins/azure/sqlserver/sqlServerTlsVersion.js new file mode 100644 index 000000000..0628a6186 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerTlsVersion.js @@ -0,0 +1,116 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server Minimum TLS Version', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures Microsoft Azure SQL Servers do not allow outdated TLS certificate versions.', + more_info: 'TLS 1.2 or higher should be used for all TLS connections to Microsoft Azure SQL server. This setting applies to all databases associated with the server.', + recommended_action: 'Modify SQL server firewall and virtual network settings to set desired minimum TLS version.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/connectivity-settings#minimal-tls-version', + apis: ['servers:listSql'], + remediation_min_version: '202104012200', + remediation_description: 'TLS version 1.2 will be set for the affected SQL server', + apis_remediate: ['servers:listSql'], + actions: {remediate:['servers:update'], rollback:['servers:update']}, + permissions: {remediate: ['servers:update'], rollback: ['servers:update']}, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var sql_server_min_tls_version = '1.2'; + + var desiredVersion = parseFloat(sql_server_min_tls_version); + + async.each(locations.servers, function(location, rcb) { + var servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + if (!server.id) return; + + if (server.minimalTlsVersion) { + if (parseFloat(server.minimalTlsVersion) >= desiredVersion) { + helpers.addResult(results, 0, + `SQL server is using TLS version ${server.minimalTlsVersion} which is equal to or higher than desired TLS version ${sql_server_min_tls_version}`, + location, server.id); + } else { + helpers.addResult(results, 2, + `SQL server is using TLS version ${server.minimalTlsVersion} which is less than desired TLS version ${sql_server_min_tls_version}`, + location, server.id); + } + } else { + helpers.addResult(results, 2, + 'SQL server allows all TLS versions', + location, server.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'sqlServerTlsVersion'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2020-08-01-preview'; + var method = 'PATCH'; + + // for logging purposes + var serverNameArr = resource.split('/'); + var serverName = serverNameArr[serverNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'minimalTlsVersion': '1.2' + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'TLS1.2': 'Disabled', + 'Server': serverName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } + } +}; \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js b/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js new file mode 100644 index 000000000..13b477315 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var sqlServerTlsVersion = require('./sqlServerTlsVersion'); + +const servers = [ + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "minimalTlsVersion": "1.1", + "publicNetworkAccess": "Enabled" + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled" + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "khulnasoft", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "minimalTlsVersion": "1.2", + "publicNetworkAccess": "Enabled" + } +]; + +const createCache = (servers, serversErr) => { + return { + servers: { + listSql: { + 'eastus': { + err: serversErr, + data: servers + } + } + } + } +}; + +describe('sqlServerTlsVersion', function() { + describe('run', function() { + it('should give passing result if no SQL servers found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + sqlServerTlsVersion.run(cache, {}, callback); + }); + + it('should give failing result if SQL server is using TLS version less than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('less than desired TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[0]], + ); + + sqlServerTlsVersion.run(cache, { sql_server_min_tls_version: '1.2' }, callback); + }); + + it('should give failing result if SQL server allows all TLS versions', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL server allows all TLS versions'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[1]], + ); + + sqlServerTlsVersion.run(cache, { sql_server_min_tls_version: '1.2' }, callback); + }); + + it('should give passing result if SQL server is using TLS version equal to or higher than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('equal to or higher than desired TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [servers[2]] + ); + + sqlServerTlsVersion.run(cache, { sql_server_min_tls_version: '1.2' }, callback); + }); + + it('should give unknown result if unable to query for SQL servers', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + servers, + { message: 'unable to query servers'} + ); + + sqlServerTlsVersion.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js b/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js new file mode 100644 index 000000000..2f17d7de3 --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js @@ -0,0 +1,63 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'SQL Server VNet Rules Integrated', + category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', + + description: 'Ensures that SQL servers have VNet rules integrated.', + more_info: 'Configuring SQL server to operate within a Virtual Network (VNet) offers a myriad of benefits for enhanced security and operational control. Integrating with a VNet enables proactive safeguarding of your server against potential security threats and unauthorized access.', + recommended_action: 'Ensure VNet rule is integrated for all SQL servers.', + link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/vnet-service-endpoint-rule-overview?view=azuresql', + apis: ['servers:listSql','virtualNetworkRules:listByServer'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing SQL servers found', location); + return rcb(); + } + servers.data.forEach(function(server) { + + const virtualNetworkRules = helpers.addSource(cache, source, + ['virtualNetworkRules', 'listByServer', location, server.id]); + + if (!virtualNetworkRules || virtualNetworkRules.err || !virtualNetworkRules.data) { + helpers.addResult(results, 3, + 'Unable to query SQL Server VNet Rules: ' + helpers.addError(virtualNetworkRules), location, server.id); + return; + + } + + if (virtualNetworkRules.data.length) { + helpers.addResult(results, 0, 'SQL server has VNet rule integrated', location, server.id); + } else { + helpers.addResult(results, 2, 'SQL server does not have VNet rule integrated',location, server.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js.spec.js b/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js.spec.js new file mode 100644 index 000000000..d0e782efc --- /dev/null +++ b/plugins/azure/sqlserver/sqlServerVNetRuleIntegrated.js.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var sqlServerVNetIntegrated = require('./sqlServerVNetRuleIntegrated'); + +const listSqlServers = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.Sql/servers/test-sql-server", + "type": "Microsoft.Sql/servers", + "storageProfile": { + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "publicNetworkAccess": "Enabled" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.Sql/servers/test-sql-server2", + "type": "Microsoft.Sql/servers", + "storageProfile": { + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled", + "storageAutogrow": "Disabled" + }, + "publicNetworkAccess": "Disabled" + } +]; + +const virtualNetworkRules = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.Sql/servers/test-sql-server/virtualNetworkRules/TestRule", + "name": "TestRule", + "type": "Microsoft.Sql/servers/virtualNetworkRules", + "location": "East US", + "properties": { + "virtualNetworkSubnetId": "/subscriptions/12345/resourceGroups/cloudexploit-dev/providers/Microsoft.Network/virtualNetworks/test/subnets/default" + } + } +]; + +const createCache = (listSqlServers, rules) => { + const serverId = (listSqlServers && listSqlServers.length) ? listSqlServers[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + data: listSqlServers + } + } + }, + virtualNetworkRules: { + listByServer: { + 'eastus': { + [serverId]: { + data: rules + } + } + } + } + }; +}; + +describe('sqlServerVNetIntegrated', function () { + describe('run', function () { + it('should give passing result if no servers', function (done) { + const cache = createCache([]); + sqlServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing SQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VNet is not configured', function (done) { + const cache = createCache([listSqlServers[0]],[]); + sqlServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL server does not have VNet rule integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VNet is configured', function (done) { + const cache = createCache([listSqlServers[1]], [virtualNetworkRules[0]]); + sqlServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL server has VNet rule integrated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query SQL Servers', function (done) { + const cache = createCache(null); + sqlServerVNetIntegrated.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/sqlserver/tdeProtectorEncrypted.js b/plugins/azure/sqlserver/tdeProtectorEncrypted.js index 83b2341aa..e230aa181 100644 --- a/plugins/azure/sqlserver/tdeProtectorEncrypted.js +++ b/plugins/azure/sqlserver/tdeProtectorEncrypted.js @@ -4,66 +4,131 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'TDE Protector Encrypted', category: 'SQL Server', + domain: 'Databases', + severity: 'Medium', description: 'Ensures SQL Server TDE protector is encrypted with BYOK (Bring Your Own Key)', more_info: 'Enabling BYOK in the TDE protector allows for greater control and transparency, as well as increasing security by having full control of the encryption keys.', - recommended_action: 'Ensure that a BYOK key is set for the Transparent Data Encryption of each SQL Server.', - link: 'https://docs.microsoft.com/en-us/azure/sql-database/transparent-data-encryption-byok-azure-sql', - apis: ['servers:listSql', 'encryptionProtectors:listByServer'], + recommended_action: 'Ensure that a BYOK key is set for the Transparent Data Encryption of each SQL Server or Managed Instance.', + link: 'https://learn.microsoft.com/en-us/azure/sql-database/transparent-data-encryption-byok-azure-sql', + apis: ['servers:listSql', 'encryptionProtectors:listByServer', 'managedInstances:list', 'managedInstanceEncryptionProtectors:listByInstance'], + settings: { + sql_tde_protector_encryption_key: { + name: 'SQL Server TDE Protector Encryption Key Type', + description: 'Desired encryption key for SQL Server and Managed Instance transparent data encryption; default=service-managed key, cmk=customer-managed key', + regex: '(default|byok)', + default: 'byok' + } + }, + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:encryptionprotector:write'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); - - async.each(locations.servers, function(location, rcb) { - - var servers = helpers.addSource(cache, source, - ['servers', 'listSql', location]); - - if (!servers) return rcb(); - - if (servers.err || !servers.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL servers: ' + helpers.addError(servers), location); - return rcb(); - } - - if (!servers.data.length) { - helpers.addResult(results, 0, 'No SQL servers found', location); - return rcb(); - } - - servers.data.forEach(function(server) { - const encryptionProtectors = helpers.addSource(cache, source, - ['encryptionProtectors', 'listByServer', location, server.id]); - - if (!encryptionProtectors || encryptionProtectors.err || !encryptionProtectors.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL Server Encryption Protectors: ' + helpers.addError(encryptionProtectors), location, server.id); + + var config = { + sql_tde_protector_encryption_key: settings.sql_tde_protector_encryption_key || this.settings.sql_tde_protector_encryption_key.default + }; + + function checkEncryptionProtection(encryptionProtector, location, config, serviceType) { + if (config.sql_tde_protector_encryption_key == 'byok') { + if ((encryptionProtector.kind && + encryptionProtector.kind.toLowerCase() != 'azurekeyvault') || + (encryptionProtector.serverKeyType && + encryptionProtector.serverKeyType.toLowerCase() != 'azurekeyvault') || + !encryptionProtector.uri) { + helpers.addResult(results, 2, + `${serviceType} TDE protector is not encrypted with BYOK`, location, encryptionProtector.id); } else { - if (!encryptionProtectors.data.length) { - helpers.addResult(results, 0, 'No SQL Server Encryption Protectors found for server', location, server.id); - } else { - encryptionProtectors.data.forEach(encryptionProtector => { - if ((encryptionProtector.kind && - encryptionProtector.kind.toLowerCase() != 'azurekeyvault') || - (encryptionProtector.serverKeyType || - encryptionProtector.serverKeyType.toLowerCase() != 'azurekeyvault') || - !encryptionProtector.uri) { - helpers.addResult(results, 2, - 'SQL Server TDE protector is not encrypted with BYOK', location, encryptionProtector.id); - } else { - helpers.addResult(results, 0, - 'SQL Server TDE protector is encrypted with BYOK', location, encryptionProtector.id); - } - }); + helpers.addResult(results, 0, + `${serviceType} TDE protector is encrypted with BYOK`, location, encryptionProtector.id); + } + } else { + if (encryptionProtector.kind || encryptionProtector.serverKeyType) { + helpers.addResult(results, 0, + `${serviceType} TDE protector is encrypted with service-managed key`, location, encryptionProtector.id); + } + } + } + + async.each(locations.servers, function(location, rcb) { + async.parallel([ + // Check SQL Servers + function(cb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return cb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return cb(); } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return cb(); + } + + servers.data.forEach(server => { + const encryptionProtectors = helpers.addSource(cache, source, + ['encryptionProtectors', 'listByServer', location, server.id]); + + if (!encryptionProtectors || encryptionProtectors.err || !encryptionProtectors.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL Server Encryption Protectors: ' + helpers.addError(encryptionProtectors), location, server.id); + } else if (!encryptionProtectors.data.length) { + helpers.addResult(results, 0, 'No SQL Server Encryption Protectors found', location, server.id); + } else { + encryptionProtectors.data.forEach(protector => { + checkEncryptionProtection(protector, location, config, 'SQL Server'); + }); + } + }); + + cb(); + }, + // Check Managed Instances + function(cb) { + const managedInstances = helpers.addSource(cache, source, + ['managedInstances', 'list', location]); + + if (!managedInstances) return cb(); + + if (managedInstances.err || !managedInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instances: ' + helpers.addError(managedInstances), location); + return cb(); + } + + if (!managedInstances.data.length) { + helpers.addResult(results, 0, 'No managed instances found', location); + return cb(); + } + + managedInstances.data.forEach(instance => { + const managedInstanceEncryptionProtectors = helpers.addSource(cache, source, + ['managedInstanceEncryptionProtectors', 'listByInstance', location, instance.id]); + + if (!managedInstanceEncryptionProtectors || managedInstanceEncryptionProtectors.err || !managedInstanceEncryptionProtectors.data) { + helpers.addResult(results, 3, + 'Unable to query for Managed Instance Encryption Protectors: ' + helpers.addError(managedInstanceEncryptionProtectors), location, instance.id); + } else if (!managedInstanceEncryptionProtectors.data.length) { + helpers.addResult(results, 0, 'No Managed Instance Encryption Protectors found', location, instance.id); + } else { + managedInstanceEncryptionProtectors.data.forEach(protector => { + checkEncryptionProtection(protector, location, config, 'Managed Instance'); + }); + } + }); + + cb(); } + ], function() { + rcb(); }); - - rcb(); }, function() { - // Global checking goes here callback(null, results, source); }); } diff --git a/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js b/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js new file mode 100644 index 000000000..b7e8066b7 --- /dev/null +++ b/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js @@ -0,0 +1,171 @@ +const expect = require('chai').expect; +const tdeProtectorEncrypted = require('./tdeProtectorEncrypted'); + +const servers = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers" + } +]; + +const managedInstances = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance", + "name": "test-instance", + "type": "Microsoft.Sql/managedInstances" + } +]; + +const byokEncryptionProtector = { + "kind": "azurekeyvault", + "serverKeyType": "AzureKeyVault", + "uri": "https://test-vault.vault.azure.net/keys/test-key/123", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/encryptionProtector/current" +}; + +const serviceEncryptionProtector = { + "kind": "servicemanaged", + "serverKeyType": "ServiceManaged", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/encryptionProtector/current" +}; + +const createCache = (servers, serverEncryption, managedInstances, managedInstanceEncryption) => { + const serverId = (servers && servers.length) ? servers[0].id : null; + const managedInstanceId = (managedInstances && managedInstances.length) ? managedInstances[0].id : null; + return { + servers: { + listSql: { + 'eastus': { + err: null, + data: servers + } + } + }, + encryptionProtectors: { + listByServer: { + 'eastus': { + [serverId]: { + err: null, + data: serverEncryption + } + } + } + }, + managedInstances: { + list: { + 'eastus': { + err: null, + data: managedInstances + } + } + }, + managedInstanceEncryptionProtectors: { + listByInstance: { + 'eastus': { + [managedInstanceId]: { + err: null, + data: managedInstanceEncryption + } + } + } + } + }; +}; + +describe('tdeProtectorEncrypted', function() { + describe('run', function() { + it('should give passing result if no SQL servers or managed instances found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL servers found'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No managed instances found'); + done(); + }; + + const cache = createCache([], null, [], null); + tdeProtectorEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if no encryption protectors found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL Server Encryption Protectors found'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No Managed Instance Encryption Protectors found'); + done(); + }; + + const cache = createCache(servers, [], managedInstances, []); + tdeProtectorEncrypted.run(cache, {}, callback); + }); + + it('should give failing result if TDE protector is not encrypted with BYOK', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Server TDE protector is not encrypted with BYOK'); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Managed Instance TDE protector is not encrypted with BYOK'); + done(); + }; + + const cache = createCache( + servers, [serviceEncryptionProtector], + managedInstances, [serviceEncryptionProtector] + ); + tdeProtectorEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if TDE protector is encrypted with BYOK', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server TDE protector is encrypted with BYOK'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance TDE protector is encrypted with BYOK'); + done(); + }; + + const cache = createCache( + servers, [byokEncryptionProtector], + managedInstances, [byokEncryptionProtector] + ); + tdeProtectorEncrypted.run(cache, {}, callback); + }); + + it('should give passing result if TDE protector is encrypted with service-managed key when that is allowed', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server TDE protector is encrypted with service-managed key'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance TDE protector is encrypted with service-managed key'); + done(); + }; + + const cache = createCache( + servers, [serviceEncryptionProtector], + managedInstances, [serviceEncryptionProtector] + ); + tdeProtectorEncrypted.run(cache, { sql_tde_protector_encryption_key: 'default' }, callback); + }); + + it('should give unknown result if unable to query SQL servers or managed instances', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instances'); + done(); + }; + + const cache = createCache(null, null, null, null); + tdeProtectorEncrypted.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/blobServiceEncryption.js b/plugins/azure/storageaccounts/blobServiceEncryption.js index fec0dd4f2..0027c7833 100644 --- a/plugins/azure/storageaccounts/blobServiceEncryption.js +++ b/plugins/azure/storageaccounts/blobServiceEncryption.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Blob Service Encryption', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures encryption is properly configured for Blob Services', more_info: 'Blob Services can be configured to encrypt data-at-rest. By default Azure will create a set of keys to encrypt Blob Services, but the recommended approach is to create your own keys using Azure Key Vault.', recommended_action: 'Ensure that Blob Service is configured to use a customer-provided key vault key.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-service-encryption', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption', apis: ['storageAccounts:list'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + @@ -16,6 +18,7 @@ module.exports = { 'Encryption should be enabled for all storage accounts storing this ' + 'type of data.' }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { const results = []; @@ -30,7 +33,7 @@ module.exports = { if (storageAccounts.err || !storageAccounts.data) { helpers.addResult(results, 3, - 'Unable to query for for storage accounts: ' + helpers.addError(storageAccounts), location); + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); return rcb(); } diff --git a/plugins/azure/storageaccounts/blobServiceEncryption.spec.js b/plugins/azure/storageaccounts/blobServiceEncryption.spec.js new file mode 100644 index 000000000..91498847e --- /dev/null +++ b/plugins/azure/storageaccounts/blobServiceEncryption.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var blobServiceIEncryption = require('./blobServiceEncryption'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'services': { + 'blob': { + 'enabled': true + } + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'services': { + 'blob': { + 'enabled': false + } + } + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('blobServiceEncryption', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + blobServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + blobServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if blob encryption is enabled', function(done) { + const cache = createCache([storageAccounts[0]]); + blobServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Blob encryption is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if blob encryption is not enabled', function(done) { + const cache = createCache([storageAccounts[1]]); + blobServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blob encryption is not enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/blobServiceLoggingEnabled.js b/plugins/azure/storageaccounts/blobServiceLoggingEnabled.js new file mode 100644 index 000000000..fc9278de8 --- /dev/null +++ b/plugins/azure/storageaccounts/blobServiceLoggingEnabled.js @@ -0,0 +1,82 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Blob Service Logging Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Storage Blob service logging is enabled for "Read", "Write", and "Delete" requests.', + more_info: 'Azure Storage Blob logs contain detailed information about successful and failed requests made to your storage blobs for read, write and delete operations. This information can be used to monitor individual requests and to diagnose issues with the Storage Blob service within your Microsoft Azure account.', + recommended_action: 'Modify Blob Service and enable storage logging for "Read", "Write", and "Delete" requests.', + link: 'https://learn.microsoft.com/en-us/azure/storage/blobs/monitor-blob-storage?tabs=azure-portal', + apis: ['storageAccounts:list', 'diagnosticSettings:listByBlobServices'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let storageAccount of storageAccounts.data) { + if (!storageAccount.id) continue; + + if (storageAccount.sku && + storageAccount.sku.tier && + storageAccount.sku.tier.toLowerCase() == 'premium') { + helpers.addResult(results, 0, 'Storage Account tier is premium', location, storageAccount.id); + } else if (storageAccount.kind && + storageAccount.kind.toLowerCase() != 'storagev2') { + helpers.addResult(results, 0, 'Storage Account kind is not StorageV2', location, storageAccount.id); + } else { + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByBlobServices', location, storageAccount.id]); + + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Storage Account diagnostics settings: ' + helpers.addError(diagnosticSettings), location, storageAccount.id); + } else { + //First consider that all the logs are missing then remove the ones that are present + var missingLogs = ['StorageRead', 'StorageWrite','StorageDelete']; + + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + if (missingLogs.length) { + helpers.addResult(results, 2, `Storage Account does not have logging enabled for blob service. Missing Logs ${missingLogs}`, location, storageAccount.id); + } else { + helpers.addResult(results, 0, 'Storage Account has logging enabled for blob service read, write and delete requests', location, storageAccount.id); + } + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/blobServiceLoggingEnabled.spec.js b/plugins/azure/storageaccounts/blobServiceLoggingEnabled.spec.js new file mode 100644 index 000000000..812ea23c0 --- /dev/null +++ b/plugins/azure/storageaccounts/blobServiceLoggingEnabled.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +var blobServiceLoggingEnabled = require('./blobServiceLoggingEnabled'); + +const storageAccounts = [ + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + }, + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Premium' + } + }, + { + kind: 'BlobStorage', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Standard' + } + } +]; + +const diagnosticSettings = [ + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/blobservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + { + category: "StorageRead", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageWrite", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageDelete", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + ], + logAnalyticsDestinationType: null, + }, + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/blobservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + ], + logAnalyticsDestinationType: null, + }, +]; +const createCache = (storageAccounts, diagnosticSettings) => { + let diagnostic = {}; + if (storageAccounts.length) { + diagnostic[storageAccounts[0].id] = { + data: diagnosticSettings + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + diagnosticSettings: { + listByBlobServices: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccounts') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noStorageAccount'){ + return { + storageAccounts: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + diagnosticSettings: { + diagnosticSettings: { + 'eastus': {} + } + } + }; + } else { + const appId = (storageAccounts && storageAccounts.length) ? storageAccounts[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByBlobServices: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + +describe('blobServiceLoggingEnabled', function () { + describe('run', function () { + it('should PASS if Blob Service has logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[0]]); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Storage Account has logging enabled for blob service read, write and delete requests'); + done(); + }); + }); + + it('should Fail if Blob Service does not have logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[1]]); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.equal('Storage Account does not have logging enabled for blob service. Missing Logs StorageRead,StorageWrite,StorageDelete'); + done(); + }); + }); + + it('should PASS if no storage account found', function (done) { + const cache = createCache([], []); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('No storage accounts found'); + + done(); + }); + }); + + it('should PASS if storage account tier is premium', function (done) { + const cache = createCache([storageAccounts[1]], []); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account tier is premium'); + + done(); + }); + }); + + it('should PASS if storage account kind in not StorageV2', function (done) { + const cache = createCache([storageAccounts[2]], []); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account kind is not StorageV2'); + + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for storage accounts', function (done) { + const cache = createErrorCache('diagnostic'); + blobServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Storage Account diagnostics settings: Unable to obtain data'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/blobSoftDeletionEnabled.js b/plugins/azure/storageaccounts/blobSoftDeletionEnabled.js new file mode 100644 index 000000000..2da4a80b1 --- /dev/null +++ b/plugins/azure/storageaccounts/blobSoftDeletionEnabled.js @@ -0,0 +1,87 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Blobs Soft Deletion Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that soft delete feature is enabled for all Microsoft Storage Account blobs.', + more_info: 'When soft delete for blobs is enabled for a storage account, blobs, blob versions, and snapshots in that storage account may be recovered after they are deleted, within a retention period that you specify.', + recommended_action: 'Enable soft delete for blobs and set deletion retention policy to keep blobs for more than desired number of days', + link: 'https://learn.microsoft.com/en-us/azure/storage/blobs/soft-delete-blob-overview', + apis: ['storageAccounts:list', 'blobServices:getServiceProperties'], + settings: { + keep_deleted_blobs_for_days: { + name: 'Keep Deleted Blobs for Days', + description: 'Number of days that a blob is marked for deletion persists until it is permanently deleted', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:blobservices:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + keepForDays: parseInt(settings.keep_deleted_blobs_for_days || this.settings.keep_deleted_blobs_for_days.default) + }; + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + const getServiceProperties = helpers.addSource(cache, source, + ['blobServices', 'getServiceProperties', location, storageAccount.id]); + + if (!getServiceProperties || getServiceProperties.err || !getServiceProperties.data) { + helpers.addResult(results, 3, + `Unable to get blob service properties: ${helpers.addError(getServiceProperties)}`, + location, storageAccount.id); + } else { + if (getServiceProperties.data.deleteRetentionPolicy && + getServiceProperties.data.deleteRetentionPolicy.enabled && + getServiceProperties.data.deleteRetentionPolicy.days) { + const retentionDays = getServiceProperties.data.deleteRetentionPolicy.days; + + if (retentionDays >= config.keepForDays) { + helpers.addResult(results, 0, + `Blobs deletion policy is configured to persist deleted blobs for ${retentionDays} of ${config.keepForDays} days desired limit`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `Blobs deletion policy is configured to persist deleted blobs for ${retentionDays} of ${config.keepForDays} days desired limit`, + location, storageAccount.id); + } + } else { + helpers.addResult(results, 2, + 'Blobs soft delete feature is not enabled for Storage Account', + location, storageAccount.id); + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/blobSoftDeletionEnabled.spec.js b/plugins/azure/storageaccounts/blobSoftDeletionEnabled.spec.js new file mode 100644 index 000000000..09473aaf3 --- /dev/null +++ b/plugins/azure/storageaccounts/blobSoftDeletionEnabled.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var blobSoftDeletionEnabled = require('./blobSoftDeletionEnabled'); + +const storageAccounts = [ + { + "id": "/subscriptions/123/resourceGroups/test-group/providers/Microsoft.Storage/storageAccounts/test-account" + } +]; + +const getServiceProperties = [ + { + "sku": { "name": "Standard_LRS", "tier": "Standard" }, + "id": "/subscriptions/123/resourceGroups/test-group/providers/Microsoft.Storage/storageAccounts/test-account/blobServices/default", + "name": "default", + "type": "Microsoft.Storage/storageAccounts/blobServices", + "cors": { "corsRules": [] }, + "deleteRetentionPolicy": { "enabled": true, "days": 30 } + }, + { + "sku": { "name": "Standard_LRS", "tier": "Standard" }, + "id": "/subscriptions/123/resourceGroups/test-group/providers/Microsoft.Storage/storageAccounts/csb100320011cd09016/blobServices/default", + "name": "default", + "type": "Microsoft.Storage/storageAccounts/blobServices", + "cors": { "corsRules": [] }, + "deleteRetentionPolicy": { "enabled": false } + } +]; + + +const createCache = (list, err, getProperties, geterr) => { + const id = (list && list.length) ? list[0].id : null; + return { + storageAccounts: { + list: { + 'eastus': { + err: err, + data: list + } + } + }, + blobServices: { + getServiceProperties: { + 'eastus': { + [id]: { + err: geterr, + data: getProperties + } + } + } + } + } +}; + +describe('blobSoftDeletionEnabled', function() { + describe('run', function() { + it('should give passing result if no Storage Accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + blobSoftDeletionEnabled.run(cache, {}, callback); + }) + + it('should give failing result if Blobs soft delete feature is not enabled for Storage Account', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blobs soft delete feature is not enabled for Storage Account'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + storageAccounts, + null, + getServiceProperties[1] + ); + + blobSoftDeletionEnabled.run(cache, {}, callback); + }); + + it('should give failing result if Blobs deletion policy is configured to persist deleted blobs for less days than desired limit', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Blobs deletion policy is configured to persist deleted blobs'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + storageAccounts, + null, + getServiceProperties[0] + ); + + blobSoftDeletionEnabled.run(cache, { keep_deleted_blobs_for_days: '50' }, callback); + }); + + it('should give passing result if Blobs deletion policy is configured to persist deleted blobs for more days than desired limit', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Blobs deletion policy is configured to persist deleted blobs'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + storageAccounts, + null, + getServiceProperties[0] + ); + + blobSoftDeletionEnabled.run(cache, { keep_deleted_blobs_for_days: '20' }, callback); + }); + + it('should give unknown result if unable to query for Storage Accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + storageAccounts, + { message: "Unable to list Storage Accounts" }, + ); + + blobSoftDeletionEnabled.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/storageaccounts/fileServiceEncryption.js b/plugins/azure/storageaccounts/fileServiceEncryption.js index b40985e55..8fa71fabe 100644 --- a/plugins/azure/storageaccounts/fileServiceEncryption.js +++ b/plugins/azure/storageaccounts/fileServiceEncryption.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'File Service Encryption', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures data encryption is enabled for File Services', more_info: 'File Service encryption protects your data at rest. Azure Storage encrypts your data and automatically decrypts it for you as you access it.', recommended_action: 'Ensure that data encryption is enabled for each File Service.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-service-encryption', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption', apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/storageaccounts/fileServiceEncryption.spec.js b/plugins/azure/storageaccounts/fileServiceEncryption.spec.js new file mode 100644 index 000000000..7f3fab34c --- /dev/null +++ b/plugins/azure/storageaccounts/fileServiceEncryption.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var fileServiceIEncryption = require('./fileServiceEncryption'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'services': { + 'file': { + 'enabled': true + } + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'services': { + 'blob': { + 'enabled': false + } + } + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('fileServiceIEncryption', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + fileServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + fileServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if file encryption is enabled', function(done) { + const cache = createCache([storageAccounts[0]]); + fileServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Encryption is enabled on the File Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if file encryption is not enabled', function(done) { + const cache = createCache([storageAccounts[1]]); + fileServiceIEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Encryption is disabled on the File Service'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/infrastructureEncryption.js b/plugins/azure/storageaccounts/infrastructureEncryption.js new file mode 100644 index 000000000..0785fe96c --- /dev/null +++ b/plugins/azure/storageaccounts/infrastructureEncryption.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Infrastructure Encryption Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', + description: 'Ensure that Azure Storage Accounts have infrastructure encryption enabled.', + more_info: 'Azure Storage automatically encrypts all data in a storage account at the service level using 256-bit AES encryption. But customers who require higher levels of assurance that their data is secure can also enable 256-bit AES encryption at the Azure Storage infrastructure level for double encryption. Double encryption of Azure Storage data protects against a scenario where one of the encryption algorithms or keys may be compromised. In this scenario, the additional layer of encryption continues to protect your data.', + recommended_action: 'Delete storage account and create new storage account with infrastructure encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/infrastructure-encryption-enable', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccount = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccount) return rcb(); + + if (storageAccount.err || !storageAccount.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccount), location); + return rcb(); + } + + if (!storageAccount.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let account of storageAccount.data) { + if (!account.id) continue; + + if (account.encryption && account.encryption.requireInfrastructureEncryption){ + helpers.addResult(results, 0, 'Storage Account has infrastructure encryption enabled', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage Account does not have infrastructure encryption enabled', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/infrastructureEncryption.spec.js b/plugins/azure/storageaccounts/infrastructureEncryption.spec.js new file mode 100644 index 000000000..bb228b800 --- /dev/null +++ b/plugins/azure/storageaccounts/infrastructureEncryption.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var infrastructureEncryptionEnabled = require('./infrastructureEncryption'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': { 'key': 'value' }, + 'encryption': { + "requireInfrastructureEncryption": true, + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {}, + 'encryption': { + "services": {} + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('infrastructureEncryptionEnabled', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + infrastructureEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + infrastructureEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if infrastructure encryption enabled', function(done) { + const cache = createCache([storageAccounts[0]]); + infrastructureEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account has infrastructure encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if infrastructure encryption not enabled', function(done) { + const cache = createCache([storageAccounts[1]]); + infrastructureEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account does not have infrastructure encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/logContainerPublicAccess.js b/plugins/azure/storageaccounts/logContainerPublicAccess.js index 6f93542b9..258c22229 100644 --- a/plugins/azure/storageaccounts/logContainerPublicAccess.js +++ b/plugins/azure/storageaccounts/logContainerPublicAccess.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Log Container Public Access', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures that the Activity Log Container does not have public read access', more_info: 'The container used to store Activity Log data should not be exposed publicly to avoid data exposure of sensitive activity logs.', recommended_action: 'Ensure the access level for the storage account containing Activity Log data is set to private.', - link: 'https://docs.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources', + link: 'https://learn.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources', apis: ['storageAccounts:list', 'blobContainers:list', 'diagnosticSettingsOperations:list'], compliance: { hipaa: 'HIPAA requires that all systems used for storing ' + @@ -15,6 +17,7 @@ module.exports = { 'default, along with keeping all data private ' + 'and secure.' }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], run: function(cache, settings, callback) { const results = []; @@ -25,7 +28,9 @@ module.exports = { var diagnosticSettingsOperations = helpers.addSource(cache, source, ['diagnosticSettingsOperations', 'list', 'global']); - if (!diagnosticSettingsOperations || diagnosticSettingsOperations.err || !diagnosticSettingsOperations.data) { + if (!diagnosticSettingsOperations) return callback(null, results, source); + + if (diagnosticSettingsOperations.err || !diagnosticSettingsOperations.data) { helpers.addResult(results, 3, 'Unable to query for diagnostic settings: ' + helpers.addError(diagnosticSettingsOperations), 'global'); return callback(null, results, source); diff --git a/plugins/azure/storageaccounts/logContainerPublicAccess.spec.js b/plugins/azure/storageaccounts/logContainerPublicAccess.spec.js new file mode 100644 index 000000000..b99ac62ae --- /dev/null +++ b/plugins/azure/storageaccounts/logContainerPublicAccess.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +var logContainerPublicAccess = require('./logContainerPublicAccess'); + +const diagnosticSettings = [ + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test_log_1', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test_log_1', + 'location': 'global', + 'storageAccountId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage' + } +]; + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage' + } +]; + +const blobContainers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage/blobServices/default/containers/insights-operational-logs', + 'name': 'insights-operational-logs', + 'publicAccess': 'None' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage/blobServices/default/containers/insights-operational-logs', + 'name': 'insights-operational-logs', + 'publicAccess': 'Container' + } +]; + +const createCache = (diagnosticSettingsOperations, storageAccounts, blobContainers) => { + let containers = {}; + if (storageAccounts.length > 0) { + containers[storageAccounts[0].id] = { + data: blobContainers + }; + } + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: diagnosticSettingsOperations + } + } + }, + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + blobContainers: { + list: { + 'eastus': containers + } + } + }; +}; + +const createDiagnostingSettingsErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': {} + } + } + }; +}; + +const createStorageAccountsErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: [diagnosticSettings[0]] + } + } + }, + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +const createBlobContainersErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: [diagnosticSettings[0]] + } + } + }, + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobContainers: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('logContainerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no diagnostic settings found', function(done) { + const cache = createCache([], [], []); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No diagnostic settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createDiagnostingSettingsErrorCache(); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for diagnostic settings:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no storage accounts found', function(done) { + const cache = createCache([diagnosticSettings[0]], [], []); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createStorageAccountsErrorCache(); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no blob containers found', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[0]], []); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing storage containers found'); + + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No existing Storage Containers found for insight logs'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for blob containers', function(done) { + const cache = createBlobContainersErrorCache(); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage containers:'); + + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No existing Storage Containers found for insight logs'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage container storing the activity logs is not publicly accessible', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[0]], [blobContainers[0]]); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage container storing the activity logs is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage container storing the activity logs is publicly accessible', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[0]], [blobContainers[1]]); + logContainerPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage container storing the activity logs is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/logStorageEncryption.js b/plugins/azure/storageaccounts/logStorageEncryption.js index c107be53d..a0838daaf 100644 --- a/plugins/azure/storageaccounts/logStorageEncryption.js +++ b/plugins/azure/storageaccounts/logStorageEncryption.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Log Storage Encryption', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures BYOK encryption is properly configured in the Activity Log Storage Account', more_info: 'Storage accounts can be configured to encrypt data-at-rest. By default Azure will create a set of keys to encrypt the storage account, but the recommended approach is to create your own keys using Azure Key Vault.', recommended_action: 'Ensure the Storage Account used by Activity Logs is configured with a BYOK key.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-service-encryption-customer-managed-keys', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption-customer-managed-keys', apis: ['storageAccounts:list', 'blobContainers:list', 'diagnosticSettingsOperations:list'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + 'Enabling encryption of log storage data helps to protect this data.', }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftinsights:diagnosticsettings:write', 'microsoftinsights:diagnosticsettings:delete'], run: function(cache, settings, callback) { const results = []; @@ -23,7 +26,9 @@ module.exports = { var diagnosticSettingsOperations = helpers.addSource(cache, source, ['diagnosticSettingsOperations', 'list', 'global']); - if (!diagnosticSettingsOperations || diagnosticSettingsOperations.err || !diagnosticSettingsOperations.data) { + if (!diagnosticSettingsOperations) return callback(null, results, source); + + if (diagnosticSettingsOperations.err || !diagnosticSettingsOperations.data) { helpers.addResult(results, 3, 'Unable to query for diagnostic settings: ' + helpers.addError(diagnosticSettingsOperations), 'global'); return callback(null, results, source); diff --git a/plugins/azure/storageaccounts/logStorageEncryption.spec.js b/plugins/azure/storageaccounts/logStorageEncryption.spec.js new file mode 100644 index 000000000..4b6e5cc5c --- /dev/null +++ b/plugins/azure/storageaccounts/logStorageEncryption.spec.js @@ -0,0 +1,214 @@ +var expect = require('chai').expect; +var logStorageEncryption = require('./logStorageEncryption'); + +const diagnosticSettings = [ + { + 'id': 'subscriptions/123/providers/microsoft.insights/diagnosticSettings/test_log_1', + 'type': 'Microsoft.Insights/diagnosticSettings', + 'name': 'test_log_1', + 'location': 'global' + } +]; + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'encryption': { + 'keySource': 'Microsoft.Keyvault' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'encryption': { + 'keySource': 'Microsoft.Storage' + } + } +]; + +const blobContainers = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage/blobServices/default/containers/insights-operational-logs', + 'name': 'insights-operational-logs', + 'publicAccess': 'None' + } +]; + +const createCache = (diagnosticSettingsOperations, storageAccounts, blobContainers) => { + let containers = {}; + if (storageAccounts.length > 0) { + containers[storageAccounts[0].id] = { + data: blobContainers + }; + } + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: diagnosticSettingsOperations + } + } + }, + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + blobContainers: { + list: { + 'eastus': containers + } + } + }; +}; + +const createDiagnostingSettingsErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': {} + } + } + }; +}; + +const createStorageAccountsErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: [diagnosticSettings[0]] + } + } + }, + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +const createBlobContainersErrorCache = () => { + return { + diagnosticSettingsOperations: { + list: { + 'global': { + data: [diagnosticSettings[0]] + } + } + }, + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + blobContainers: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('logStorageEncryption', function() { + describe('run', function() { + it('should give passing result if no diagnostic settings found', function(done) { + const cache = createCache([], [], []); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No diagnostic settings found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createDiagnostingSettingsErrorCache(); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for diagnostic settings:'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no storage accounts found', function(done) { + const cache = createCache([diagnosticSettings[0]], [], []); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createStorageAccountsErrorCache(); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for blob containers', function(done) { + const cache = createBlobContainersErrorCache(); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage containers:'); + + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No existing Storage Containers found for insight logs'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no existing Storage Containers found for insight logs', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[0]], []); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Storage Containers found for insight logs'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if activity logs container for the storage account is encrypted with BYOK', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[0]], [blobContainers[0]]); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Activity Logs container for the storage account is encrypted with BYOK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if activity logs container for the storage account is not encrypted with BYOK', function(done) { + const cache = createCache([diagnosticSettings[0]], [storageAccounts[1]], [blobContainers[0]]); + logStorageEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Activity Logs container for the storage account is not encrypted with BYOK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/networkAccessDefaultAction.js b/plugins/azure/storageaccounts/networkAccessDefaultAction.js index 3a6f11c87..522ff84ce 100644 --- a/plugins/azure/storageaccounts/networkAccessDefaultAction.js +++ b/plugins/azure/storageaccounts/networkAccessDefaultAction.js @@ -4,10 +4,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Network Access Default Action', category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', description: 'Ensures that Storage Account access is restricted to trusted networks', more_info: 'Storage Accounts should be configured to accept traffic only from trusted networks. By default, all networks are selected but can be changed when creating a new storage account or in the firewall settings.', recommended_action: 'Configure the firewall of each Storage Account to allow access only from known virtual networks.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security', apis: ['storageAccounts:list'], compliance: { pci: 'PCI requires data access to be configured to use a firewall. Removing the ' + @@ -16,8 +18,28 @@ module.exports = { 'Preventing default storage account access behavior enables a more granular level ' + 'of access controls.' }, + settings: { + storage_account_encryption_allow_pattern: { + name: 'Storage Accounts Encryption Allow Pattern', + description: 'When set, whitelists storage accounts matching the given pattern. Useful for overriding storage accounts that require default encryption.', + regex: '^.{1,255}$', + default: '^khulnasoftacct([a-f0-9]){16}$' + } + }, + remediation_min_version: '202201032300', + remediation_description: 'Default network action will be set to deny all traffic for affected storage accounts', + apis_remediate: ['storageAccounts:list'], + actions: {remediate:['storageAccounts:update'], rollback:['storageAccounts:update']}, + permissions: {remediate: ['storageAccounts:update'], rollback: ['storageAccounts:update']}, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { + var config = { + storage_account_encryption_allow_pattern: settings.storage_account_encryption_allow_pattern || this.settings.storage_account_encryption_allow_pattern.default + }; + + var custom = helpers.isCustom(settings, this.settings); + const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); @@ -26,7 +48,7 @@ module.exports = { const storageAccount = helpers.addSource(cache, source, ['storageAccounts', 'list', location]); - if(!storageAccount) return rcb(); + if (!storageAccount) return rcb(); if (storageAccount.err || !storageAccount.data) { helpers.addResult(results, 3, @@ -40,21 +62,30 @@ module.exports = { return rcb(); } + var allowRegex = (config.storage_account_encryption_allow_pattern && + config.storage_account_encryption_allow_pattern.length) ? new RegExp(config.storage_account_encryption_allow_pattern) : false; + for (var acct in storageAccount.data) { const account = storageAccount.data[acct]; // Different versions of the Azure API return different response // formats for this property, hence the extra check. - if (account.networkRuleSet && - account.networkRuleSet.defaultAction && - account.networkRuleSet.defaultAction.toLowerCase() === 'deny') { - helpers.addResult(results, 0, 'Storage Account default network access rule set to deny', location, account.id); - } else if (account.networkAcls && - account.networkAcls.defaultAction && - account.networkAcls.defaultAction.toLowerCase() === 'deny') { - helpers.addResult(results, 0, 'Storage Account default network access rule set to deny', location, account.id); + if (allowRegex && allowRegex.test(account.name)) { + helpers.addResult(results, 0, + 'Storage account: ' + account.name + ' is whitelisted via custom setting.', + location, account.id, custom); } else { - helpers.addResult(results, 2, 'Storage Account default network access rule set to allow from all networks', location, account.id); + if (account.networkRuleSet && + account.networkRuleSet.defaultAction && + account.networkRuleSet.defaultAction.toLowerCase() === 'deny') { + helpers.addResult(results, 0, 'Storage Account default network access rule set to deny', location, account.id); + } else if (account.networkAcls && + account.networkAcls.defaultAction && + account.networkAcls.defaultAction.toLowerCase() === 'deny') { + helpers.addResult(results, 0, 'Storage Account default network access rule set to deny', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage Account default network access rule set to allow from all networks', location, account.id); + } } } rcb(); @@ -62,5 +93,51 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'networkAccessDefaultAction'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2021-04-01'; + var method = 'PATCH'; + + // for logging purposes + var saNameArr = resource.split('/'); + var saName = saNameArr[saNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'networkAcls': { + 'defaultAction': 'Deny' + } + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'DefaultNetworkAction': 'Allow', + 'StorageAccount': saName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Deny' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } } }; diff --git a/plugins/azure/storageaccounts/networkAccessDefaultAction.spec.js b/plugins/azure/storageaccounts/networkAccessDefaultAction.spec.js new file mode 100644 index 000000000..9f8781d12 --- /dev/null +++ b/plugins/azure/storageaccounts/networkAccessDefaultAction.spec.js @@ -0,0 +1,119 @@ +var expect = require('chai').expect; +var networkAccessDefaultAction = require('./networkAccessDefaultAction'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/khulnasoftacct945c8a10894266bf', + 'name': 'khulnasoftacct945c8a10894266bf', + 'networkAcls': { + 'resourceAccessRules': [], + 'bypass': 'AzureServices', + 'virtualNetworkRules': [], + 'ipRules': [], + 'defaultAction': 'Allow' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'networkAcls': { + 'resourceAccessRules': [], + 'bypass': 'AzureServices', + 'virtualNetworkRules': [], + 'ipRules': [], + 'defaultAction': 'Deny' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'networkAcls': { + 'resourceAccessRules': [], + 'bypass': 'AzureServices', + 'virtualNetworkRules': [], + 'ipRules': [], + 'defaultAction': 'Allow' + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createStorageAccountsErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('networkAccessDefaultAction', function() { + describe('run', function() { + it('should give passing result if no storage accounts found', function(done) { + const cache = createCache([]); + networkAccessDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createStorageAccountsErrorCache(); + networkAccessDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage Account is whitelisted via custom settings', function(done) { + const cache = createCache([storageAccounts[0]]); + networkAccessDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account: ' + storageAccounts[0].name + ' is whitelisted via custom setting.'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage Account default network access rule set to deny in networkAcls', function(done) { + const cache = createCache([storageAccounts[1]]); + networkAccessDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account default network access rule set to deny'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage Account default network access rule set to allow from all networks', function(done) { + const cache = createCache([storageAccounts[2]]); + networkAccessDefaultAction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account default network access rule set to allow from all networks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/queueServiceLoggingEnabled.js b/plugins/azure/storageaccounts/queueServiceLoggingEnabled.js new file mode 100644 index 000000000..720a20f9b --- /dev/null +++ b/plugins/azure/storageaccounts/queueServiceLoggingEnabled.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Queue Service Logging Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Storage Queue service logging is enabled for "Read", "Write", and "Delete" requests.', + more_info: 'Azure Storage Queue logs contain detailed information about successful and failed requests made to your storage queues for read, write and delete operations. This information can be used to monitor individual requests and to diagnose issues with the Storage Queue service within your Microsoft Azure account.', + recommended_action: 'Modify Queue Service and enable storage logging for "Read", "Write", and "Delete" requests.', + link: 'https://learn.microsoft.com/en-us/azure/storage/queues/monitor-queue-storage?tabs=azure-portal', + apis: ['storageAccounts:list', 'storageAccounts:listKeys','diagnosticSettings:listByQueueServices'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let storageAccount of storageAccounts.data) { + + if (!storageAccount.id) continue; + + if (storageAccount.sku && + storageAccount.sku.tier && + storageAccount.sku.tier.toLowerCase() == 'premium') { + helpers.addResult(results, 0, 'Storage Account tier is premium', location, storageAccount.id); + } else if (storageAccount.kind && + storageAccount.kind.toLowerCase() != 'storagev2') { + helpers.addResult(results, 0, 'Storage Account kind is not StorageV2', location, storageAccount.id); + } else { + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByQueueServices', location, storageAccount.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Storage Account diagnostics settings: ' + helpers.addError(diagnosticSettings), location, storageAccount.id); + } else { + //First consider that all the logs are missing then remove the ones that are present + var missingLogs = ['StorageRead', 'StorageWrite','StorageDelete']; + + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + if (missingLogs.length) { + helpers.addResult(results, 2, `Storage Account does not have logging enabled for queue service. Missing Logs ${missingLogs}`, location, storageAccount.id); + } else { + helpers.addResult(results, 0, 'Storage Account has logging enabled for queue service read, write and delete requests', location, storageAccount.id); + } + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/queueServiceLoggingEnabled.spec.js b/plugins/azure/storageaccounts/queueServiceLoggingEnabled.spec.js new file mode 100644 index 000000000..d83e2cd77 --- /dev/null +++ b/plugins/azure/storageaccounts/queueServiceLoggingEnabled.spec.js @@ -0,0 +1,251 @@ +var expect = require('chai').expect; +var queueServiceLoggingEnabled = require('./queueServiceLoggingEnabled'); + +const storageAccounts = [ + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + privateEndpointConnections: [], + minimumTlsVersion: 'TLS1_2', + allowBlobPublicAccess: false, + supportsHttpsTrafficOnly: true, + accessTier: 'Hot', + provisioningState: 'Succeeded', + creationTime: '2021-03-09T16:54:18.2838672Z', + primaryEndpoints: [Object], + primaryLocation: 'eastus', + statusOfPrimary: 'available' + }, + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Premium' + } + }, + { + kind: 'BlobStorage', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Standard' + } + } +]; + +const diagnosticSettings = [ + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/queueservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + { + category: "StorageRead", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageWrite", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageDelete", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + ], + logAnalyticsDestinationType: null, + }, + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/queueservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + ], + logAnalyticsDestinationType: null, + }, +]; +const createCache = (storageAccounts, diagnosticSettings) => { + let diagnostic = {}; + if (storageAccounts.length) { + diagnostic[storageAccounts[0].id] = { + data: diagnosticSettings + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + diagnosticSettings: { + listByQueueServices: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccounts') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noStorageAccount'){ + return { + storageAccounts: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + diagnosticSettings: { + diagnosticSettings: { + 'eastus': {} + } + } + }; + } else { + const appId = (storageAccounts && storageAccounts.length) ? storageAccounts[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByQueueServices: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + + +describe('queueServiceLoggingEnabled', function () { + describe('run', function () { + it('should PASS if Queue Service has logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[0]]); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Storage Account has logging enabled for queue service read, write and delete requests'); + done(); + }); + }); + + it('should Fail if Queue Service does not have logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[1]]); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account does not have logging enabled for queue service. Missing Logs StorageRead,StorageWrite,StorageDelete'); + done(); + }); + }); + + it('should PASS if no storage account found', function (done) { + const cache = createCache([], []); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('No storage accounts found'); + + done(); + }); + }); + + it('should PASS if storage account tier is premium', function (done) { + const cache = createCache([storageAccounts[1]], []); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account tier is premium'); + + done(); + }); + }); + + it('should PASS if storage account kind in not StorageV2', function (done) { + const cache = createCache([storageAccounts[2]], []); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account kind is not StorageV2'); + + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for storage accounts', function (done) { + const cache = createErrorCache('storageAccounts'); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts:'); + done(); + }); + }); + + + it('should UNKNOWN if Unable to query for for Queue Service', function (done) { + const cache = createErrorCache('diagnostic'); + queueServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Storage Account diagnostics settings: Unable to obtain data'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/storageAccountHasTags.js b/plugins/azure/storageaccounts/storageAccountHasTags.js new file mode 100644 index 000000000..bbd4ff246 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Storage Account Has Tags', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that Azure Storage accounts have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify storage account and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccount = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccount) return rcb(); + + if (storageAccount.err || !storageAccount.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccount), location); + return rcb(); + } + + if (!storageAccount.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + for (let account of storageAccount.data) { + + if (!account.id) continue; + + if (account.tags && Object.entries(account.tags).length > 0){ + helpers.addResult(results, 0, 'Storage Account has tags associated', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage Account does not have tags associated', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountHasTags.spec.js b/plugins/azure/storageaccounts/storageAccountHasTags.spec.js new file mode 100644 index 000000000..963faaf89 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountHasTags.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var storageAccountHasTags = require('./storageAccountHasTags'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': { 'key': 'value' } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {} + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountHasTags', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + storageAccountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + storageAccountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if trusted MS access is enabled', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if trusted MS access is not enabled', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js new file mode 100644 index 000000000..1150e02c5 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Storage Account Private Endpoints', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that Azure Storage accounts are accessible only through private endpoints or have restricted public access.', + more_info: 'Azure Private Endpoint is a network interface that connects you privately and securely to a service powered by Azure Private Link. Private Endpoint uses a private IP address from your VNet, effectively bringing the service such as Azure Storage Accounts into your VNet. If private endpoints are not configured, ensure that public access is restricted to specific IP addresses or virtual networks.', + recommended_action: 'Modify storage accounts and configure private endpoints or restrict public access to specific networks.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftnetwork:privateendpoints:write', 'microsoftstorage:storageaccounts:privateendpointconnections:write'], + settings: { + check_selected_networks: { + name: 'Evaluate Selected Networks', + description: 'Checks if specific IP addresses or virtual networks are set to restrict Storage Account access when private endpoints are not configured.', + regex: '^(true|false)$', + default: false, + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + let config = { + check_selected_networks: settings.check_selected_networks || this.settings.check_selected_networks.default + }; + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccount = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccount) return rcb(); + + if (storageAccount.err || !storageAccount.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccount), location); + return rcb(); + } + + if (!storageAccount.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let account of storageAccount.data) { + if (!account.id) continue; + + if (account.privateEndpointConnections && account.privateEndpointConnections.length){ + helpers.addResult(results, 0, 'Private endpoints are configured for the storage account', location, account.id); + } else { + // Check public network access when private endpoints are not configured + let isPublicAccessEnabled = (account.publicNetworkAccess && account.publicNetworkAccess.toLowerCase() === 'enabled') || + (!account.publicNetworkAccess && account.networkAcls && account.networkAcls.defaultAction && account.networkAcls.defaultAction.toLowerCase() === 'allow'); + + if (isPublicAccessEnabled) { + if (config.check_selected_networks) { + let hasNetworkRestrictions = false; + + if (account.networkAcls) { + // Check if default action is deny (meaning public access is restricted) + if (account.networkAcls.defaultAction && account.networkAcls.defaultAction.toLowerCase() === 'deny') { + hasNetworkRestrictions = true; + } + + // Check if there are IP rules or virtual network rules configured + if ((account.networkAcls.ipRules && account.networkAcls.ipRules.length > 0) || + (account.networkAcls.virtualNetworkRules && account.networkAcls.virtualNetworkRules.length > 0)) { + hasNetworkRestrictions = true; + } + } + + if (hasNetworkRestrictions) { + helpers.addResult(results, 0, 'Storage account is not publicly accessible', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage account is publicly accessible', location, account.id); + } + } else { + helpers.addResult(results, 2, 'Storage account is publicly accessible', location, account.id); + } + } else { + helpers.addResult(results, 0, 'Storage account is not publicly accessible', location, account.id); + } + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js new file mode 100644 index 000000000..ae72c29a3 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js @@ -0,0 +1,207 @@ +var expect = require('chai').expect; +var storageAccountPrivateEndpoint = require('./storageAccountPrivateEndpoint'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc1', + 'location': 'eastus', + 'name': 'acc1', + 'tags': { 'key': 'value' }, + "privateEndpointConnections": [ + { + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc1/privateEndpointConnections/test.3d321801-7cb1-4586-afa7-deee7ab88744", + "name": "test.3d321801-7cb1-4586-afa7-deee7ab88744", + "type": "Microsoft.Storage/storageAccounts/privateEndpointConnections", + } + ], + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc2', + 'location': 'eastus', + 'name': 'acc2', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Disabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc3', + 'location': 'eastus', + 'name': 'acc3', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc4', + 'location': 'eastus', + 'name': 'acc4', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "192.168.1.0/24", + "action": "Allow" + } + ], + "virtualNetworkRules": [] + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc5', + 'location': 'eastus', + 'name': 'acc5', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [] + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc6', + 'location': 'eastus', + 'name': 'acc6', + 'tags': {}, + "privateEndpointConnections": [], + // publicNetworkAccess property missing + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [] + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountPrivateEndpoint', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if private endpoint configured', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Private endpoints are configured for the storage account'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is disabled', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled without network restrictions', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is enabled with network restrictions when check_selected_networks is true', function(done) { + const cache = createCache([storageAccounts[3]]); + const settings = { check_selected_networks: true }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled without sufficient network restrictions when check_selected_networks is true', function(done) { + const cache = createCache([storageAccounts[4]]); + const settings = { check_selected_networks: true }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled regardless of network restrictions when check_selected_networks is false', function(done) { + const cache = createCache([storageAccounts[3]]); + const settings = { check_selected_networks: false }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if publicNetworkAccess is missing but networkAcls defaultAction is Allow', function(done) { + const cache = createCache([storageAccounts[5]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js new file mode 100644 index 000000000..83dd52a9e --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Storage Account Public Network Access', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Public Network Access is disabled for storage accounts.', + more_info: 'Disabling public network access for Azure storage accounts enhances security by blocking anonymous access to data in containers and blobs. This restriction ensures that only trusted network sources can access the storage, reducing the risk of unauthorized access and data exposure.', + recommended_action: 'Modify storage accounts and disable Public Network Access.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccount = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccount) return rcb(); + + if (storageAccount.err || !storageAccount.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccount), location); + return rcb(); + } + + if (!storageAccount.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let account of storageAccount.data) { + if (!account.id) continue; + + if (account.publicNetworkAccess && (account.publicNetworkAccess.toLowerCase() == 'disabled' || account.publicNetworkAccess.toLowerCase() == 'securedbyperimeter')){ + helpers.addResult(results, 0, 'Storage account has public network access disabled', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage account does not have public network access disabled', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js new file mode 100644 index 000000000..13403dca6 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var storageAccountPublicNetworkAccess = require('./storageAccountPublicNetworkAccess'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': { 'key': 'value' }, + "publicNetworkAccess": "Disabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {}, + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {}, + "publicNetworkAccess": "SecuredByPerimeter" + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountPublicNetworkAccess', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage account has public network access disabled', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage account does not have public network access disabled', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage account has public network access secured by perimeter', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountsAADEnabled.js b/plugins/azure/storageaccounts/storageAccountsAADEnabled.js index c577bfdeb..37bf4beca 100644 --- a/plugins/azure/storageaccounts/storageAccountsAADEnabled.js +++ b/plugins/azure/storageaccounts/storageAccountsAADEnabled.js @@ -2,12 +2,14 @@ var async = require('async'); var helpers = require('../../../helpers/azure/'); module.exports = { - title: 'Storage Accounts AAD Enabled', + title: 'Storage Accounts Entra ID Enabled', category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', description: 'Ensures that identity-based Directory Service for Azure File Authentication is enabled for all Azure Files', - more_info: 'Enabling identity-based Authentication ensures that only the authorized Active Directory members can access or connect to the file shares, enforcing granular access control.', + more_info: 'Enabling identity-based Authentication ensures that only the authorized Entra ID members can access or connect to the file shares, enforcing granular access control.', recommended_action: 'Ensure that identity-based Directory Service for Azure File Authentication is enabled for all Azure File Shares.', - link: 'https://docs.microsoft.com/en-us/azure/storage/files/storage-files-active-directory-overview', + link: 'https://learn.microsoft.com/en-us/azure/storage/files/storage-files-active-directory-overview', apis: ['storageAccounts:list', 'fileShares:list'], settings: { storage_account_check_file_share: { @@ -15,14 +17,24 @@ module.exports = { description: 'When set to true Plugin will check if storage account has any active file shares', regex: '^(true|false)$', default: 'false' + }, + storage_account_encryption_allow_pattern: { + name: 'Storage Accounts Encryption Allow Pattern', + description: 'When set, whitelists storage accounts matching the given pattern. Useful for overriding storage accounts that require default encryption.', + regex: '^.{1,255}$', + default: '^khulnasoftacct([a-f0-9]){16}$' } }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { var config = { storage_account_check_file_share: settings.storage_account_check_file_share || this.settings.storage_account_check_file_share.default, + storage_account_encryption_allow_pattern: settings.storage_account_encryption_allow_pattern || this.settings.storage_account_encryption_allow_pattern.default }; config.storage_account_check_file_share = (config.storage_account_check_file_share == 'true'); + var custom = helpers.isCustom(settings, this.settings); + var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); @@ -44,26 +56,36 @@ module.exports = { return rcb(); } + var allowRegex = (config.storage_account_encryption_allow_pattern && + config.storage_account_encryption_allow_pattern.length) ? new RegExp(config.storage_account_encryption_allow_pattern) : false; + storageAccounts.data.forEach(function(storageAccount){ - if (storageAccount.enableAzureFilesAadIntegration) { - helpers.addResult(results, 0, 'Storage Account is configured with AAD Authentication', location, storageAccount.id); - } else if (config.storage_account_check_file_share) { - var fileShares = helpers.addSource(cache, source, - ['fileShares', 'list', location, storageAccount.id]); + if (allowRegex && allowRegex.test(storageAccount.name)) { + helpers.addResult(results, 0, + 'Storage account: ' + storageAccount.name + ' is whitelisted via custom setting.', + location, storageAccount.id, custom); + } else { + if (storageAccount.enableAzureFilesAadIntegration) { + helpers.addResult(results, 0, 'Storage Account is configured with Entra ID Authentication', location, storageAccount.id); + } else if (config.storage_account_check_file_share) { + var fileShares = helpers.addSource(cache, source, + ['fileShares', 'list', location, storageAccount.id]); - if (!fileShares || fileShares.err && !fileShares.data) { - helpers.addResult(results, 3, - 'Unable to query for file shares: ' + helpers.addError(fileShares), location, storageAccount.id); - } else { - if (!fileShares.data.length) { - helpers.addResult(results, 0, 'Storage Account is not configured with AAD Authentication but no file shares are present', location, storageAccount.id); + if (!fileShares || fileShares.err && !fileShares.data) { + helpers.addResult(results, 3, + 'Unable to query for file shares: ' + helpers.addError(fileShares), location, storageAccount.id); } else { - helpers.addResult(results, 2, 'Storage Account is not configured with AAD Authentication', location, storageAccount.id); + if (!fileShares.data.length) { + helpers.addResult(results, 0, 'Storage Account is not configured with Entra ID Authentication but no file shares are present', location, storageAccount.id); + } else { + helpers.addResult(results, 2, 'Storage Account is not configured with Entra ID Authentication', location, storageAccount.id); + } } + } else { + helpers.addResult(results, 2, 'Storage Account is not configured with Entra ID Authentication', location, storageAccount.id); } - } else { - helpers.addResult(results, 2, 'Storage Account is not configured with AAD Authentication', location, storageAccount.id); } + }); rcb(); diff --git a/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js b/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js index c916b6aab..3b7447e9e 100644 --- a/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js +++ b/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js @@ -41,11 +41,11 @@ describe('storageAccountsAADEnabled', function() { auth.run(cache, {}, callback); }) - it('should give failing result if storage account is not configured with aad authentication', function(done) { + it('should give failing result if storage account is not configured with Entra ID authentication', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Storage Account is not configured with AAD Authentication'); + expect(results[0].message).to.include('Storage Account is not configured with Entra ID Authentication'); expect(results[0].region).to.equal('eastus'); done() }; @@ -114,11 +114,11 @@ describe('storageAccountsAADEnabled', function() { auth.run(cache, {}, callback); }) - it('should give passing result if storage account is not configured with aad authentication but no file shares', function(done) { + it('should give passing result if storage account is not configured with Entra ID authentication but no file shares', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Storage Account is not configured with AAD Authentication but no file shares are present'); + expect(results[0].message).to.include('Storage Account is not configured with Entra ID Authentication but no file shares are present'); expect(results[0].region).to.equal('eastus'); done() }; @@ -183,7 +183,7 @@ describe('storageAccountsAADEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('Storage Account is configured with AAD Authentication'); + expect(results[0].message).to.include('Storage Account is configured with Entra ID Authentication'); expect(results[0].region).to.equal('eastus') done() }; @@ -192,8 +192,8 @@ describe('storageAccountsAADEnabled', function() { null, [ { - "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/devstoragecloudsploit", - "name": "devstoragecloudsploit", + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/devresourcegroup/providers/Microsoft.Storage/storageAccounts/devstoragecloudexploit", + "name": "devstoragecloudexploit", "type": "Microsoft.Storage/storageAccounts", "tags": { "environment": "dev" @@ -211,12 +211,12 @@ describe('storageAccountsAADEnabled', function() { }, "provisioningState": "Succeeded", "primaryEndpoints": { - "blob": "https://devstoragecloudsploit.blob.core.windows.net/", - "queue": "https://devstoragecloudsploit.queue.core.windows.net/", - "table": "https://devstoragecloudsploit.table.core.windows.net/", - "file": "https://devstoragecloudsploit.file.core.windows.net/", - "web": "https://devstoragecloudsploit.z13.web.core.windows.net/", - "dfs": "https://devstoragecloudsploit.dfs.core.windows.net/" + "blob": "https://devstoragecloudexploit.blob.core.windows.net/", + "queue": "https://devstoragecloudexploit.queue.core.windows.net/", + "table": "https://devstoragecloudexploit.table.core.windows.net/", + "file": "https://devstoragecloudexploit.file.core.windows.net/", + "web": "https://devstoragecloudexploit.z13.web.core.windows.net/", + "dfs": "https://devstoragecloudexploit.dfs.core.windows.net/" }, "primaryLocation": "eastus", "statusOfPrimary": "available", @@ -224,11 +224,11 @@ describe('storageAccountsAADEnabled', function() { "statusOfSecondary": "available", "creationTime": "2019-01-29T05:29:35.757Z", "secondaryEndpoints": { - "blob": "https://devstoragecloudsploit-secondary.blob.core.windows.net/", - "queue": "https://devstoragecloudsploit-secondary.queue.core.windows.net/", - "table": "https://devstoragecloudsploit-secondary.table.core.windows.net/", - "web": "https://devstoragecloudsploit-secondary.z13.web.core.windows.net/", - "dfs": "https://devstoragecloudsploit-secondary.dfs.core.windows.net/" + "blob": "https://devstoragecloudexploit-secondary.blob.core.windows.net/", + "queue": "https://devstoragecloudexploit-secondary.queue.core.windows.net/", + "table": "https://devstoragecloudexploit-secondary.table.core.windows.net/", + "web": "https://devstoragecloudexploit-secondary.z13.web.core.windows.net/", + "dfs": "https://devstoragecloudexploit-secondary.dfs.core.windows.net/" }, "encryption": { "services": { @@ -264,10 +264,10 @@ describe('storageAccountsAADEnabled', function() { "entries": [], "continuationToken": null, "error": false, - "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/test1/providers/Microsoft.Storage/storageAccounts/devstoragecloudsploit", + "id": "/subscriptions/ade0e01e-f9cd-49d3-bba7-d5a5362a3414/resourceGroups/test1/providers/Microsoft.Storage/storageAccounts/devstoragecloudexploit", "location": "eastus", "storageAccount": { - "name": "devstoragecloudsploit" + "name": "devstoragecloudexploit" } } ] diff --git a/plugins/azure/storageaccounts/storageAccountsEncryption.js b/plugins/azure/storageaccounts/storageAccountsEncryption.js index ce09be867..1a37b9efe 100644 --- a/plugins/azure/storageaccounts/storageAccountsEncryption.js +++ b/plugins/azure/storageaccounts/storageAccountsEncryption.js @@ -4,10 +4,12 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Storage Accounts Encryption', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures encryption is enabled for Storage Accounts', more_info: 'Storage accounts can be configured to encrypt data-at-rest. By default Azure will create a set of keys to encrypt the storage account, but the recommended approach is to create your own keys using Azure Key Vault.', recommended_action: 'Ensure all Storage Accounts are configured with a BYOK key.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-service-encryption-customer-managed-keys', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption-customer-managed-keys', apis: ['storageAccounts:list'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + @@ -16,8 +18,23 @@ module.exports = { 'Encryption should be enabled for all storage accounts storing this ' + 'type of data.' }, + settings: { + storage_account_encryption_allow_pattern: { + name: 'Storage Accounts Encryption Allow Pattern', + description: 'When set, whitelists storage accounts matching the given pattern. Useful for overriding storage accounts that require default encryption.', + regex: '^.{1,255}$', + default: '^khulnasoftacct([a-f0-9]){16}$' + } + }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { + var config = { + storage_account_encryption_allow_pattern: settings.storage_account_encryption_allow_pattern || this.settings.storage_account_encryption_allow_pattern.default + }; + + var custom = helpers.isCustom(settings, this.settings); + var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); @@ -37,17 +54,27 @@ module.exports = { if (!storageAccount.data.length) { helpers.addResult(results, 0, 'No storage accounts found', location); } else { + + var allowRegex = (config.storage_account_encryption_allow_pattern && + config.storage_account_encryption_allow_pattern.length) ? new RegExp(config.storage_account_encryption_allow_pattern) : false; + for (var acct in storageAccount.data) { var account = storageAccount.data[acct]; - if (account.encryption && account.encryption.keySource && - account.encryption.keySource == 'Microsoft.Keyvault') { - helpers.addResult(results, 0, 'Storage Account encryption is configured with Microsoft Key vault', location, account.id); - } else if (account.encryption && account.encryption.keySource && - account.encryption.keySource == 'Microsoft.Storage') { - helpers.addResult(results, 2, 'Storage Account encryption is configured using Microsoft Default Storage Keys', location, account.id); + if (allowRegex && allowRegex.test(account.name)) { + helpers.addResult(results, 0, + 'Storage account: ' + account.name + ' is whitelisted via custom setting.', + location, account.id, custom); } else { - helpers.addResult(results, 2, 'Storage Account is not configured for data-at-rest encryption', location, account.id); + if (account.encryption && account.encryption.keySource && + account.encryption.keySource == 'Microsoft.Keyvault') { + helpers.addResult(results, 0, 'Storage Account encryption is configured with Microsoft Key vault', location, account.id); + } else if (account.encryption && account.encryption.keySource && + account.encryption.keySource == 'Microsoft.Storage') { + helpers.addResult(results, 2, 'Storage Account encryption is configured using Microsoft Default Storage Keys', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage Account is not configured for data-at-rest encryption', location, account.id); + } } } } diff --git a/plugins/azure/storageaccounts/storageAccountsEncryption.spec.js b/plugins/azure/storageaccounts/storageAccountsEncryption.spec.js new file mode 100644 index 000000000..c8e9e66fd --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountsEncryption.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var storageAccountsEncryption = require('./storageAccountsEncryption'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'keySource': 'Microsoft.Keyvault' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'encryption': { + 'keySource': 'Microsoft.Storage' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountsEncryption', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + storageAccountsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + storageAccountsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if storage account is whitelisted', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountsEncryption.run(cache, {storage_account_encryption_allow_pattern: 'acc'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account: acc is whitelisted via custom setting.'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result data-at-rest encryption is configured with Microsoft KeyVault', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account encryption is configured with Microsoft Key vault'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if data-at-rest encryption is configured using Microsoft default storage keys', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account encryption is configured using Microsoft Default Storage Keys'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if data-at-rest encryption is not configured', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountsEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account is not configured for data-at-rest encryption'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountsHttps.js b/plugins/azure/storageaccounts/storageAccountsHttps.js index 3ae5acac6..2e64c6db9 100644 --- a/plugins/azure/storageaccounts/storageAccountsHttps.js +++ b/plugins/azure/storageaccounts/storageAccountsHttps.js @@ -4,16 +4,19 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Storage Accounts HTTPS', category: 'Storage Accounts', + domain: 'Storage', + severity: 'High', description: 'Ensures HTTPS-only traffic is allowed to storage account endpoints', more_info: 'Storage Accounts can contain sensitive information and should only be accessed over HTTPS. Enabling the HTTPS-only flag ensures that Azure does not allow HTTP traffic to Storage Accounts.', recommended_action: 'Enable the HTTPS-only option for all Storage Accounts.', - link: 'https://docs.microsoft.com/en-us/azure/governance/policy/samples/ensure-https-storage-account', + link: 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/ensure-https-storage-account', apis: ['storageAccounts:list'], remediation_min_version: '202006260310', remediation_description: 'The HTTPS-only option will be enabled for the storage account', apis_remediate: ['storageAccounts:list'], actions: {remediate:['storageAccounts:update'], rollback:['storageAccounts:update']}, permissions: {remediate: ['storageAccounts:update'], rollback: ['storageAccounts:update']}, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], compliance: { hipaa: 'HIPAA requires all data to be transmitted over secure channels. ' + 'Storage Account HTTPS should be used to ensure all data access ' + @@ -77,7 +80,6 @@ module.exports = { 'properties': { 'supportsHttpsTrafficOnly': true } - }; // logging @@ -87,7 +89,9 @@ module.exports = { }; helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { - action.action = putCall; + if (err) return callback(err); + if (action) action.action = putCall; + remediation_file['post_remediate']['actions'][pluginName][resource] = action; remediation_file['remediate']['actions'][pluginName][resource] = { diff --git a/plugins/azure/storageaccounts/storageAccountsHttps.spec.js b/plugins/azure/storageaccounts/storageAccountsHttps.spec.js new file mode 100644 index 000000000..1c45f8032 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountsHttps.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var storageAccountsHttps = require('./storageAccountsHttps'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'supportsHttpsTrafficOnly': true + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/test-storage', + 'name': 'test-storage', + 'supportsHttpsTrafficOnly': false + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createStorageAccountsErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountsHttps', function() { + describe('run', function() { + it('should give passing result if no storage accounts found', function(done) { + const cache = createCache([]); + storageAccountsHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createStorageAccountsErrorCache(); + storageAccountsHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage Account is configured with HTTPS-only traffic', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountsHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account is configured with HTTPS-only traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage Account is not configured with HTTPS-only traffic', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountsHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account is not configured with HTTPS-only traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountsTlsVersion.js b/plugins/azure/storageaccounts/storageAccountsTlsVersion.js new file mode 100644 index 000000000..1234d1eca --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountsTlsVersion.js @@ -0,0 +1,115 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Accounts Minimum TLS Version', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures Microsoft Azure Storage Accounts are using the latest TLS version 1.2 to enforce stricter security measure.', + more_info: 'Azure Storage accounts permit clients to send and receive data with the oldest version of TLS, TLS 1.0, and above. ' + + 'To enforce stricter security measures, you can configure your storage account to require that clients send and receive data with a newer version of TLS.', + recommended_action: 'Modify Storage Account configuration and set desired minimum TLS version', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version', + apis: ['storageAccounts:list'], + remediation_min_version: '202112312200', + remediation_description: 'TLS version 1.2 will be set for the affected Storage Accounts', + apis_remediate: ['storageAccounts:list'], + actions: {remediate:['storageAccounts:update'], rollback:['storageAccounts:update']}, + permissions: {remediate: ['storageAccounts:update'], rollback: ['storageAccounts:update']}, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var sa_min_tls_version = '1.2'; + + var desiredVersion = parseFloat(sa_min_tls_version); + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccounts = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No Storage Accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(function(storageAccount) { + if (!storageAccount.id) return; + + let tlsVersion = storageAccount.minimumTlsVersion ? storageAccount.minimumTlsVersion : 'TLS1.2'; //Default is TLS 1.2 + tlsVersion = tlsVersion.replace('TLS', ''); + tlsVersion = tlsVersion.replace('_', '.'); + + if (parseFloat(tlsVersion) >= desiredVersion) { + helpers.addResult(results, 0, + `Storage Account is using TLS version ${tlsVersion} which is equal to or higher than desired TLS version ${sa_min_tls_version}`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `Storage Account is using TLS version ${tlsVersion} which is less than desired TLS version ${sa_min_tls_version}`, + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + var putCall = this.actions.remediate; + + // inputs specific to the plugin + var pluginName = 'storageAccountsTlsVersion'; + var baseUrl = 'https://management.azure.com/{resource}?api-version=2021-04-01'; + var method = 'PATCH'; + + // for logging purposes + var saNameArr = resource.split('/'); + var saName = saNameArr[saNameArr.length - 1]; + + // create the params necessary for the remediation + if (settings.region) { + var body = { + 'location': settings.region, + 'properties': { + 'minimumTlsVersion': 'TLS1_2' + } + }; + + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'TLS1.2': 'Disabled', + 'StorageAccount': saName + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } else { + callback('No region found'); + } + } +}; \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountsTlsVersion.spec.js b/plugins/azure/storageaccounts/storageAccountsTlsVersion.spec.js new file mode 100644 index 000000000..21c6b2c68 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountsTlsVersion.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var storageAccountsTlsVersion = require('./storageAccountsTlsVersion'); + +const storageAccounts = [ + { + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "kind": "StorageV2", + "id": "/subscriptions/dce7d0ad-jdde-e23c-2edc-28fc0d22117e/resourceGroups/cloud-shell-storage-centralindia/providers/Microsoft.Storage/storageAccounts/testsa", + "name": "csg10032001681f5f9e", + "type": "Microsoft.Storage/storageAccounts", + "location": "centralindia", + "tags": { + "ms-resource-usage": "azure-cloud-shell" + }, + "privateEndpointConnections": [], + "minimumTlsVersion": "TLS1_2", + "allowBlobPublicAccess": false, + "networkAcls": { + "bypass": "AzureServices", + "virtualNetworkRules": [], + "ipRules": [], + "defaultAction": "Allow" + }, + "supportsHttpsTrafficOnly": true, + "encryption": { + "services": { + "file": { + "keyType": "Account", + "enabled": true, + "lastEnabledTime": "2021-08-05T14:20:46.8158854Z" + }, + "blob": { + "keyType": "Account", + "enabled": true, + "lastEnabledTime": "2021-08-05T14:20:46.8158854Z" + } + }, + "keySource": "Microsoft.Storage" + }, + "accessTier": "Hot", + "provisioningState": "Succeeded", + "creationTime": "2021-08-05T14:20:46.7377764Z", + "primaryLocation": "centralindia", + "statusOfPrimary": "available" + }, + { + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "kind": "StorageV2", + "id": "/subscriptions/dce7d0ad-jdde-e23c-2edc-28fc0d22117e/resourceGroups/cloud-shell-storage-centralindia/providers/Microsoft.Storage/storageAccounts/testsa", + "name": "csg10032001681f5f9e", + "type": "Microsoft.Storage/storageAccounts", + "location": "centralindia", + "tags": { + "ms-resource-usage": "azure-cloud-shell" + }, + "privateEndpointConnections": [], + "minimumTlsVersion": "TLS1_1", + "allowBlobPublicAccess": false, + "networkAcls": { + "bypass": "AzureServices", + "virtualNetworkRules": [], + "ipRules": [], + "defaultAction": "Allow" + }, + "supportsHttpsTrafficOnly": true, + "encryption": { + "services": { + "file": { + "keyType": "Account", + "enabled": true, + "lastEnabledTime": "2021-08-05T14:20:46.8158854Z" + }, + "blob": { + "keyType": "Account", + "enabled": true, + "lastEnabledTime": "2021-08-05T14:20:46.8158854Z" + } + }, + "keySource": "Microsoft.Storage" + }, + "accessTier": "Hot", + "provisioningState": "Succeeded", + "creationTime": "2021-08-05T14:20:46.7377764Z", + "primaryLocation": "centralindia", + "statusOfPrimary": "available" + } +]; + + +const createCache = (accounts, accountsErr) => { + return { + storageAccounts: { + list: { + 'eastus': { + err: accountsErr, + data: accounts + } + } + } + } +}; + +describe('storageAccountsTlsVersion', function() { + describe('run', function() { + it('should give passing result if no Storage Accounts found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Storage Accounts found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + storageAccountsTlsVersion.run(cache, {}, callback); + }); + + it('should give failing result if Storage Account is using TLS version less than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('less than desired TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [storageAccounts[1]], + ); + + storageAccountsTlsVersion.run(cache, { sa_min_tls_version: '1.2' }, callback); + }); + + it('should give passing result if Storage Account is using TLS version equal to or higher than desired TLS version', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('equal to or higher than desired TLS version'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [storageAccounts[0]] + ); + + storageAccountsTlsVersion.run(cache, { sa_min_tls_version: '1.2' }, callback); + }); + + it('should give unknown result if unable to query for Storage Accounts', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + storageAccounts, + { message: 'unable to query servers'} + ); + + storageAccountsTlsVersion.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/storageaccounts/tableServiceLoggingEnabled.js b/plugins/azure/storageaccounts/tableServiceLoggingEnabled.js new file mode 100644 index 000000000..df0e56456 --- /dev/null +++ b/plugins/azure/storageaccounts/tableServiceLoggingEnabled.js @@ -0,0 +1,81 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Table Service Logging Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Storage Table service logging is enabled for "Read", "Write", and "Delete" requests.', + more_info: 'Azure Storage Table Service logs contain detailed information about successful and failed requests made to your storage tables for read, write and delete operations. This information can be used to monitor individual requests and to diagnose issues with the Storage Table service within your Microsoft Azure account.', + recommended_action: 'Modify Table Service and enable storage logging for "Read", "Write", and "Delete" requests.', + link: 'https://learn.microsoft.com/en-us/azure/storage/tables/monitor-table-storage?tabs=azure-portal', + apis: ['storageAccounts:list', 'storageAccounts:listKeys', 'diagnosticSettings:listByTableServices'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + for (let storageAccount of storageAccounts.data) { + if (!storageAccount.id) continue; + + if (storageAccount.sku && + storageAccount.sku.tier && + storageAccount.sku.tier.toLowerCase() == 'premium') { + helpers.addResult(results, 0, 'Storage Account tier is premium', location, storageAccount.id); + } else if (storageAccount.kind && + storageAccount.kind.toLowerCase() != 'storagev2') { + helpers.addResult(results, 0, 'Storage Account kind is not StorageV2', location, storageAccount.id); + } else { + + const diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByTableServices', location, storageAccount.id]); + + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, 'Unable to query Storage Account diagnostics settings: ' + helpers.addError(diagnosticSettings), location, storageAccount.id); + } else { + //First consider that all the logs are missing then remove the ones that are present + var missingLogs = ['StorageRead', 'StorageWrite','StorageDelete']; + + diagnosticSettings.data.forEach(settings => { + const logs = settings.logs; + missingLogs = missingLogs.filter(requiredCategory => + !logs.some(log => (log.category === requiredCategory && log.enabled) || log.categoryGroup === 'allLogs' && log.enabled) + ); + }); + + if (missingLogs.length) { + helpers.addResult(results, 2, `Storage Account does not have logging enabled for table service. Missing Logs ${missingLogs}`, location, storageAccount.id); + } else { + helpers.addResult(results, 0, 'Storage Account has logging enabled for table service read, write and delete requests', location, storageAccount.id); + } + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/tableServiceLoggingEnabled.spec.js b/plugins/azure/storageaccounts/tableServiceLoggingEnabled.spec.js new file mode 100644 index 000000000..a73c8c912 --- /dev/null +++ b/plugins/azure/storageaccounts/tableServiceLoggingEnabled.spec.js @@ -0,0 +1,251 @@ +var expect = require('chai').expect; +var tableServiceLoggingEnabled = require('./tableServiceLoggingEnabled'); + +const storageAccounts = [ + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + privateEndpointConnections: [], + minimumTlsVersion: 'TLS1_2', + allowBlobPublicAccess: false, + supportsHttpsTrafficOnly: true, + accessTier: 'Hot', + provisioningState: 'Succeeded', + creationTime: '2021-03-09T16:54:18.2838672Z', + primaryEndpoints: [Object], + primaryLocation: 'eastus', + statusOfPrimary: 'available' + }, + { + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Premium' + } + }, + { + kind: 'BlobStorage', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + sku: { + tier: 'Standard' + } + } +]; + +const diagnosticSettings = [ + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/tableservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + { + category: "StorageRead", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageWrite", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + { + category: "StorageDelete", + categoryGroup: null, + enabled: true, + retentionPolicy: { + enabled: false, + days: 0, + }, + }, + ], + logAnalyticsDestinationType: null, + }, + { + id: "/subscriptions/1234/resourcegroups/test/providers/microsoft.storage/storageaccounts/test1/tableservices/default/providers/microsoft.insights/diagnosticSettings/testsetting", + type: "Microsoft.Insights/diagnosticSettings", + name: "testsetting", + location: "eastus", + logs: [ + ], + logAnalyticsDestinationType: null, + }, +]; +const createCache = (storageAccounts, diagnosticSettings) => { + let diagnostic = {}; + if (storageAccounts.length) { + diagnostic[storageAccounts[0].id] = { + data: diagnosticSettings + }; + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + diagnosticSettings: { + listByTableServices: { + 'eastus': diagnostic + } + } + }; +}; + +const createErrorCache = (key) => { + if (key == 'storageAccounts') { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; + } else if (key === 'noStorageAccount'){ + return { + storageAccounts: { + list: { + 'eastus': { + data:{} + } + } + } + }; + }else if (key === 'diagnostic') { + return { + storageAccounts: { + list: { + 'eastus': { + data: [storageAccounts[0]] + } + } + }, + diagnosticSettings: { + diagnosticSettings: { + 'eastus': {} + } + } + }; + } else { + const appId = (storageAccounts && storageAccounts.length) ? storageAccounts[0].id : null; + const diagnosticSetting = (diagnosticSettings && diagnosticSettings.length) ? diagnosticSettings[0].id : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: [appId[0]] + } + } + }, + diagnosticSettings: { + listByTableServices: { + 'eastus': { + data: {} + } + } + } + }; + } +}; + + +describe('tableServiceLoggingEnabled', function () { + describe('run', function () { + it('should PASS if Table Service has logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[0]]); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Storage Account has logging enabled for table service read, write and delete requests'); + done(); + }); + }); + + it('should FAIL if Table Service does not have logging enabled', function (done) { + const cache = createCache([storageAccounts[0]],[diagnosticSettings[1]]); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account does not have logging enabled for table service. Missing Logs StorageRead,StorageWrite,StorageDelete'); + done(); + }); + }); + + it('should PASS if no storage account found', function (done) { + const cache = createCache([], []); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('No storage accounts found'); + + done(); + }); + }); + + it('should PASS if storage account tier is premium', function (done) { + const cache = createCache([storageAccounts[1]], []); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account tier is premium'); + + done(); + }); + }); + + it('should PASS if storage account kind in not StorageV2', function (done) { + const cache = createCache([storageAccounts[2]], []); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + expect(results[0].message).to.equal('Storage Account kind is not StorageV2'); + + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for storage accounts', function (done) { + const cache = createErrorCache('storageAccounts'); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for storage accounts:'); + done(); + }); + }); + + + it('should UNKNOWN if Unable to query for for Table Service', function (done) { + const cache = createErrorCache('diagnostic'); + tableServiceLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Storage Account diagnostics settings: Unable to obtain data'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/trustedMsAccessEnabled.js b/plugins/azure/storageaccounts/trustedMsAccessEnabled.js index b8a4bfc11..1c3ef079c 100644 --- a/plugins/azure/storageaccounts/trustedMsAccessEnabled.js +++ b/plugins/azure/storageaccounts/trustedMsAccessEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Trusted MS Access Enabled', category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', description: 'Ensures that Trusted Microsoft Services Access is enabled on Storage Accounts', more_info: 'Enabling firewall rules on Storage Accounts blocks all access by default. To ensure that Microsoft and Azure services that connect to the Storage Account still retain access, trusted Microsoft services should be allowed to access the storage account.', recommended_action: 'For each Storage Account, configure an exception for trusted Microsoft services.', - link: 'https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security', apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/storageaccounts/trustedMsAccessEnabled.spec.js b/plugins/azure/storageaccounts/trustedMsAccessEnabled.spec.js new file mode 100644 index 000000000..455ec5615 --- /dev/null +++ b/plugins/azure/storageaccounts/trustedMsAccessEnabled.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var trustedMsAccessEnabled = require('./trustedMsAccessEnabled'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'networkAcls': { + 'bypass': 'AzureServices' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'networkAcls': { + 'bypass': 'None' + } + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('trustedMsAccessEnabled', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + trustedMsAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + trustedMsAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if trusted MS access is enabled', function(done) { + const cache = createCache([storageAccounts[0]]); + trustedMsAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage Account is set to allow trusted Microsoft services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if trusted MS access is not enabled', function(done) { + const cache = createCache([storageAccounts[1]]); + trustedMsAccessEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage Account is not set to allow trusted Microsoft services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/subscription/subscriptionHasTags.js b/plugins/azure/subscription/subscriptionHasTags.js new file mode 100644 index 000000000..03022125b --- /dev/null +++ b/plugins/azure/subscription/subscriptionHasTags.js @@ -0,0 +1,45 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Azure Subscription Has Tags', + category: 'Subscription', + domain: 'Management', + severity: 'Low', + description: 'Ensures that Azure subscriptions have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify affected subscription and add tags.', + link: 'https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.management.resourcemanager.models.subscription.tags', + apis: ['subscriptions:get'], + realtime_triggers: ['microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.subscriptions, function(location, rcb){ + let subscriptions = helpers.addSource(cache, source, + ['subscriptions', 'get', location]); + + if (!subscriptions) return rcb(); + + if (subscriptions.err || !subscriptions.data || !subscriptions.data.length) { + helpers.addResult(results, 3, 'Unable to query for subscriptions: ' + helpers.addError(subscriptions), location); + return rcb(); + } + + for (let sub of subscriptions.data){ + if (sub.tags && Object.keys(sub.tags).length > 0) { + helpers.addResult(results, 0, 'Subscription has tags', location, sub.id); + } else { + helpers.addResult(results, 2, 'Subscription does not have tags', location, sub.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/subscription/subscriptionHasTags.spec.js b/plugins/azure/subscription/subscriptionHasTags.spec.js new file mode 100644 index 000000000..54db6d464 --- /dev/null +++ b/plugins/azure/subscription/subscriptionHasTags.spec.js @@ -0,0 +1,67 @@ +var expect = require('chai').expect; +var subscriptionHasTags = require('./subscriptionHasTags'); + +const subscription = [ + { + 'name': 'test-sub', + 'id': '/subscriptions/123', + 'tags': { 'key': 'value'}, + 'subscriptionId': '123', + }, + { + 'name': 'test-sub', + 'id': '/subscriptions/123', + 'subscriptionId': '123', + } +]; + +const createCache = (subscription) => { + return { + subscriptions: { + get: { + 'global': { + data: subscription + } + + }, + } + }; +}; + +describe('subscriptionHasTags', function() { + describe('run', function() { + + it('should give unknown result if unable to query for subscription', function(done) { + const cache = createCache(null); + subscriptionHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for subscriptions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if subscription has tags', function(done) { + const cache = createCache([subscription[0]]); + subscriptionHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Subscription has tags'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if subscription does not have tags', function(done) { + const cache = createCache([subscription[1]]); + subscriptionHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Subscription does not have tags'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.js b/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.js new file mode 100644 index 000000000..c375d7556 --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Private Endpoints', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensure that Azure Synapse workspace is accessible only through managed private endpoints.', + more_info: 'Enabling managed private endpoints for Azure Synapse Analytics workspace ensure secure, private communication between your Synapse workspace and other Azure resources, traversing exclusively over the Microsoft backbone network. It enhances security by protecting against data exfiltration and allowing connectivity only to specific approved resources.', + recommended_action: 'Modify Synapse workspace and configure managed private endpoints.', + link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/security/synapse-workspace-managed-private-endpoints', + apis: ['synapse:listWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + if (workspace.privateEndpointConnections && + workspace.privateEndpointConnections.length) { + helpers.addResult(results, 0, 'Synapse workspace has managed private endpoints configured', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have managed private endpoints configured', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.spec.js b/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.spec.js new file mode 100644 index 000000000..6aa7a0a3f --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspacPrivateEndpoint.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var synapseWorkspacPrivateEndpoint = require('./synapseWorkspacPrivateEndpoint'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + azureADOnlyAuthentication: true, + privateEndpointConnections: [{ + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test/privateEndpointConnections/test-endpoint-synapse-123", + properties: { + privateEndpoint: { + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Network/privateEndpoints/test-endpoint-synapse", + }, + privateLinkServiceConnectionState: { + status: "Approved", + }, + }, + }], + }, + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + privateEndpointConnections: [] + }, +]; + + +const createCache = (workspaces, err) => { + + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('synapseWorkspacPrivateEndpoint', function () { + describe('run', function () { + + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + synapseWorkspacPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + synapseWorkspacPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace has Private endpoints configured ', function (done) { + const cache = createCache([workspaces[0]], null); + synapseWorkspacPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has managed private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if workspace does not have Private endpoints configured', function (done) { + const cache = createCache([workspaces[1]], null); + synapseWorkspacPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have managed private endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js new file mode 100644 index 000000000..42a942f9d --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Entra ID Auth Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Synapse workspace has Entra ID authentication enabled.', + more_info: 'Enabling Azure Entra ID authentication for Synapse workspace enhances security by ensuring that only authenticated and authorized users can access resources and eliminating the need for password storage. This integration simplifies permission management and secure access.', + recommended_action: 'Enable Entra ID authentication mode for all Synapse workspace.', + link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/sql/active-directory-authentication', + apis: ['synapse:listWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + if (workspace.azureADOnlyAuthentication) { + helpers.addResult(results, 0, 'Synapse workspace has Entra ID authentication enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have Entra ID authentication enabled', location, workspace.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js new file mode 100644 index 000000000..674a19296 --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js @@ -0,0 +1,82 @@ +var expect = require('chai').expect; +var synapseWorkspaceAdAuthEnabled = require('./synapseWorkspaceAdAuthEnabled'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + azureADOnlyAuthentication: true + }, + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + }, +]; + + +const createCache = (workspaces, err) => { + + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('synapseWorkspaceAdAuthEnabled', function () { + describe('run', function () { + + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace has Entra ID auth enabled', function (done) { + const cache = createCache([workspaces[0]], null); + synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if workspace does not have Entra ID auth', function (done) { + const cache = createCache([workspaces[1]], null); + synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspaceHasTags.js b/plugins/azure/synapse/synapseWorkspaceHasTags.js new file mode 100644 index 000000000..05f405375 --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspaceHasTags.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Has Tags', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensures that Azure Synapse workspaces have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify Synapse workspace and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['synapse:listWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + if (workspace.tags && + Object.entries(workspace.tags).length > 0) { + helpers.addResult(results, 0, 'Synapse workspace has tags', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have tags', location, workspace.id); + } + } + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/synapseWorkspaceHasTags.spec.js b/plugins/azure/synapse/synapseWorkspaceHasTags.spec.js new file mode 100644 index 000000000..2663374e2 --- /dev/null +++ b/plugins/azure/synapse/synapseWorkspaceHasTags.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var synapseWorkspaceHasTags = require('./synapseWorkspaceHasTags'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + "tags": { + "key": "value" + } + }, + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + "tags": {} + }, +]; + + +const createCache = (workspaces, err) => { + + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('synapseWorkspaceHasTags', function () { + describe('run', function () { + + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + synapseWorkspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + synapseWorkspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace has tags', function (done) { + const cache = createCache([workspaces[0]], null); + synapseWorkspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if workspace does not have tags', function (done) { + const cache = createCache([workspaces[1]], null); + synapseWorkspaceHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.js b/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.js new file mode 100644 index 000000000..a9beb8a45 --- /dev/null +++ b/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Diagnostic Logging Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that diagnostic logging is enabled for Synapse workspace.', + more_info: 'Enabling diagnostic logs in Azure Synapse workspace is important for monitoring, troubleshooting, and optimizing performance. These logs provide detailed insights into resource usage, query execution, and potential issues, allowing administrators to identify bottlenecks, track errors, and improve the overall efficiency and reliability of the workspace.', + recommended_action: 'Enable diagnostic logging for all Synapse workspaces.', + link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/monitor-synapse-analytics', + apis: ['synapse:listWorkspaces', 'diagnosticSettings:listByWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete','microsoftinsights:diagnosticSettings:delete','microsoftinsights:diagnosticSettings:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByWorkspaces', location, workspace.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Synapse workspace diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, workspace.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Synapse workspace has diagnostic logs enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have diagnostic logs enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.spec.js b/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.spec.js new file mode 100644 index 000000000..73d35d187 --- /dev/null +++ b/plugins/azure/synapse/workspaceDiagnosticLogsEnabled.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var workspaceDiagnosticLogsEnabled = require('./workspaceDiagnosticLogsEnabled'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + } +]; + + +const diagnosticSettings = [ + { + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + type: 'Microsoft.Insights/diagnosticSettings', + name: 'test', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + "category": null, + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + }, + { + "category": null, + "categoryGroup": "audit", + "enabled": false, + "retentionPolicy": { + "enabled": false, + "days": 0 + } + } + ], + logAnalyticsDestinationType: null + } +]; + +const createCache = (workspaces, ds) => { + const id = workspaces && workspaces.length ? workspaces[0].id : null; + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces + } + } + }, + diagnosticSettings: { + listByWorkspaces: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('workspaceDiagnosticLogsEnabled', function() { + describe('run', function() { + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + workspaceDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces: Unable to obtain data'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([workspaces[0]], null); + workspaceDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Synapse workspace diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([workspaces[0]], [diagnosticSettings[0]]); + workspaceDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([workspaces[0]], [[]]); + workspaceDiagnosticLogsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/synapse/workspaceDoubleEncryption.js b/plugins/azure/synapse/workspaceDoubleEncryption.js new file mode 100644 index 000000000..4382d68ff --- /dev/null +++ b/plugins/azure/synapse/workspaceDoubleEncryption.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Double Encryption Enabled', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensures that Azure Synapse workspaces have double Encryption enabled.', + more_info: 'Enabling double encryption for Synapse workspace provides an extra layer of protection for data at rest and in transit. This feature significantly enhances security and helps ensure compliance with stringent data protection standards within the Azure environment.', + recommended_action: 'Create a new Synapse workspace and enable double encryption using CMK.', + link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/security/workspaces-encryption', + apis: ['synapse:listWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + if (workspace.encryption && + workspace.encryption.doubleEncryptionEnabled && + Object.entries(workspace.encryption.cmk).length > 0) { + helpers.addResult(results, 0, 'Synapse workspace has double encryption enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have double encryption enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/workspaceDoubleEncryption.spec.js b/plugins/azure/synapse/workspaceDoubleEncryption.spec.js new file mode 100644 index 000000000..54f0b00f8 --- /dev/null +++ b/plugins/azure/synapse/workspaceDoubleEncryption.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var workspaceDoubleEncryption = require('./workspaceDoubleEncryption'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + encryption: { + doubleEncryptionEnabled: false + } + }, + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + encryption: { + cmk: { + kekIdentity: { + useSystemAssignedIdentity: true, + }, + key: { + name: "default", + keyVaultUrl: "https://test-key-0011.vault.azure.net/keys/test-key", + }, + }, + doubleEncryptionEnabled: true, + } + }, +]; + + +const createCache = (workspaces, err) => { + + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceDoubleEncryption', function () { + describe('run', function () { + + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + workspaceDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace has double encryption enabled', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has double encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if workspace does not have double encryption enabled', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have double encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/synapse/workspaceManagedIdentity.js b/plugins/azure/synapse/workspaceManagedIdentity.js new file mode 100644 index 000000000..4f46ce60e --- /dev/null +++ b/plugins/azure/synapse/workspaceManagedIdentity.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Synapse Workspace Managed Identity', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensure that Azure Synapse workspace has managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + recommended_action: 'Modify Synapse workspace and enable managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/synapse-service-identity', + apis: ['synapse:listWorkspaces'], + realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.synapse, function(location, rcb) { + const workspaces = helpers.addSource(cache, source, + ['synapse', 'listWorkspaces', location]); + + if (!workspaces) return rcb(); + + + if (workspaces.err || !workspaces.data) { + helpers.addResult(results, 3, 'Unable to query Synapse workspaces: ' + helpers.addError(workspaces), location); + return rcb(); + } + + if (!workspaces.data.length) { + helpers.addResult(results, 0, 'No existing Synapse workspaces found', location); + return rcb(); + } + + for (let workspace of workspaces.data) { + if (!workspace.id) continue; + + if (workspace.identity && workspace.identity.type) { + helpers.addResult(results, 0, 'Synapse workspace has managed identity enabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Synapse workspace does not have managed identity enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/synapse/workspaceManagedIdentity.spec.js b/plugins/azure/synapse/workspaceManagedIdentity.spec.js new file mode 100644 index 000000000..760a2fc15 --- /dev/null +++ b/plugins/azure/synapse/workspaceManagedIdentity.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect; +var workspaceManagedIdentity = require('./workspaceManagedIdentity'); + +const workspaces = [ + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + identity: { + type: "SystemAssigned", + tenantId: "1234532134532134532", + principalId: "13123232223223323", + }, + }, + { + type: "Microsoft.Synapse/workspaces", + id: "/subscriptions/123/resourceGroups/rsgrp/providers/Microsoft.Synapse/workspaces/test", + location: "eastus", + name: "test", + }, +]; + + +const createCache = (workspaces, err) => { + + return { + synapse: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceManagedIdentity', function () { + describe('run', function () { + + it('should give a passing result if no Synapse workspaces are found', function (done) { + const cache = createCache([], null); + workspaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Synapse workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Synapse workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Synapse workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if workspace has managed identity', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Synapse workspace has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if workspace does not have managed identity', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceManagedIdentity.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Synapse workspace does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/tableservice/tableServiceAllAccessAcl.js b/plugins/azure/tableservice/tableServiceAllAccessAcl.js index 07286837b..7a3116f46 100644 --- a/plugins/azure/tableservice/tableServiceAllAccessAcl.js +++ b/plugins/azure/tableservice/tableServiceAllAccessAcl.js @@ -4,17 +4,20 @@ var helpers = require('../../../helpers/azure'); module.exports = { title: 'Table Service All Access ACL', category: 'Table Service', + domain: 'Databases', + severity: 'High', description: 'Ensures tables do not allow full write, delete, or read ACL permissions', more_info: 'Table Service tables can be configured to allow to read, write or delete on objects. This option should not be configured unless there is a strong business requirement.', recommended_action: 'Disable global read, write, and delete policies on all tables and ensure the ACL is configured with least privileges.', - link: 'https://docs.microsoft.com/en-us/azure/storage/tables/table-storage-quickstart-portal', - apis: ['storageAccounts:list', 'storageAccounts:listKeys', 'tableService:listTablesSegmented', 'tableService:getTableAcl'], + link: 'https://learn.microsoft.com/en-us/azure/storage/tables/table-storage-quickstart-portal', + apis: ['storageAccounts:list', 'storageAccounts:listKeys', 'tableService:listTablesSegmentedNew', 'tableService:getTableAcl'], compliance: { hipaa: 'HIPAA access controls require data to be secured with least-privileged ' + 'ACLs. Table Service ACLs enable granular permissions for data access.', pci: 'PCI data must be secured via least-privileged ACLs. Table Service ACLs ' + 'enable granular permissions for data access.' }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], run: function(cache, settings, callback) { var results = []; @@ -58,7 +61,7 @@ module.exports = { 'No existing Table Service tables found', location, storageAccount.id); } else { listTablesSegmented.data.forEach(function(table) { - var tableId = `${storageAccount.id}/tableService/${table}`; + var tableId = `${storageAccount.id}/tableService/${table.name}`; // Add ACL var getTableAcl = helpers.addSource(cache, source, @@ -70,10 +73,10 @@ module.exports = { } else { var acl = getTableAcl.data; var fullPermissions = []; - if (acl.signedIdentifiers && Object.keys(acl.signedIdentifiers).length) { for (var ident in acl.signedIdentifiers) { - var permissions = acl.signedIdentifiers[ident].Permissions; + var permissions = acl.signedIdentifiers[ident].accessPolicy.permission; + for (var i = 0; i <= permissions.length; i++) { switch (permissions.charAt(i)) { // case "r": diff --git a/plugins/azure/tableservice/tableServiceAllAccessAcl.spec.js b/plugins/azure/tableservice/tableServiceAllAccessAcl.spec.js new file mode 100644 index 000000000..367abc072 --- /dev/null +++ b/plugins/azure/tableservice/tableServiceAllAccessAcl.spec.js @@ -0,0 +1,204 @@ +var expect = require('chai').expect; +var tableServiceAllAccessAcl = require('./tableServiceAllAccessAcl'); + +const storageAccounts = [ + { + sku: [Object], + kind: 'StorageV2', + id: '/subscriptions/1234/resourceGroups/cloud-shell-storage-eastus/providers/Microsoft.Storage/storageAccounts/csb100320011e293683', + name: 'csb100320011e293683', + type: 'Microsoft.Storage/storageAccounts', + location: 'eastus', + tags: [Object], + privateEndpointConnections: [], + minimumTlsVersion: 'TLS1_2', + allowBlobPublicAccess: false, + networkAcls: [Object], + supportsHttpsTrafficOnly: true, + encryption: [Object], + accessTier: 'Hot', + provisioningState: 'Succeeded', + creationTime: '2021-03-09T16:54:18.2838672Z', + primaryEndpoints: [Object], + primaryLocation: 'eastus', + statusOfPrimary: 'available' + } +]; + +const listKeys = [ + { + keyName: 'key1', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, + { + keyName: 'key2', + value: 'r0jtlC8ninZ0d8/Wn1DSu+YyROFiddLAVHROGtKuj1RHaaExE9DcWDQFdcy4NG8Xd0ecJuW17P15+ASth3mIhg==', + permissions: 'FULL' + }, +]; + +const listTablesSegmented = [ + { + "name": "testtablecs" + } +]; + +const getTableAcl = [ + { + name: 'testtablecs', + signedIdentifiers: [{ accessPolicy: { permission: 'raup' } }] + }, + { + name: 'testtablecs ', + signedIdentifiers: {} + }, + { + name: 'testtablecs ', + signedIdentifiers: [{ accessPolicy: { permission: 'cwdl' } }] + }, +]; + +const createCache = (list, listKeys, segments, acl, keysErr) => { + var id = (list && list.length) ? list[0].id : null; + var segmentName = (segments && segments.length) ? segments[0].name : null; + return { + storageAccounts: { + list: { + 'eastus': { + data: list + }, + }, + listKeys: { + 'eastus': { + [id]: { + err: keysErr, + data: listKeys + }, + }, + }, + }, + tableService: { + listTablesSegmented: { + 'eastus': { + [id]: { + data: segments + } + } + }, + getTableAcl: { + 'eastus': { + [id + '/tableService/' + segmentName]: { + data: acl + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': { + err: { + message: 'error while listing storageAccounts' + }, + }, + }, + listKeys: { + 'eastus': { + err: { + message: 'error while listing storageAccount keys' + }, + }, + }, + }, + tableService: { + listTablesSegmented: { + 'eastus': { + err: { + message: 'Unable to list file share' + } + } + }, + getTableAcl: { + 'eastus': { + err: { + message: 'Unable to get share Acl' + } + } + } + } + }; +}; + +describe('tableServiceAllAccessAcl', function () { + describe('run', function () { + it('should PASS if Table ACL does not contain full access permissions', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listTablesSegmented[0]], [getTableAcl[2]]); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should PASS if Table ACL has not been configured', function (done) { + const cache = createCache(storageAccounts[0], [listKeys[0]], [listTablesSegmented[0]], getTableAcl[1]); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should FAIL if Table ACL allows', function (done) { + const cache = createCache([storageAccounts[0]], [listKeys[0]], [listTablesSegmented[0]], getTableAcl[2]); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should PASS if No storage accounts found', function (done) { + const cache = createCache([]); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query for for Table Service using Storage Account SAS', function (done) { + const cache = createErrorCache(); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if Unable to query Table Service table ACL', function (done) { + const cache = createErrorCache(); + tableServiceAllAccessAcl.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.js b/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.js new file mode 100644 index 000000000..267718aaf --- /dev/null +++ b/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Accelerated Networking Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that accelerated networking is enabled on Azure virtual machines(VM).', + more_info: 'Accelerated networking enables single root I/O virtualization (SR-IOV) to a VM, greatly improving its networking performance.', + recommended_action: 'Enable accelerated networking in virtual machine network interfaces', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/create-vm-accelerated-networking-powershell', + apis: ['virtualMachines:listAll', 'networkInterfaces:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftnetwork:networkinterfaces:write', 'microsoftcompute:virtualmachines:delete', 'microsoftnetwork:networkinterfaces:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + var networkInterfaces = helpers.addSource(cache, source, + ['networkInterfaces', 'listAll', location]); + + if (!networkInterfaces || networkInterfaces.err || !networkInterfaces.data || !networkInterfaces.data.length) { + helpers.addResult(results, 3, 'Unable to query for network interfaces: ' + helpers.addError(networkInterfaces), location); + return rcb(); + } + + const nicMap = new Map(); + networkInterfaces.data.forEach(networkInterface => { + nicMap.set(networkInterface.id, networkInterface.enableAcceleratedNetworking); + }); + + virtualMachines.data.forEach(virtualMachine => { + let acclNetwrork = false; + if (virtualMachine.networkProfile && virtualMachine.networkProfile.networkInterfaces && + virtualMachine.networkProfile.networkInterfaces.length > 0) { + acclNetwrork = virtualMachine.networkProfile.networkInterfaces.find(interface => { + if (nicMap.get(interface.id)) { + return true; + } + }); + } + + if (acclNetwrork) { + helpers.addResult(results, 0, 'Accelerated Networking is enabled on Azure Virtual Machine(VM)', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Accelerated Networking is not enabled on Azure Virtual Machine(VM)', location, virtualMachine.id); + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.spec.js b/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.spec.js new file mode 100644 index 000000000..f0b35fdf3 --- /dev/null +++ b/plugins/azure/virtualmachines/acceleratedNetworkingEnabled.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var acceleratedNetworkingEnabled = require('./acceleratedNetworkingEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'networkProfile': { + 'networkInterfaces': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkInterfaces/test-network-interface' + } + ] + } + } +]; + +const networkInterfaces = [ + { + 'name': 'test-network-interface', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkInterfaces/test-network-interface', + 'location': 'eastus', + 'type': 'Microsoft.Network/networkInterfaces', + 'enableAcceleratedNetworking': true + }, + { + 'name': 'test-network-interface', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkInterfaces/test-network-interface', + 'location': 'eastus', + 'type': 'Microsoft.Network/networkInterfaces', + 'enableAcceleratedNetworking': false + } +]; + +const createCache = (virtualMachines, networkInterfaces) => { + let machine = {}; + let interface = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + if (interface) { + interface['data'] = networkInterfaces; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + networkInterfaces: { + listAll: { + 'eastus': interface + } + } + }; +}; + +describe('acceleratedNetworkingEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([], null); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null, null); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtualMachines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if no network interfaces found', function(done) { + const cache = createCache([virtualMachines[0]], []); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for network interfaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for network interfaces', function(done) { + const cache = createCache([virtualMachines[0]], null); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for network interfaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if accelerated networking is enabled', function(done) { + const cache = createCache([virtualMachines[0]], [networkInterfaces[0]]); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Accelerated Networking is enabled on Azure Virtual Machine(VM)'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if accelerated networking is not enabled', function(done) { + const cache = createCache([virtualMachines[0]], [networkInterfaces[1]]); + acceleratedNetworkingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Accelerated Networking is not enabled on Azure Virtual Machine(VM)'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/approvedVmExtension.js b/plugins/azure/virtualmachines/approvedVmExtension.js new file mode 100644 index 000000000..899f8a699 --- /dev/null +++ b/plugins/azure/virtualmachines/approvedVmExtension.js @@ -0,0 +1,85 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Approved Extensions', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that approved virtual machine extensions are installed.', + more_info: 'Extensions are small applications that provide post-deployment configuration and automation on Azure VMs. Extensions installed should be approved by the organization to meet the organizational security requirements.', + recommended_action: 'Uninstall unapproved virtual machine extensions', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/extensions/overview', + apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], + settings: { + vm_approved_extensions: { + name: 'Approved VM extensions', + description: 'List of comma separated approved extension names', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write', 'microsoftcompute:virtualmachines:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + approvedExtensions: settings.vm_approved_extensions || this.settings.vm_approved_extensions.default + }; + + if (!config.approvedExtensions.length) return callback(null, results, source); + + var extensionsList = config.approvedExtensions.split(','); + + async.each(locations.virtualMachines, function(location, rcb){ + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No Virtual Machines found', location); + return rcb(); + } + + async.each(virtualMachines.data, function(virtualMachine, scb){ + const virtualMachineExtensions = helpers.addSource(cache, source, + ['virtualMachineExtensions', 'list', location, virtualMachine.id]); + + if (!virtualMachineExtensions || virtualMachineExtensions.err || !virtualMachineExtensions.data) { + helpers.addResult(results, 3, 'Unable to query for VM Extensions: ' + helpers.addError(virtualMachineExtensions), location, virtualMachine.id); + return scb(); + } + + if (!virtualMachineExtensions.data.length) { + helpers.addResult(results, 0, 'No VM Extensions found', location, virtualMachine.id); + return scb(); + } + + virtualMachineExtensions.data.forEach(function(virtualMachineExtension) { + let found = extensionsList.some(extension => extension.trim() === virtualMachineExtension.name); + + if (found) { + helpers.addResult(results, 0, 'Installed extensions are approved by the organization', location, virtualMachineExtension.id); + } else { + helpers.addResult(results, 2, 'Installed extensions are not approved by the organization', location, virtualMachineExtension.id); + } + }); + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/approvedVmExtension.spec.js b/plugins/azure/virtualmachines/approvedVmExtension.spec.js new file mode 100644 index 000000000..bc311f80c --- /dev/null +++ b/plugins/azure/virtualmachines/approvedVmExtension.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var approvedVmExtension = require('./approvedVmExtension'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const virtualMachineExtension = [ + { + 'name': 'TestExtension', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/TestExtension', + 'type': 'Microsoft.Compute/virtualMachines/extensions' + } +]; + +const createCache = (virtualMachines, virtualMachineExtension) => { + let machine = {}; + let extension = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachines.length && virtualMachineExtension) { + extension[virtualMachines[0].id] = { + 'data': virtualMachineExtension + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + virtualMachineExtensions: { + list: { + 'eastus': extension + } + } + }; +}; + +describe('approvedVmExtension', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + approvedVmExtension.run(cache, { vm_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + approvedVmExtension.run(cache, { vm_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]], []); + approvedVmExtension.run(cache, { vm_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VM Extensions found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]]); + approvedVmExtension.run(cache, { vm_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM Extensions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vm extension is approved', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + approvedVmExtension.run(cache, { vm_approved_extensions: 'TestExtension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Installed extensions are approved by the organization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if vm extension is not approved', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + approvedVmExtension.run(cache, { vm_approved_extensions: 'Extension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Installed extensions are not approved by the organization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/approvedVmImage.js b/plugins/azure/virtualmachines/approvedVmImage.js new file mode 100644 index 000000000..f7151d58f --- /dev/null +++ b/plugins/azure/virtualmachines/approvedVmImage.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Managed VM Machine Image', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that VM is launched from a managed VM image.', + more_info: 'A managed VM image contains the information necessary to create a VM, including the OS and data disks. Virtual Machines should be launched using managed images to ensure security practices and consistency across all the instances.', + recommended_action: 'Ensure that VM is launched using managed VM image', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.storageProfile && virtualMachine.storageProfile.imageReference && + virtualMachine.storageProfile.imageReference.id) { + helpers.addResult(results, 0, 'VM is launched using Azure managed VM image', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'VM is not launched using Azure managed VM image', location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/approvedVmImage.spec.js b/plugins/azure/virtualmachines/approvedVmImage.spec.js new file mode 100644 index 000000000..74f8c7ca1 --- /dev/null +++ b/plugins/azure/virtualmachines/approvedVmImage.spec.js @@ -0,0 +1,92 @@ +var expect = require('chai').expect; +var approvedVmImage = require('./approvedVmImage'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'imageReference': { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/galleries/myGallery/images/test-def-1/versions/1.0.0', + 'exactVersion': '1.0.0' + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'imageReference': { + 'publisher': 'Canonical', + 'offer': 'UbuntuServer', + 'sku': '18.04-LTS', + 'version': 'latest', + 'exactVersion': '18.04.202007160' + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('approvedVmImage', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + approvedVmImage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + approvedVmImage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtualMachines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VM is launched using Azure managed VM image', function(done) { + const cache = createCache([virtualMachines[0]]); + approvedVmImage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM is launched using Azure managed VM image'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM is not launched using Azure managed VM image', function(done) { + const cache = createCache([virtualMachines[1]]); + approvedVmImage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM is not launched using Azure managed VM image'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/classicInstances.js b/plugins/azure/virtualmachines/classicInstances.js index 1b62ec5fc..e7c9e0ec2 100644 --- a/plugins/azure/virtualmachines/classicInstances.js +++ b/plugins/azure/virtualmachines/classicInstances.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Classic Instances', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', description: 'Ensures Azure Resource Manager is being used for instances instead of Cloud Services (VM Classic)', more_info: 'ARM is the latest and most secure method of launching Azure resources. VM Classic should not be used.', recommended_action: 'Migrate instances from Cloud Service to ARM.', - link: 'https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-overview', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/resource-group-overview', apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete',], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/virtualmachines/computeGalleryRbacSharing.js b/plugins/azure/virtualmachines/computeGalleryRbacSharing.js new file mode 100644 index 000000000..05518cee4 --- /dev/null +++ b/plugins/azure/virtualmachines/computeGalleryRbacSharing.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Compute Gallery RBAC Sharing', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that the Azure Compute Gallery machine images are shared using RBAC only.', + more_info: 'Images, definitions, and versions in Azure Compute Gallery can be shared using the built-in Azure Roles-based Access Control (RBAC) roles. Compute Galleries shared directly with subscription, tenant or community expose the resource to increased vulnerability. Directly shared galleries cannot contain encrypted image versions.', + recommended_action: 'Ensure that all Azure Compute Galleries are using RBAC only.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/shared-image-galleries?tabs=azure-cli#sharing', + apis: ['computeGalleries:list'], + realtime_triggers: ['microsoftcompute:galleries:write', 'microsoftcompute:galleries:delete', 'microsoftcompute:galleries:share:action'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.computeGalleries, function(location, rcb){ + + var computeGalleries = helpers.addSource(cache, source, ['computeGalleries', 'list', location]); + + if (!computeGalleries) return rcb(); + + if (computeGalleries.err || !computeGalleries.data) { + helpers.addResult(results, 3, 'Unable to query for Compute Galleries' + helpers.addError(computeGalleries), location); + return rcb(); + } + if (!computeGalleries.data.length) { + helpers.addResult(results, 0, 'No existing Compute Galleries found', location); + return rcb(); + } + + computeGalleries.data.forEach(gallery => { + + if (!gallery.sharingProfile || (gallery.sharingProfile && gallery.sharingProfile.permissions && gallery.sharingProfile.permissions.toLowerCase() == 'private')) { + helpers.addResult(results, 0, 'Compute Gallery machine images are shared using RBAC only', location, gallery.id); + } else { + helpers.addResult(results, 2, `Compute Gallery machine images are shared with ${gallery.sharingProfile.permissions.toLowerCase()}`, location, gallery.id); + } + + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/computeGalleryRbacSharing.spec.js b/plugins/azure/virtualmachines/computeGalleryRbacSharing.spec.js new file mode 100644 index 000000000..bb6575244 --- /dev/null +++ b/plugins/azure/virtualmachines/computeGalleryRbacSharing.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var computeGalleryRbac = require('./computeGalleryRbacSharing'); + +const computeGalleries = [ + { + "name": "testgallery", + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/testgallery", + "type": "Microsoft.Compute/galleries", + "location": "eastus", + "tags": {}, + "identifier": { + "uniqueName": "12345-TESTGALLERY" + }, + "provisioningState": "Succeeded", + "sharingProfile": { + "permissions": "Private" + }, + + }, + { + "name": "testgallerymeerab", + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/testgallerymeerab", + "type": "Microsoft.Compute/galleries", + "location": "eastus", + "tags": {}, + "identifier": { + "uniqueName": "12345-TESTGALLERYMEERAB" + }, + "sharingProfile": { + "permissions": "Community", + "communityGalleryInfo": { + "communityGalleryEnabled": true, + "publisherUri": "www.test.com", + "publisherContact": "meerabshafique93704@gmail.com", + "eula": "www.test.com", + "publicNamePrefix": "testtesttest", + "publicNames": [ + "testtesttest-1fb35c99-9f7d-4e54-9b12-bd35cf2602f7" + ] + } + }, + "provisioningState": "Succeeded" + }, + { + "name": "testgallerymeerab", + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/testgallerymeerab", + "type": "Microsoft.Compute/galleries", + "location": "eastus", + "tags": {}, + "identifier": { + "uniqueName": "12345-TESTGALLERYMEERAB" + }, + "sharingProfile": { + "permissions": "Groups", + "groups": [ + { + "type": "Subscriptions", + "ids": [ + "34a4ab42-0d72-47d9-bd1a-aed207386dac", + "380fd389-260b-41aa-bad9-0a83108c370b" + ] + }, + { + "type": "AADTenants", + "ids": [ + "c24c76aa-8897-4027-9b03-8f7928b54ff6" + ] + } + ] + }, + "provisioningState": "Succeeded" + }, + { + "name": "testgallery", + "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/testgallery", + "type": "Microsoft.Compute/galleries", + "location": "eastus", + "tags": {}, + "identifier": { + "uniqueName": "12345-TESTGALLERY" + }, + "provisioningState": "Succeeded", + + }, + +]; + +const createCache = (computeGalleries) => { + let gallery = {}; + if (computeGalleries) { + gallery['data'] = computeGalleries; + } + return { + computeGalleries: { + list: { + 'eastus': gallery + } + } + }; +}; + +describe('computeGalleryRbac', function () { + describe('run', function () { + it('should give passing result if No existing Compute Galleries found', function (done) { + const cache = createCache([]); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Compute Galleries found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Compute Galleries', function (done) { + const cache = createCache(null); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Compute Galleries'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Compute Gallery has community shared setting', function (done) { + const cache = createCache([computeGalleries[1]]); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Compute Gallery machine images are shared with community'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Compute Gallery has group shared setting', function (done) { + const cache = createCache([computeGalleries[2]]); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Compute Gallery machine images are shared with groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Compute Gallery is shared using RBAC', function (done) { + const cache = createCache([computeGalleries[0]]); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Compute Gallery machine images are shared using RBAC only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Compute Gallery is shared using RBAC (no sharing profile)', function (done) { + const cache = createCache([computeGalleries[3]]); + computeGalleryRbac.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Compute Gallery machine images are shared using RBAC only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/desiredSkuSize.js b/plugins/azure/virtualmachines/desiredSkuSize.js new file mode 100644 index 000000000..0a9b58e02 --- /dev/null +++ b/plugins/azure/virtualmachines/desiredSkuSize.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Desired SKU Size', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that virtual machines is using the desired SKU size. This is an opt in plugin and will not run if no desired SKU size is provided.', + more_info: 'VM SKU size defines the compute power and data processing speed. VM SKU size should be chosen carefully to address compute requirements for the organization and to save un-necessary costs.', + recommended_action: 'Resize VM to desired SKU size.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/sizes', + apis: ['virtualMachines:listAll'], + settings: { + vm_desired_sku_size: { + name: 'VM Desired SKU Size', + description: 'Comma separated desired SKU sizes for the virtual machines. Created virtual machine SKU sizes should match the desired SKU size.Please visit https://learn.microsoft.com/en-us/azure/virtual-machines/sizes for available sizes', + regex: '(.*,?)+', + default: '' + } + }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:disks:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + desiredSkuSize: settings.vm_desired_sku_size || this.settings.vm_desired_sku_size.default + }; + + if (!config.desiredSkuSize.length) { + return callback(null, results, source); + } + + async.each(locations.virtualMachines, function(location, rcb) { + const virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machines : ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines', location); + return rcb(); + } + + async.each(virtualMachines.data, function(virtualMachine, scb) { + let vmSkuSize; + if (virtualMachine.hardwareProfile && virtualMachine.hardwareProfile.vmSize) { + vmSkuSize = virtualMachine.hardwareProfile.vmSize.toLowerCase(); + } else { + return scb(); + } + + if ((config.desiredSkuSize.toLowerCase()).includes(vmSkuSize)) { + helpers.addResult(results, 0, `Virtual machine is using the desired SKU size of '${config.desiredSkuSize.toLowerCase()}'`, location, virtualMachine.id); + } else { + helpers.addResult(results, 2, `Virtual machine is not using the desired SKU size of '${config.desiredSkuSize.toLowerCase()}'`, location, virtualMachine.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/desiredSkuSize.spec.js b/plugins/azure/virtualmachines/desiredSkuSize.spec.js new file mode 100644 index 000000000..5197ebb33 --- /dev/null +++ b/plugins/azure/virtualmachines/desiredSkuSize.spec.js @@ -0,0 +1,91 @@ +var expect = require('chai').expect; +var desiredSkuSize = require('./desiredSkuSize'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'hardwareProfile': { + 'vmSize': 'Standard_DS3_v2' + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'hardwareProfile': { + 'vmSize': 'Standard_B1ls' + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('desiredSkuSize', function() { + describe('run', function() { + it('should not run plugin if no size defined in setting', function(done) { + const cache = createCache([]); + desiredSkuSize.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + desiredSkuSize.run(cache, { vm_desired_sku_size: 'standard_ds3_v2'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + desiredSkuSize.run(cache, { vm_desired_sku_size: 'standard_ds3_v2'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual machine is of desired SKU size', function(done) { + const cache = createCache([virtualMachines[0]]); + desiredSkuSize.run(cache, { vm_desired_sku_size: 'standard_ds3_v2'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual machine is using the desired SKU size of \'standard_ds3_v2\''); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual machine is not of desired sku size', function(done) { + const cache = createCache([virtualMachines[1]]); + desiredSkuSize.run(cache, { vm_desired_sku_size: 'standard_ds3_v2'}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual machine is not using the desired SKU size of \'standard_ds3_v2\''); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js new file mode 100644 index 000000000..cf1dbc60d --- /dev/null +++ b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js @@ -0,0 +1,56 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Attached Disk Volumes BYOK Encryption Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that attached Azure virtual machine disks have BYOK (Customer-Managed Key) encryption enabled.', + more_info: 'Encrypting virtual machine disk volumes helps protect and safeguard your data to meet organizational security and compliance commitments.', + recommended_action: 'Ensure that virtual machine disks are created using BYOK encryption', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/disk-encryption-key-vault', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disk volumes found', location); + return rcb(); + } + + async.each(disks.data, function(disk, scb) { + if (disk.diskState && disk.diskState.toLowerCase() === 'attached') { + if (disk.encryption && disk.encryption.type && + (disk.encryption.type === 'EncryptionAtRestWithCustomerKey' || + disk.encryption.type === 'EncryptionAtRestWithPlatformAndCustomerKeys')) { + helpers.addResult(results, 0, 'Disk volume has BYOK encryption enabled', location, disk.id); + } else { + helpers.addResult(results, 2, 'Disk volume has BYOK encryption disabled', location, disk.id); + } + } + scb(); + }, function() { + rcb(); + }); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js new file mode 100644 index 000000000..c21585190 --- /dev/null +++ b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js @@ -0,0 +1,120 @@ +var expect = require('chai').expect; +var diskByokEncryptionEnabled = require('./diskByokEncryptionEnabled'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Attached', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Attached', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Attached', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformAndCustomerKeys', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Unattached', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + } +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('diskByokEncryptionEnabled', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + diskByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(); + diskByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk volumes'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume has BYOK encryption enabled only', function(done) { + const cache = createCache([disks[1]]); + diskByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk volume has BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume has BYOK encryption enabled along with platform key ', function(done) { + const cache = createCache([disks[2]]); + diskByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk volume has BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk volume has BYOK encryption disabled', function(done) { + const cache = createCache([disks[0]]); + diskByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk volume has BYOK encryption disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js new file mode 100644 index 000000000..c9749a15a --- /dev/null +++ b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js @@ -0,0 +1,60 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Guest Level Diagnostics Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that the guest level diagnostics are enabled ', + more_info: 'Guest Level Diagnostics should be enabled to collect information about VMs processing and state of VM applications.', + recommended_action: 'Enable guest level diagnostics for all virtual machines', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-enable-vm-agent', + apis: ['virtualMachines:listAll', 'virtualMachines:get'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write','microsoftcompute:virtualmachines:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb){ + + var virtualMachines = helpers.addSource(cache, source, ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines found', location); + return rcb(); + } + + for (let virtualMachine of virtualMachines.data) { + const virtualMachineData = helpers.addSource(cache, source, ['virtualMachines', 'get', location, virtualMachine.id]); + + if (!virtualMachineData || !virtualMachineData.data || virtualMachineData.err) { + helpers.addResult(results, 3, 'Unable to query for virtual machine data', location, virtualMachine.id); + } else { + + const diagnosticSetting = virtualMachineData.data.resources && virtualMachineData.data.resources.length? + virtualMachineData.data.resources.find(resource => (resource.properties && resource.properties.settings && resource.properties.settings.ladCfg && resource.properties.settings.ladCfg.diagnosticMonitorConfiguration)): false; + if (diagnosticSetting) { + helpers.addResult(results, 0, 'Guest Level Diagnostics are enabled for the virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Guest Level Diagnostics are disabled for the virtual machine', location, virtualMachine.id); + } + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js new file mode 100644 index 000000000..b8a147d68 --- /dev/null +++ b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var guestLevelDiagnosticsEnabled = require('./guestLevelDiagnosticsEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const virtualMachinesData = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'resources': [ + { + 'properties': { + 'settings': { + 'ladCfg': { + 'diagnosticMonitorConfiguration': {} + } + } + } + } + ] + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'resources': [ + { + 'properties': { + 'settings': {} + } + } + ] + } +]; + +const createCache = (virtualMachines, virtualMachineDetails) => { + let machine = {}; + let machineDetails = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachineDetails && virtualMachines.length) { + machineDetails[virtualMachines[0].id]= { + 'data': virtualMachineDetails + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + }, + get: { + 'eastus': machineDetails + } + } + }; +}; + +describe('guestLevelDiagnosticsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + guestLevelDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + guestLevelDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine details', function(done) { + const cache = createCache([virtualMachines[0]]); + guestLevelDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine data'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VM has guest level diagnostics enabled', function(done) { + const cache = createCache([virtualMachines[0]], virtualMachinesData[0]); + guestLevelDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Guest Level Diagnostics are enabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM has guest level diagnostics disabled', function(done) { + const cache = createCache([virtualMachines[0]], virtualMachinesData[1]); + guestLevelDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Guest Level Diagnostics are disabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/instantRestoreRetention.js b/plugins/azure/virtualmachines/instantRestoreRetention.js new file mode 100644 index 000000000..207b6609f --- /dev/null +++ b/plugins/azure/virtualmachines/instantRestoreRetention.js @@ -0,0 +1,127 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Instant Restore Backup Retention Period', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that VM instant restore backup retention policy is configured to retain backups for the desired number of days.', + more_info: 'Azure Backup provides independent and isolated backups to guard against unintended destruction of the data on your VMs. These backups should be retained for a specific amount of time to recover destroyed VM.', + recommended_action: 'Configure virtual machine instant restore backup retention policy to retain backups for desired number of days', + link: 'https://learn.microsoft.com/en-us/azure/backup/backup-instant-restore-capability', + apis: ['virtualMachines:listAll', 'recoveryServiceVaults:listBySubscriptionId', 'backupProtectedItems:listByVault', 'backupPolicies:listByVault'], + settings: { + vm_instant_restore_retention_period: { + name: 'VM Instant Restore Backup Retention Period', + description: 'Number of days that a VM instant restore backup will be retained', + regex: '^[1-5]$', + default: '5' + } + }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftrecoveryservices:vaults:write', 'microsoftrecoveryservices:vaults:delete', 'microsoftrecoveryservices:vaults:backupfabrics:protectioncontainers:protecteditems:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + retentionPeriod: parseInt(settings.vm_instant_restore_retention_period || this.settings.vm_instant_restore_retention_period.default) + }; + + async.each(locations.virtualMachines, function(location, rcb) { + const virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + const recoveryVaults = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'listBySubscriptionId', location]); + + if (!recoveryVaults || recoveryVaults.err || !recoveryVaults.data) { + helpers.addResult(results, 3, 'Unable to query for backup recovery vaults: ' + helpers.addError(recoveryVaults), location); + return rcb(); + } + + if (!recoveryVaults.data.length) { + helpers.addResult(results, 2, 'No backup recovery vaults found', location); + return rcb(); + } + + const vmPoliciesMap = new Map(); + const backupPoliciesMap = new Map(); + + for (const vault of recoveryVaults.data) { + const backupProtectedItems = helpers.addSource(cache, source, + ['backupProtectedItems', 'listByVault', location, vault.id]); + + if (!backupProtectedItems || backupProtectedItems.err || !backupProtectedItems.data) { + helpers.addResult(results, 3, 'Unable to query for backup retention policies : ' + helpers.addError(backupProtectedItems), location); + return rcb(); + } + + const backupPolicies = helpers.addSource(cache, source, + ['backupPolicies', 'listByVault', location, vault.id]); + + if (!backupPolicies || backupPolicies.err || !backupPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for backup retention policies : ' + helpers.addError(backupPolicies), location); + return rcb(); + } + + for (const bpItem of backupProtectedItems.data) { + if (bpItem.virtualMachineId && vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase())) { + vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase()).push(bpItem.policyId); + } else if (bpItem.virtualMachineId) { + vmPoliciesMap.set(bpItem.virtualMachineId.toLowerCase(), []); + vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase()).push(bpItem.policyId); + } + } + + for (const backupPolicy of backupPolicies.data) { + backupPoliciesMap.set(backupPolicy.id, backupPolicy); + } + } + + async.each(virtualMachines.data, function(virtualMachine, scb) { + const vmPolicies = vmPoliciesMap.get(virtualMachine.id.toLowerCase()); + if (vmPolicies && vmPolicies.length) { + let retentionDays = 0; + for (const vmPolicy of vmPolicies) { + const backupPolicy = backupPoliciesMap.get(vmPolicy); + + if (backupPolicy && backupPolicy.instantRpRetentionRangeInDays) { + retentionDays = backupPolicy.instantRpRetentionRangeInDays; + } + } + + if (retentionDays === 0) { + helpers.addResult(results, 2, 'No instant restore backup is configured', location, virtualMachine.id); + } else if (retentionDays >= config.retentionPeriod) { + helpers.addResult(results, 0, `VM instant restore backups are configured to be retained for ${retentionDays} of ${config.retentionPeriod} days desired limit`, location, virtualMachine.id); + } else { + helpers.addResult(results, 2, `VM instant restore backups are configured to be retained for ${retentionDays} of ${config.retentionPeriod} days desired limit`, location, virtualMachine.id); + } + } else { + helpers.addResult(results, 2, 'No backup policies are configured for the virtual machine', location, virtualMachine.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/instantRestoreRetention.spec.js b/plugins/azure/virtualmachines/instantRestoreRetention.spec.js new file mode 100644 index 000000000..415591b92 --- /dev/null +++ b/plugins/azure/virtualmachines/instantRestoreRetention.spec.js @@ -0,0 +1,236 @@ +var expect = require('chai').expect; +var instantRestoreRetention = require('./instantRestoreRetention'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const recoveryVaults = [ + { + 'name': 'test-vault', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault', + 'type': 'Microsoft.RecoveryServices/vaults' + } +]; + +const backupProtectedItems = [ + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + } +]; + +const backupPolicy = [ + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy', + 'name': 'DailyPolicy', + 'type': 'Microsoft.RecoveryServices/vaults/backupPolicies', + 'instantRPDetails': {}, + 'instantRpRetentionRangeInDays': 5, + 'retentionPolicy': { + 'retentionPolicyType': 'LongTermRetentionPolicy', + 'dailySchedule': { + 'retentionDuration': { + 'count': 30, + 'durationType': 'Days' + } + } + } + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy', + 'name': 'DailyPolicy', + 'type': 'Microsoft.RecoveryServices/vaults/backupPolicies', + 'instantRPDetails': {}, + 'instantRpRetentionRangeInDays': 2, + 'retentionPolicy': { + 'retentionPolicyType': 'LongTermRetentionPolicy', + 'dailySchedule': { + 'retentionDuration': { + 'count': 30, + 'durationType': 'Days' + } + } + } + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/HourlyLogBackup', + 'name': 'HourlyLogBackup', + 'type': 'Microsoft.RecoveryServices/vaults/backupPolicies', + 'subProtectionPolicy': [] + } +]; + +const createCache = (virtualMachines, recoveryVaults, backupProtectedItem, backupPolicies) => { + let machines = {}; + let vaults = {}; + let protectedItems = {}; + let policy = {}; + if (virtualMachines) { + machines['data'] = virtualMachines; + } + if (recoveryVaults) { + vaults['data'] = recoveryVaults; + if (recoveryVaults.length && backupProtectedItem) { + protectedItems[recoveryVaults[0].id] = { + 'data': backupProtectedItem + }; + } + + if (recoveryVaults.length && backupPolicies) { + policy[recoveryVaults[0].id] = { + 'data': backupPolicies + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machines + } + }, + recoveryServiceVaults: { + listBySubscriptionId: { + 'eastus': vaults + } + }, + backupProtectedItems: { + listByVault: { + 'eastus': protectedItems + } + }, + backupPolicies: { + listByVault: { + 'eastus': policy + } + } + }; +}; + +describe('instantRestoreRetention', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]], []); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup recovery vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup recovery vaults'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup product items', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup retention policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup retention policies', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup retention policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup product items found for virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[1]], [backupPolicy[0]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup policies are configured for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if instant restore backup retention period not found', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]], [backupPolicy[2]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No instant restore backup is configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if instant restore backup retention period is configured', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]], [backupPolicy[0]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM instant restore backups are configured to be retained for 5 of 5 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if backup retention period is not configured', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]], [backupPolicy[1]]); + instantRestoreRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM instant restore backups are configured to be retained for 2 of 5 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/noUnattachedDisks.js b/plugins/azure/virtualmachines/noUnattachedDisks.js new file mode 100644 index 000000000..343add4a7 --- /dev/null +++ b/plugins/azure/virtualmachines/noUnattachedDisks.js @@ -0,0 +1,51 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'No Unattached Disk Volumes', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that the Azure virtual machines have no unattached disk volumes.', + more_info: 'When a virtual machine (VM) in Azure is deleted, by default, any disks that are attached to the VM aren\'t deleted. Those disks need to be deleted to save cost for unused resources.', + recommended_action: 'Ensure that there are no unattached virtual machine disk volumes', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disks-find-unattached-portal', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb){ + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disk volumes found', location); + return rcb(); + } + + disks.data.forEach(disk => { + if (disk.diskState && disk.diskState.toLowerCase() != 'unattached') { + helpers.addResult(results, 0, 'Disk volume is attached to a virtual machine', location, disk.id); + } else { + helpers.addResult(results, 2, 'Disk volume is not attached to a virtual machine', location, disk.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/noUnattachedDisks.spec.js b/plugins/azure/virtualmachines/noUnattachedDisks.spec.js new file mode 100644 index 000000000..ffdfd34fa --- /dev/null +++ b/plugins/azure/virtualmachines/noUnattachedDisks.spec.js @@ -0,0 +1,90 @@ +var expect = require('chai').expect; +var noUnattachedDisks = require('./noUnattachedDisks'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Attached' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Reserved' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Unattached' + } +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('noUnattachedDisks', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + noUnattachedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(null); + noUnattachedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk volumes:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if disk volume is attached to a virtual machine', function(done) { + const cache = createCache([disks[0], disks[1]]); + noUnattachedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[1].status).to.equal(0); + expect(results[0].message).to.include('Disk volume is attached to a virtual machine'); + expect(results[1].message).to.include('Disk volume is attached to a virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('disk volume is not attached to a virtual machine', function(done) { + const cache = createCache([disks[2]]); + noUnattachedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk volume is not attached to a virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/oldVmDiskSnapshots.js b/plugins/azure/virtualmachines/oldVmDiskSnapshots.js new file mode 100644 index 000000000..88f8b0549 --- /dev/null +++ b/plugins/azure/virtualmachines/oldVmDiskSnapshots.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Old VM Disk Snapshots', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that virtual machines do not have older disk snapshots.', + more_info: 'A snapshot is a full, read-only copy of a virtual hard drive (VHD). You can take a snapshot of an OS or data disk VHD to use as a backup, or to troubleshoot virtual machine (VM) issues. VM snapshots older than a specific period of time should be deleted to save cost of unused resources.', + recommended_action: 'Ensure that there are no undesired old VM disk snapshots', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/snapshot-copy-managed-disk', + apis: ['snapshots:list'], + settings: { + days_since_snapshot_creation: { + name: 'Days Since Snapshot Creation', + description: 'The number of days since snapshot was created. Snapshots older than this value of days should be deleted', + regex: '^[0-9]*', + default: '30' + } + }, + realtime_triggers: ['microsoftcompute:snapshots:write', 'microsoftcompute:snapshots:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + daysSinceCreation: parseInt(settings.days_since_snapshot_creation || this.settings.days_since_snapshot_creation.default) + }; + + async.each(locations.snapshots, function(location, rcb) { + const snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', location]); + + if (!snapshots) return rcb(); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk snapshots : ' + helpers.addError(snapshots), location); + return rcb(); + } + + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No existing virtual machine disk snapshots', location); + return rcb(); + } + + async.each(snapshots.data, function(snapshot, scb) { + const daysCreated = helpers.daysBetween(new Date(), new Date(snapshot.timeCreated)); + + if (daysCreated <= config.daysSinceCreation) { + helpers.addResult(results, 0, `VM disk snapshot is ${daysCreated} days older which is equal to or less than ${config.daysSinceCreation} days limit`, location, snapshot.id); + } else { + helpers.addResult(results, 2, `VM disk snapshot is ${daysCreated} days older which is more than ${config.daysSinceCreation} days limit`, location, snapshot.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/azure/virtualmachines/oldVmDiskSnapshots.spec.js b/plugins/azure/virtualmachines/oldVmDiskSnapshots.spec.js new file mode 100644 index 000000000..d8f9981f6 --- /dev/null +++ b/plugins/azure/virtualmachines/oldVmDiskSnapshots.spec.js @@ -0,0 +1,90 @@ +var expect = require('chai').expect; +var oldVmDiskSnapshots = require('./oldVmDiskSnapshots'); +var helpers = require('../../../helpers/azure'); + +let dateNow = new Date().toISOString(); +let datePast = new Date(new Date().setDate(new Date().getDate() - 90)).toISOString(); +const snapshots = [ + { + 'name': 'test-snapshot', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/snapshots/test-ali-ss', + 'type': 'Microsoft.Compute/snapshots', + 'location': 'eastus', + 'diskSizeGB': 30, + 'timeCreated': dateNow + }, + { + 'name': 'test-snapshot', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/snapshots/test-ali-ss', + 'type': 'Microsoft.Compute/snapshots', + 'location': 'eastus', + 'diskSizeGB': 30, + 'timeCreated': datePast + } +]; + +const createCache = (snapshots) => { + let snapshot = {}; + if (snapshots) { + snapshot['data'] = snapshots; + } + return { + snapshots: { + list: { + 'eastus': snapshot + } + } + }; +}; + +describe('oldVmDiskSnapshots', function() { + describe('run', function() { + it('should give passing result if no snapshots', function(done) { + const cache = createCache([]); + oldVmDiskSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machine disk snapshots'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for snapshots', function(done) { + const cache = createCache(null); + oldVmDiskSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk snapshots'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if snapshot is not older than desired limit', function(done) { + const cache = createCache([snapshots[0]]); + const daysCreated = helpers.daysBetween(new Date(), new Date(snapshots[0].timeCreated)); + + oldVmDiskSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include(`VM disk snapshot is ${daysCreated} days older which is equal to or less than 30 days limit`); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if snapshot is older than desired limit', function(done) { + const cache = createCache([snapshots[1]]); + const daysCreated = helpers.daysBetween(new Date(), new Date(snapshots[1].timeCreated)); + + oldVmDiskSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include(`VM disk snapshot is ${daysCreated} days older which is more than 30 days limit`); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/passwordAuthDisabled.js b/plugins/azure/virtualmachines/passwordAuthDisabled.js new file mode 100644 index 000000000..9f5b5a9af --- /dev/null +++ b/plugins/azure/virtualmachines/passwordAuthDisabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Password Authentication Disabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that password authentication is disabled on Azure virtual machines.', + more_info: 'SSH provides secure sign-ins over unsecured connections. Although SSH provides an encrypted connection, using passwords with SSH connections still leaves the VM vulnerable so it is recommended to connect to VM over SSH instead of password.', + recommended_action: 'Disable password authentication on Azure virtual machine', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.osProfile && virtualMachine.osProfile.windowsConfiguration) { + helpers.addResult(results, 0, 'SSH authentication is not supported in Windows VM', location, virtualMachine.id); + } else { + if (virtualMachine.osProfile && virtualMachine.osProfile.linuxConfiguration && + virtualMachine.osProfile.linuxConfiguration.disablePasswordAuthentication) { + helpers.addResult(results, 0, 'Password authentication is disabled on virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Password authentication is not disabled on virtual machine', location, virtualMachine.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/passwordAuthDisabled.spec.js b/plugins/azure/virtualmachines/passwordAuthDisabled.spec.js new file mode 100644 index 000000000..b15e39577 --- /dev/null +++ b/plugins/azure/virtualmachines/passwordAuthDisabled.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var passwordAuthDisabled = require('./passwordAuthDisabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'adminUsername': 'khulnasoftuser', + 'linuxConfiguration': { + 'disablePasswordAuthentication': true + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'computerName': 'test-vm', + 'linuxConfiguration': { + 'disablePasswordAuthentication': false + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('passwordAuthDisabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + passwordAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + passwordAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtualMachines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Password authentication is disabled on virtual machine', function(done) { + const cache = createCache([virtualMachines[0]]); + passwordAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Password authentication is disabled on virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Password authentication is not disabled on virtual machine', function(done) { + const cache = createCache([virtualMachines[1]]); + passwordAuthDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Password authentication is not disabled on virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.js b/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.js new file mode 100644 index 000000000..7e3ea6107 --- /dev/null +++ b/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.js @@ -0,0 +1,81 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Virtual Machine Performance Diagnostics Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that performance diagnostics is enabled on virtual machines.', + more_info: 'The performance diagnostics tool helps in troubleshooting performance issues that can affect a Windows or Linux virtual machine (VM).', + recommended_action: 'Enable performance diagnostics on Azure virtual machines', + link: 'https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/performance-diagnostics', + apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write','microsoftcompute:virtualmachines:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No Virtual Machines found', location); + return rcb(); + } + + async.each(virtualMachines.data, function(virtualMachine, scb) { + const virtualMachineExtensions = helpers.addSource(cache, source, + ['virtualMachineExtensions', 'list', location, virtualMachine.id]); + + if (!virtualMachineExtensions || virtualMachineExtensions.err || !virtualMachineExtensions.data) { + helpers.addResult(results, 3, 'Unable to query for VM Extensions: ' + helpers.addError(virtualMachineExtensions), location, virtualMachine.id); + return scb(); + } + + if (!virtualMachineExtensions.data.length) { + helpers.addResult(results, 2, 'Performance Diagnostics is disabled on the virtual machine', location, virtualMachine.id); + return scb(); + } + + var windowsImg = false; + if ((virtualMachine.storageProfile && + virtualMachine.storageProfile.imageReference && + virtualMachine.storageProfile.imageReference.offer && + virtualMachine.storageProfile.imageReference.offer.toLowerCase().indexOf('windowsserver') > -1) || (virtualMachine.storageProfile && + virtualMachine.storageProfile.osDisk && + virtualMachine.storageProfile.osDisk.osType && + virtualMachine.storageProfile.osDisk.osType.toLowerCase().indexOf('windows') > -1)) { + windowsImg = true; + } + + const adEnabled = virtualMachineExtensions.data.some((virtualMachineExtension) => (((windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AzurePerformanceDiagnostics') || + (!windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AzurePerformanceDiagnosticsLinux')) && + (virtualMachineExtension.provisioningState && virtualMachineExtension.provisioningState === 'Succeeded'))); + + if (adEnabled) { + helpers.addResult(results, 0, 'Performance Diagnostics is enabled on the virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Performance Diagnostics is disabled on the virtual machine', location, virtualMachine.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.spec.js b/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.spec.js new file mode 100644 index 000000000..1168bf7e8 --- /dev/null +++ b/plugins/azure/virtualmachines/performanceDiagnosticsEnabled.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var performanceDiagnosticsEnabled = require('./performanceDiagnosticsEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Windows' + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Linux' + } + } + } +]; + +const virtualMachineExtension = [ + { + 'name': 'AzurePerformanceDiagnostics', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/AzurePerformanceDiagnostics', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'AzurePerformanceDiagnosticsLinux', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/AzurePerformanceDiagnosticsLinux', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + } + +]; + +const createCache = (virtualMachines, virtualMachineExtension) => { + let machine = {}; + let extension = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachines.length && virtualMachineExtension) { + extension[virtualMachines[0].id] = { + 'data': virtualMachineExtension + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + virtualMachineExtensions: { + list: { + 'eastus': extension + } + } + }; +}; + +describe('performanceDiagnosticsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[1]], []); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Performance Diagnostics is disabled on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]]); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM Extensions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Performance Diagnostics is enabled on the virtual machine for windows machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Performance Diagnostics is enabled on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Performance Diagnostics is enabled on the virtual machine for linux machine', function(done) { + const cache = createCache([virtualMachines[1]], [virtualMachineExtension[1]]); + performanceDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Performance Diagnostics is enabled on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Performance Diagnostics is disabled on the virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[1]]); + performanceDiagnosticsEnabled.run(cache, { vm_approved_extensions: 'Extension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Performance Diagnostics is disabled on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/premiumSsdDisabled.js b/plugins/azure/virtualmachines/premiumSsdDisabled.js new file mode 100644 index 000000000..25632c8ca --- /dev/null +++ b/plugins/azure/virtualmachines/premiumSsdDisabled.js @@ -0,0 +1,73 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Premium SSD Disabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that the Azure virtual machines are configured to use standard SSD disk volumes instead of premium SSD disk volumes for managed disks.', + more_info: 'Azure standard SSD disks store data on solid state drives (SSDs), like Azure\'s existing premium storage disks. Standard SSD disks are a cost-effective storage option optimized for workloads that need consistent performance at lower IOPS levels.', + recommended_action: 'Modify virtual machines disks to use standard SSD disk volumes instead of premium SSD disk volumes', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disks-types', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb){ + const virtualMachines = helpers.addSource(cache, source, ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + let foundDisk = false; + if (virtualMachine.storageProfile && virtualMachine.storageProfile.osDisk && + virtualMachine.storageProfile.osDisk.managedDisk && + virtualMachine.storageProfile.osDisk.managedDisk.id && + virtualMachine.storageProfile.osDisk.managedDisk.storageAccountType && + virtualMachine.storageProfile.osDisk.managedDisk.storageAccountType.toLowerCase() === 'premium_lrs') { + helpers.addResult(results, 2, 'Attached OS disk volume is of Premium SSD type', location, virtualMachine.storageProfile.osDisk.managedDisk.id); + foundDisk = true; + } else if (virtualMachine.storageProfile && virtualMachine.storageProfile.osDisk && virtualMachine.storageProfile.osDisk.managedDisk){ + helpers.addResult(results, 0, 'Attached OS disk volume is not of Premium SSD type', location, virtualMachine.storageProfile.osDisk.managedDisk.id); + foundDisk = true; + } + + const dataDisks = (virtualMachine.storageProfile && virtualMachine.storageProfile.dataDisks) ? virtualMachine.storageProfile.dataDisks : []; + + for (const dataDisk of dataDisks) { + if (dataDisk.managedDisk && dataDisk.managedDisk.storageAccountType && dataDisk.managedDisk.id && + dataDisk.managedDisk.storageAccountType.toLowerCase() === 'premium_lrs') { + helpers.addResult(results, 2, 'Attached data disk volume is of Premium SSD type', location, dataDisk.managedDisk.id); + foundDisk = true; + } else if (dataDisk.managedDisk) { + helpers.addResult(results, 0, 'Attached data disk volume is not of Premium SSD type', location, dataDisk.managedDisk.id); + foundDisk = true; + } + } + if (!foundDisk) { + helpers.addResult(results, 0, 'No disks found for the Virtual machine', location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/premiumSsdDisabled.spec.js b/plugins/azure/virtualmachines/premiumSsdDisabled.spec.js new file mode 100644 index 000000000..9ee16dba2 --- /dev/null +++ b/plugins/azure/virtualmachines/premiumSsdDisabled.spec.js @@ -0,0 +1,166 @@ +var expect = require('chai').expect; +var premiumSsdDisabled = require('./premiumSsdDisabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'managedDisk': { + 'storageAccountType': 'StandardSSD_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_d88ee8681dbe4bd3bbbd52a1f8e46d7f' + } + }, + 'dataDisks': [] + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'managedDisk': { + 'storageAccountType': 'Premium_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_d88ee8681dbe4bd3bbbd52a1f8e46d7f' + } + }, + 'dataDisks': [] + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'managedDisk': { + 'storageAccountType': 'StandardSSD_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_d88ee8681dbe4bd3bbbd52a1f8e46d7f' + } + }, + 'dataDisks': [ + { + 'managedDisk': { + 'storageAccountType': 'StandardSSD_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-disk' + } + } + ] + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'managedDisk': { + 'storageAccountType': 'Premium_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_d88ee8681dbe4bd3bbbd52a1f8e46d7f' + } + }, + 'dataDisks': [ + { + 'managedDisk': { + 'storageAccountType': 'Premium_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test-disk' + } + } + ] + } + } +]; + +const createCache = (virtualMachines) => { + const machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('premiumSsdDisabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines found', function(done) { + const cache = createCache([]); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if OS disk volume is not of Premium SSD type', function(done) { + const cache = createCache([virtualMachines[0]]); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Attached OS disk volume is not of Premium SSD type'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if OS disk volume is of Premium SSD type', function(done) { + const cache = createCache([virtualMachines[1]]); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Attached OS disk volume is of Premium SSD type'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing results if OS and data disk volumes is not of Premium SSD type', function(done) { + const cache = createCache([virtualMachines[2]]); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Attached OS disk volume is not of Premium SSD type'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing results if OS and data disk volume is of Premium SSD type', function(done) { + const cache = createCache([virtualMachines[3]]); + premiumSsdDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Attached OS disk volume is of Premium SSD type'); + + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Attached data disk volume is of Premium SSD type'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.js b/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.js new file mode 100644 index 000000000..28ad6ec1e --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Disk Snapshot BYOK Encryption Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure virtual machine disk snapshot have BYOK (Customer-Managed Key) encryption enabled.', + more_info: 'Encrypting virtual machine disk snapshot helps protect and safeguard your data to meet organizational security and compliance commitments.', + recommended_action: 'Modify affected snapshots and and enable customer managed key encryption.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption', + apis: ['snapshots:list'], + realtime_triggers: ['microsoftcompute:snapshots:write', 'microsoftcompute:snapshots:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.snapshots, function(location, rcb) { + const snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', location]); + + if (!snapshots) return rcb(); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk snapshots: ' + helpers.addError(snapshots), location); + return rcb(); + } + + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No virtual machine disk snapshots found', location); + return rcb(); + } + + for (let snapshot of snapshots.data) { + + if (!snapshot.id) continue; + + if (snapshot.encryption && snapshot.encryption.type && + snapshot.encryption.type === 'EncryptionAtRestWithCustomerKey' || + snapshot.encryption.type === 'EncryptionAtRestWithPlatformAndCustomerKeys') { + + helpers.addResult(results, 0, 'VM disk snapshot has BYOK encryption enabled', location, snapshot.id); + } else { + helpers.addResult(results, 2, 'VM disk snapshot does not have BYOK encryption enabled', location, snapshot.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.spec.js b/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.spec.js new file mode 100644 index 000000000..0351b6495 --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var snapshotByokEncryptionEnabled = require('./snapshotByokEncryptionEnabled'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformAndCustomerKeys', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + } + } +]; + +const createCache = (snapshots) => { + const snap = {}; + if (snapshots) { + snap['data'] = snapshots; + } + return { + snapshots: { + list: { + 'eastus': snap + } + } + }; +}; + +describe('snapshotByokEncryptionEnabled', function() { + describe('run', function() { + it('should give passing result if no disk snapshot found', function(done) { + const cache = createCache([]); + snapshotByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No virtual machine disk snapshots found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk snapshot', function(done) { + const cache = createCache(); + snapshotByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk snapshots:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk snapshot has BYOK encryption enabled only', function(done) { + const cache = createCache([disks[1]]); + snapshotByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk snapshot has BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk snapshot has BYOK encryption enabled along with platform key ', function(done) { + const cache = createCache([disks[2]]); + snapshotByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk snapshot has BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk snapshot has BYOK encryption disabled', function(done) { + const cache = createCache([disks[0]]); + snapshotByokEncryptionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM disk snapshot does not have BYOK encryption enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotHasTags.js b/plugins/azure/virtualmachines/snapshotHasTags.js new file mode 100644 index 000000000..114c72684 --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotHasTags.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Snapshot Has Tags', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Azure VM disk snapshots have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify affected snapshots and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['snapshots:list'], + realtime_triggers: ['microsoftcompute:snapshots:write', 'microsoftcompute:snapshots:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.snapshots, function(location, rcb) { + const snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', location]); + + if (!snapshots) return rcb(); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk snapshots: ' + helpers.addError(snapshots), location); + return rcb(); + } + + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No virtual machine disk snapshots found', location); + return rcb(); + } + for (let snapshot of snapshots.data) { + + if (!snapshot.id) continue; + + if (snapshot.tags && Object.entries(snapshot.tags).length > 0){ + helpers.addResult(results, 0, 'VM disk snapshot has tags associated', location, snapshot.id); + } else { + helpers.addResult(results, 2, 'VM disk snapshot does not have tags associated', location, snapshot.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotHasTags.spec.js b/plugins/azure/virtualmachines/snapshotHasTags.spec.js new file mode 100644 index 000000000..7fe036ccf --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotHasTags.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect; +var snapshotHasTags = require('./snapshotHasTags'); +var helpers = require('../../../helpers/azure'); + +const snapshots = [ + { + 'name': 'test-snapshot', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/snapshots/test-ali-ss', + 'type': 'Microsoft.Compute/snapshots', + 'location': 'eastus', + 'diskSizeGB': 30, + 'tags': { 'key': 'value' } + }, + { + 'name': 'test-snapshot', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/snapshots/test-ali-ss', + 'type': 'Microsoft.Compute/snapshots', + 'location': 'eastus', + 'diskSizeGB': 30, + 'tags': {} + } +]; + +const createCache = (snapshots) => { + let snapshot = {}; + if (snapshots) { + snapshot['data'] = snapshots; + } + return { + snapshots: { + list: { + 'eastus': snapshot + } + } + }; +}; + +describe('snapshotHasTags', function() { + describe('run', function() { + it('should give passing result if no snapshots', function(done) { + const cache = createCache([]); + snapshotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No virtual machine disk snapshots found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for snapshots', function(done) { + const cache = createCache(null); + snapshotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk snapshots'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if snapshot has tags associated', function(done) { + const cache = createCache([snapshots[0]]); + + snapshotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk snapshot has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if snapshot does not have tags associated', function(done) { + const cache = createCache([snapshots[1]]); + + snapshotHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM disk snapshot does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.js b/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.js new file mode 100644 index 000000000..f7345c2c5 --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Disk Snapshot Public Access Disabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that Azure virtual machine disk snapshot are not publicly accessible.', + more_info: 'A snapshot is a full, read-only copy of a virtual hard disk (VHD). You can use a snapshot as a point-in-time backup. Stopping public access to Snapshot ensure that your backups are protected at all times.', + recommended_action: 'Modify snapshots and disable public access', + link: 'https://learn.microsoft.com/en-us/azure/backup/security-overview', + apis: ['snapshots:list'], + realtime_triggers: ['microsoftcompute:snapshots:write', 'microsoftcompute:snapshots:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.snapshots, function(location, rcb) { + const snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', location]); + + if (!snapshots) return rcb(); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for VM disk snapshots: ' + helpers.addError(snapshots), location); + return rcb(); + } + + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No VM disk snapshots found', location); + return rcb(); + } + for (let snapshot of snapshots.data) { + if (!snapshot.id) continue; + + if (snapshot && snapshot.networkAccessPolicy && + snapshot.networkAccessPolicy.toLowerCase() === 'allowprivate' || + snapshot.networkAccessPolicy.toLowerCase() === 'denyall') { + + helpers.addResult(results, 0, 'VM disk snapshot has public access disabled', location, snapshot.id); + } else { + helpers.addResult(results, 2, 'VM disk snapshot does not have public access disabled', location, snapshot.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.spec.js b/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.spec.js new file mode 100644 index 000000000..20a33e8f1 --- /dev/null +++ b/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var snapshotPublicAccessDisabled = require('./snapshotPublicAccessDisabled'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + }, + "networkAccessPolicy": 'allowall' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + }, + "networkAccessPolicy": 'DenyAll' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/snapshot/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformAndCustomerKeys', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + }, + "networkAccessPolicy": 'AllowPrivate' + } +]; + +const createCache = (snapshots) => { + const snap = {}; + if (snapshots) { + snap['data'] = snapshots; + } + return { + snapshots: { + list: { + 'eastus': snap + } + } + }; +}; + +describe('snapshotPublicAccessDisabled', function() { + describe('run', function() { + it('should give passing result if no disk snapshot found', function(done) { + const cache = createCache([]); + snapshotPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VM disk snapshots found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk snapshot', function(done) { + const cache = createCache(); + snapshotPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM disk snapshots:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk snapshot has private access only', function(done) { + const cache = createCache([disks[1]]); + snapshotPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk snapshot has public access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk snapshot not have public access disabled', function(done) { + const cache = createCache([disks[0]]); + snapshotPublicAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM disk snapshot does not have public access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.js b/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.js new file mode 100644 index 000000000..aba6a8d38 --- /dev/null +++ b/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM System-Assigned Identity Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that virtual machines have system-assigned managed identities enabled.', + more_info: 'System-assigned managed identities for Azure VMs allow authentication to other services without the need to manage and store credentials in code.', + recommended_action: 'Modify virtual machine and enable system-assigned managed identity.', + link: 'https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/qs-configure-portal-windows-vm', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + for (let vm of virtualMachines.data) { + if (!vm.id) continue; + + if (vm.identity && vm.identity.type && vm.identity.type.toLowerCase() === 'systemassigned'){ + helpers.addResult(results, 0, 'Virtual Machine has system assigned managed identity enabled', location, vm.id); + } else { + helpers.addResult(results, 2, 'Virtual Machine does not have system assigned managed identity enabled', location, vm.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.spec.js b/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.spec.js new file mode 100644 index 000000000..6134d0fdf --- /dev/null +++ b/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var systemAssignedIdentityEnabled = require('./systemAssignedIdentityEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'adminUsername': 'khulnasoftuser', + 'linuxConfiguration': { + 'disablePasswordAuthentication': true + } + }, + "identity": { + "type": "SystemAssigned", + "principalId": "824f877b-e90f-43ab-b135-36e7cfff154", + "tenantId": "d207c7bd-fcb1-4dd3-855a-cfd2ffff123" + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'computerName': 'test-vm', + 'linuxConfiguration': { + 'disablePasswordAuthentication': false + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('systemAssignedIdentityEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + systemAssignedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + systemAssignedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtualMachines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if system assigned managed identity enabled', function(done) { + const cache = createCache([virtualMachines[0]]); + systemAssignedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine has system assigned managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if system assigned managed identity not enabled', function(done) { + const cache = createCache([virtualMachines[1]]); + systemAssignedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine does not have system assigned managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js new file mode 100644 index 000000000..ed2663b9f --- /dev/null +++ b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js @@ -0,0 +1,53 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Unattached Disk Volumes BYOK Encryption Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that unattached Azure virtual machine disks have BYOK (Customer-Managed Key) encryption enabled.', + more_info: 'Encrypting virtual machine disk volumes helps protect and safeguard your data to meet organizational security and compliance commitments. Having unattached disks with default encryption type can lead to data leakage.', + recommended_action: 'Delete remove unattached disks or enable BYOK encryption for them.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/disk-encryption-key-vault', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for VM disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing VM disk volumes found', location); + return rcb(); + } + + for (let disk of disks.data) { + if (!disk.id) continue; + if (disk.diskState && disk.diskState.toLowerCase() === 'unattached') { + if (disk.encryption && disk.encryption.type && + disk.encryption.type === 'EncryptionAtRestWithPlatformKey') { + helpers.addResult(results, 2, 'Unattached disk volume has BYOK encryption disabled', location, disk.id); + } else { + helpers.addResult(results, 0, 'Unattached disk volume has BYOK encryption enabled', location, disk.id); + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js new file mode 100644 index 000000000..011343cf9 --- /dev/null +++ b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var diskUnattachedAndDefaultEncryption = require('./unattachedDiskWithDefaultEncryption'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey' + }, + 'diskState': 'Reserved' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + }, + 'diskState': 'unattached' + } +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('diskUnattachedAndDefaultEncryption', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing VM disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM disk volumes'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk volume is unattached and encrypted with default encryption key', function(done) { + const cache = createCache([disks[1]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk volume is unattached and encrypted with default encryption key'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume is attached or encrypted with BYO', function(done) { + const cache = createCache([disks[0]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk volume is attached or encrypted with BYOK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.js b/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.js new file mode 100644 index 000000000..d1bb2a1c4 --- /dev/null +++ b/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.js @@ -0,0 +1,53 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Unattached Disk Volumes with Default Encryption', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that no default encrypted Azure virtual machine disks are in unattached state.', + more_info: 'Encrypting virtual machine disk volumes helps protect and safeguard your data to meet organizational security and compliance commitments. Having unattached disks with default encryption type can lead to data leakage.', + recommended_action: 'Delete remove unattached disks or enable BYOK encryption for them.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/disk-encryption-key-vault', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for VM disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing VM disk volumes found', location); + return rcb(); + } + + for (let disk of disks.data) { + if (!disk.id) continue; + + if (disk.encryption && disk.encryption.type && + disk.encryption.type === 'EncryptionAtRestWithPlatformKey' && + disk.diskState && disk.diskState.toLowerCase() === 'unattached') { + helpers.addResult(results, 2, 'Disk volume is unattached and encrypted with default encryption key', location, disk.id); + } else { + helpers.addResult(results, 0, 'Disk volume is attached or encrypted with BYOK', location, disk.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.spec.js b/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.spec.js new file mode 100644 index 000000000..011343cf9 --- /dev/null +++ b/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var diskUnattachedAndDefaultEncryption = require('./unattachedDiskWithDefaultEncryption'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey' + }, + 'diskState': 'Reserved' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + }, + 'diskState': 'unattached' + } +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('diskUnattachedAndDefaultEncryption', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing VM disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM disk volumes'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk volume is unattached and encrypted with default encryption key', function(done) { + const cache = createCache([disks[1]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk volume is unattached and encrypted with default encryption key'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume is attached or encrypted with BYO', function(done) { + const cache = createCache([disks[0]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk volume is attached or encrypted with BYOK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js new file mode 100644 index 000000000..c72e70bc7 --- /dev/null +++ b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Entra ID Authentication Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure Entra ID authentication is enabled for virtual machines.', + more_info: 'Organizations can now improve the security of virtual machines (VMs) in Azure by integrating with Azure Entra ID authentication. Enabling Azure Entra ID authentication for Azure virtual machines (VMs) ensures access to VMs from one central point and simplifies access permission management.', + recommended_action: 'Enable Azure Entra ID authentication for Azure virtual machines', + link: 'https://learn.microsoft.com/en-us/entra/identity/devices/howto-vm-sign-in-azure-ad-windows', + apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write', 'microsoftcompute:virtualmachines:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No Virtual Machines found', location); + return rcb(); + } + + async.each(virtualMachines.data, function(virtualMachine, scb) { + const virtualMachineExtensions = helpers.addSource(cache, source, + ['virtualMachineExtensions', 'list', location, virtualMachine.id]); + + if (!virtualMachineExtensions || virtualMachineExtensions.err || !virtualMachineExtensions.data) { + helpers.addResult(results, 3, 'Unable to query for VM Extensions: ' + helpers.addError(virtualMachineExtensions), location, virtualMachine.id); + return scb(); + } + + if (!virtualMachineExtensions.data.length) { + helpers.addResult(results, 2, 'Azure Entra ID authentication is disabled for the virtual machine', location, virtualMachine.id); + return scb(); + } + + var windowsImg = false; + if ((virtualMachine.storageProfile && + virtualMachine.storageProfile.imageReference && + virtualMachine.storageProfile.imageReference.offer && + virtualMachine.storageProfile.imageReference.offer.toLowerCase().indexOf('windowsserver') > -1) || + (virtualMachine.storageProfile && + virtualMachine.storageProfile.osDisk && + virtualMachine.storageProfile.osDisk.osType && + virtualMachine.storageProfile.osDisk.osType.toLowerCase().indexOf('windows') > -1)) { + windowsImg = true; + } + + const adEnabled = virtualMachineExtensions.data.some((virtualMachineExtension) => ((windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AADLoginForWindows') || + (!windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AADLoginForLinux') || + (!windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AADSSHLoginForLinux'))); + + if (adEnabled) { + helpers.addResult(results, 0, 'Azure Entra ID authentication is enabled for the virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Azure Entra ID authentication is disabled for the virtual machine', location, virtualMachine.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js new file mode 100644 index 000000000..3360c8861 --- /dev/null +++ b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +var adAuthenticationEnabled = require('./vmAdAuthenticationEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Windows' + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Linux' + } + } + } +]; + +const virtualMachineExtension = [ + { + 'name': 'AADLoginForWindows', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/AADLoginForWindows', + 'type': 'Microsoft.Compute/virtualMachines/extensions' + }, + { + 'name': 'AADSSHLoginForLinux', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/AADSSHLoginForLinux', + 'type': 'Microsoft.Compute/virtualMachines/extensions' + } + +]; + +const createCache = (virtualMachines, virtualMachineExtension) => { + let machine = {}; + let extension = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachines.length && virtualMachineExtension) { + extension[virtualMachines[0].id] = { + 'data': virtualMachineExtension + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + virtualMachineExtensions: { + list: { + 'eastus': extension + } + } + }; +}; + +describe('vmAdAuthenticationEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[1]], []); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Entra ID authentication is disabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]]); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM Extensions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Azure Entra ID authentication is enabled for the virtual machine for windows machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Entra ID authentication is enabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Azure Entra ID authentication is enabled for the virtual machine for linux machine', function(done) { + const cache = createCache([virtualMachines[1]], [virtualMachineExtension[1]]); + adAuthenticationEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Entra ID authentication is enabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Azure Entra ID authentication is disabled for the virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[1]]); + adAuthenticationEnabled.run(cache, { vm_approved_extensions: 'Extension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Entra ID authentication is disabled for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmAgentEnabled.js b/plugins/azure/virtualmachines/vmAgentEnabled.js index 95745bc70..6d96c80a4 100644 --- a/plugins/azure/virtualmachines/vmAgentEnabled.js +++ b/plugins/azure/virtualmachines/vmAgentEnabled.js @@ -5,16 +5,19 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Agent Enabled', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that the VM Agent is enabled for virtual machines', - more_info: 'The VM agent must be enabled on Azure virtual machines in order to enable Azure Security Center for data collection.', + more_info: 'The VM agent must be enabled on Azure virtual machines in order to enable Azure Defender for data collection.', recommended_action: 'Enable the VM agent for all virtual machines.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-enable-vm-agent', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-agentless-scanning-vms', apis: ['virtualMachines:listAll'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken. VM ' + 'agent is needed to provide the necessary logs.' }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/virtualmachines/vmAgentEnabled.spec.js b/plugins/azure/virtualmachines/vmAgentEnabled.spec.js new file mode 100644 index 000000000..0d03cb0fd --- /dev/null +++ b/plugins/azure/virtualmachines/vmAgentEnabled.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var vmAgentEnabled = require('./vmAgentEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'linuxConfiguration': { + 'provisionVMAgent': true, + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'linuxConfiguration': { + 'provisionVMAgent': false, + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'windowsConfiguration': { + 'provisionVMAgent': true, + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'osProfile': { + 'windowsConfiguration': { + 'provisionVMAgent': false, + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmAgentEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vm agent is enabled for linux VM', function(done) { + const cache = createCache([virtualMachines[0]]); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM Agent is enabled for this virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if vm agent is disabled for linux VM', function(done) { + const cache = createCache([virtualMachines[1]]); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM Agent is not enabled for this virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vm agent is enabled for windows VM', function(done) { + const cache = createCache([virtualMachines[2]]); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM Agent is enabled for this virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if vm agent is disabled for windows VM', function(done) { + const cache = createCache([virtualMachines[3]]); + vmAgentEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM Agent is not enabled for this virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmAutoUpdateEnabled.js b/plugins/azure/virtualmachines/vmAutoUpdateEnabled.js index 37b4a26a8..92a3eb808 100644 --- a/plugins/azure/virtualmachines/vmAutoUpdateEnabled.js +++ b/plugins/azure/virtualmachines/vmAutoUpdateEnabled.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Auto Update Enabled', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that VM Auto Update is enabled for virtual machines', more_info: 'Enabling Auto Update on Azure virtual machines reduces the security risk of missing security patches.', recommended_action: 'Enable VM auto update on all virtual machines', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machines/windows-or-linux/maintenance-and-updates', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows-or-linux/maintenance-and-updates', apis: ['virtualMachines:listAll'], compliance: { pci: 'PCI requires all system components have the latest updates ' + 'and patches installed within a month of release.' }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/virtualmachines/vmAvailabilitySetEnabled.js b/plugins/azure/virtualmachines/vmAvailabilitySetEnabled.js index 012589a97..e8f52231a 100644 --- a/plugins/azure/virtualmachines/vmAvailabilitySetEnabled.js +++ b/plugins/azure/virtualmachines/vmAvailabilitySetEnabled.js @@ -4,11 +4,14 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'VM Availability Set Enabled', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', description: 'Ensures that Virtual Machines have Availability Set enabled', more_info: 'Enabling Availability Sets ensures that during either a planned or unplanned maintenance event, the virtual machine will still be available.', recommended_action: 'Virtual Machine Availability Sets can only be configured when creating a new virtual machine. Recreate the Virtual Machine with Availability Sets enabled.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machines/windows/manage-availability', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/manage-availability', apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/virtualmachines/vmAvailabilitySetLimit.js b/plugins/azure/virtualmachines/vmAvailabilitySetLimit.js index 48e782f0e..d646d5ac4 100644 --- a/plugins/azure/virtualmachines/vmAvailabilitySetLimit.js +++ b/plugins/azure/virtualmachines/vmAvailabilitySetLimit.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Availability Set Limit', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', description: 'Determine if the number of VM instances is close to the Azure per-availability set limit', more_info: 'Azure limits availability sets to certain numbers of resources. Exceeding those limits could prevent resources from launching.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machines/windows/overview', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/overview', recommended_action: 'Contact Azure support to increase the number of instances available', - apis: ['resourceGroups:list', 'availabilitySets:listBySubscription'], + apis: ['resourceGroups:list', 'availabilitySets:listByResourceGroup'], settings: { instance_limit_percentage_fail: { name: 'Instance Limit Percentage Fail', @@ -23,6 +25,7 @@ module.exports = { default: 75 } }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], run: function(cache, settings, callback) { var config = { @@ -36,49 +39,66 @@ module.exports = { var source = {}; var locations = helpers.locations(settings.govcloud); - async.each(locations.availabilitySets, function(location, rcb){ + async.each(locations.resourceGroups, function(location, rcb){ + var resourceGroups = helpers.addSource(cache, source, + ['resourceGroups', 'list', location]); - var availabilitySets = helpers.addSource(cache, source, - ['availabilitySets', 'listBySubscription', location]); + if (!resourceGroups) return rcb(); - if (!availabilitySets) return rcb(); - - if (availabilitySets.err || !availabilitySets.data) { + if (resourceGroups.err || !resourceGroups.data) { helpers.addResult(results, 3, - 'Unable to query Availability Sets: ' + helpers.addError(availabilitySets), location); + 'Unable to query Resource Groups: ' + helpers.addError(resourceGroups), location); return rcb(); } - if (!availabilitySets.data.length) { - helpers.addResult(results, 0, 'No existing Availability Sets', location); + if (!resourceGroups.data.length) { + helpers.addResult(results, 0, 'No existing Resource Groups', location); return rcb(); } - var limits = { - 'max-instances': 200 - }; + async.each(resourceGroups.data, function(resourceGroup, scb){ + var availabilitySets = helpers.addSource(cache, source, + ['availabilitySets', 'listByResourceGroup', location, resourceGroup.id]); - availabilitySets.data.forEach(availabilitySet => { - if (availabilitySet.virtualMachines) { - var vmInstances = availabilitySet.virtualMachines.length; - } else { - return; + if (!availabilitySets || availabilitySets.err || !availabilitySets.data) { + helpers.addResult(results, 3, + 'Unable to query Availability Sets: ' + helpers.addError(availabilitySets), location); + return scb(); } - var percentage = Math.ceil((vmInstances / limits['max-instances']) * 100); - var returnMsg = 'Availability Set contains ' + vmInstances + ' of ' + - limits['max-instances'] + ' (' + percentage + '%) available instances'; - - if (percentage >= config.instance_limit_percentage_fail) { - helpers.addResult(results, 2, returnMsg, location, availabilitySet.id); - } else if (percentage >= config.instance_limit_percentage_warn) { - helpers.addResult(results, 1, returnMsg, location, availabilitySet.id); - } else { - helpers.addResult(results, 0, returnMsg, location, availabilitySet.id); + if (!availabilitySets.data.length) { + helpers.addResult(results, 0, 'No existing Availability Sets', location); + return scb(); } - }); - rcb(); + var limits = { + 'max-instances': 200 + }; + + availabilitySets.data.forEach(availabilitySet => { + if (availabilitySet.virtualMachines) { + var vmInstances = availabilitySet.virtualMachines.length; + } else { + return; + } + + var percentage = Math.ceil((vmInstances / limits['max-instances']) * 100); + var returnMsg = 'Availability Set contains ' + vmInstances + ' of ' + + limits['max-instances'] + ' (' + percentage + '%) available instances'; + + if (percentage >= config.instance_limit_percentage_fail) { + helpers.addResult(results, 2, returnMsg, location, availabilitySet.id); + } else if (percentage >= config.instance_limit_percentage_warn) { + helpers.addResult(results, 1, returnMsg, location, availabilitySet.id); + } else { + helpers.addResult(results, 0, returnMsg, location, availabilitySet.id); + } + }); + + scb(); + }, function(){ + rcb(); + }); }, function(){ callback(null, results, source); }); diff --git a/plugins/azure/virtualmachines/vmAvailabilitySetLimit.spec.js b/plugins/azure/virtualmachines/vmAvailabilitySetLimit.spec.js new file mode 100644 index 000000000..65f0e259c --- /dev/null +++ b/plugins/azure/virtualmachines/vmAvailabilitySetLimit.spec.js @@ -0,0 +1,147 @@ +var expect = require('chai').expect; +var vmAvailabilitySetLimit = require('./vmAvailabilitySetLimit'); + +const resourceGroups = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group', + 'name': 'khulnasoft-resource-group', + 'type': 'Microsoft.Resources/resourceGroups', + 'location': 'eastus', + 'tags': {}, + 'properties': { + 'provisioningState': 'Succeeded' + } + } +]; + +const availabilitySets = [ + { + 'name': 'test-set', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/availabilitySets/test-set', + 'type': 'Microsoft.Compute/availabilitySets', + 'location': 'eastus', + 'tags': {}, + 'platformUpdateDomainCount': 5, + 'platformFaultDomainCount': 2, + 'virtualMachines': [ + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm-1' + } + ], + 'sku': { + 'name': 'Aligned' + } + } +]; + +const createCache = (resourceGroups, availabilitySets) => { + let group = {}; + let set = {}; + if (resourceGroups) { + group['data'] = resourceGroups; + if (resourceGroups.length && availabilitySets) { + set[resourceGroups[0].id] = { + data: availabilitySets + }; + } + } + return { + resourceGroups: { + list: { + 'eastus': group + } + }, + availabilitySets: { + listByResourceGroup: { + 'eastus': set + } + } + }; +}; + +describe('vmAvailabilitySetLimit', function() { + describe('run', function() { + it('should give passing result if No existing resource groups', function(done) { + const cache = createCache([]); + vmAvailabilitySetLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Resource Groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for resource groups', function(done) { + const cache = createCache(); + vmAvailabilitySetLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Resource Groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if No existing Availability Sets', function(done) { + const cache = createCache([resourceGroups[0]], []); + vmAvailabilitySetLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Availability Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for availability sets', function(done) { + const cache = createCache([resourceGroups[0]]); + vmAvailabilitySetLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Availability Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vm instances percentage are below the limits', function(done) { + const cache = createCache([resourceGroups[0]], [availabilitySets[0]]); + vmAvailabilitySetLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Availability Set contains 1 of 200 (1%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing results if number of vm instances has reached the fail limit', function(done) { + const cache = createCache([resourceGroups[0]], [availabilitySets[0]]); + const settings = { + instance_limit_percentage_fail: 1 + }; + vmAvailabilitySetLimit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Availability Set contains 1 of 200 (1%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give warn result if number of vm instances has reached the warn limit', function(done) { + const cache = createCache([resourceGroups[0]], [availabilitySets[0]]); + const settings = { + instance_limit_percentage_warn: 1 + }; + vmAvailabilitySetLimit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('Availability Set contains 1 of 200 (1%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmBackupsEnabled.js b/plugins/azure/virtualmachines/vmBackupsEnabled.js new file mode 100644 index 000000000..8ed78000e --- /dev/null +++ b/plugins/azure/virtualmachines/vmBackupsEnabled.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Backups Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure virtual machine backups are enabled.', + more_info: 'Azure Backup provides independent and isolated backups to guard against unintended destruction of the data on your VMs.', + recommended_action: 'Enable Azure virtual machine backups', + link: 'https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction', + apis: ['virtualMachines:listAll', 'recoveryServiceVaults:listBySubscriptionId', 'backupProtectedItems:listByVault'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftrecoveryservices:vaults:write', 'microsoftrecoveryservices:vaults:delete', 'microsoftrecoveryservices:vaults:backupfabrics:protectioncontainers:protecteditems:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + const virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + const recoveryVaults = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'listBySubscriptionId', location]); + + if (!recoveryVaults || recoveryVaults.err || !recoveryVaults.data) { + helpers.addResult(results, 3, 'Unable to query for backup vaults: ' + helpers.addError(recoveryVaults), location); + return rcb(); + } + + if (!recoveryVaults.data.length) { + helpers.addResult(results, 2, 'No backup vaults found', location); + return rcb(); + } + + const vmPoliciesMap = new Map(); + + for (const vault of recoveryVaults.data) { + const backupProtectedItems = helpers.addSource(cache, source, + ['backupProtectedItems', 'listByVault', location, vault.id]); + + if (!backupProtectedItems || backupProtectedItems.err || !backupProtectedItems.data) { + helpers.addResult(results, 3, 'Unable to query for backups : ' + helpers.addError(backupProtectedItems), location); + return rcb(); + } + + for (const bpItem of backupProtectedItems.data) { + if (bpItem.virtualMachineId && vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase())) { + vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase()).push(bpItem.policyId); + } else if (bpItem.virtualMachineId) { + vmPoliciesMap.set(bpItem.virtualMachineId.toLowerCase(), []); + vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase()).push(bpItem.policyId); + } + } + } + + for (const virtualMachine of virtualMachines.data) { + const vmPolicies = vmPoliciesMap.get(virtualMachine.id.toLowerCase()); + + let vmBackupsEnabled = false; + if (vmPolicies && vmPolicies.length) { + vmBackupsEnabled = vmPolicies.some(policy => (policy && policy.length)); + } + + if (vmBackupsEnabled) { + helpers.addResult(results, 0, 'Azure virtual machine has backups enabled', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Azure virtual machine does not have backups enabled', location, virtualMachine.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmBackupsEnabled.spec.js b/plugins/azure/virtualmachines/vmBackupsEnabled.spec.js new file mode 100644 index 000000000..7a9dc9944 --- /dev/null +++ b/plugins/azure/virtualmachines/vmBackupsEnabled.spec.js @@ -0,0 +1,150 @@ +var expect = require('chai').expect; +var vmBackupsEnabled = require('./vmBackupsEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const recoveryVaults = [ + { + 'name': 'test-vault', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault', + 'type': 'Microsoft.RecoveryServices/vaults' + } +]; + +const backupProtectedItems = [ + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + } +]; + +const createCache = (virtualMachines, recoveryVaults, backupProtectedItem) => { + let machines = {}; + let vaults = {}; + let protectedItems = {}; + if (virtualMachines) { + machines['data'] = virtualMachines; + } + if (recoveryVaults) { + vaults['data'] = recoveryVaults; + if (recoveryVaults.length && backupProtectedItem) { + protectedItems[recoveryVaults[0].id] = { + 'data': backupProtectedItem + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machines + } + }, + recoveryServiceVaults: { + listBySubscriptionId: { + 'eastus': vaults + } + }, + backupProtectedItems: { + listByVault: { + 'eastus': protectedItems + } + } + }; +}; + +describe('vmBackupsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]], []); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]]); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup vaults'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup product items', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]]); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup product items found for virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[1]]); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure virtual machine does not have backups enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if backups are enabled', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]]); + vmBackupsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure virtual machine has backups enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.js b/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.js new file mode 100644 index 000000000..42efbc81e --- /dev/null +++ b/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.js @@ -0,0 +1,52 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Virtual Machine Boot Diagnostics Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that the VM boot diagnostics is enabled for virtual machines.', + more_info: 'Boot diagnostics is a debugging feature for Azure virtual machines (VM) that allows diagnosis of VM boot failures. Boot diagnostics enables a user to observe the state of their VM as it is booting up by collecting serial log information and screenshots.', + recommended_action: 'Enable boot diagnostics for all virtual machines.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/boot-diagnostics', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb){ + + var virtualMachines = helpers.addSource(cache, source, ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.diagnosticsProfile && virtualMachine.diagnosticsProfile.bootDiagnostics && + virtualMachine.diagnosticsProfile.bootDiagnostics.enabled) { + helpers.addResult(results, 0, 'Virtual machine has boot diagnostics enabled', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Virtual machine does not have boot diagnostics enabled', location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.spec.js b/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.spec.js new file mode 100644 index 000000000..ee712bb52 --- /dev/null +++ b/plugins/azure/virtualmachines/vmBootDiagnosticsEnabled.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var vmBootDiagnosticsEnabled = require('./vmBootDiagnosticsEnabled'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'diagnosticsProfile': { + 'bootDiagnostics': { + 'enabled': true + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'diagnosticsProfile': { + 'bootDiagnostics': { + 'enabled': false + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmBootDiagnosticsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + vmBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Boot Diagnostics is enabled for virtual machine', function(done) { + const cache = createCache([virtualMachines[0]]); + vmBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual machine has boot diagnostics enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Boot Diagnostics is disabled for virtual machine', function(done) { + const cache = createCache([virtualMachines[1]]); + vmBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual machine does not have boot diagnostics enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDailyBackupRetention.js b/plugins/azure/virtualmachines/vmDailyBackupRetention.js new file mode 100644 index 000000000..56f8d3059 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDailyBackupRetention.js @@ -0,0 +1,126 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Daily Backup Retention Period', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that VM daily backup retention policy is configured to retain backups for the desired number of days.', + more_info: 'Azure Backup provides independent and isolated backups to guard against unintended destruction of the data on your VMs. These backups should be retained for a specific amount of time to recover destroyed VM.', + recommended_action: 'Configure virtual machine daily backup retention policy to retain backups for desired number of days', + link: 'https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction', + apis: ['virtualMachines:listAll', 'recoveryServiceVaults:listBySubscriptionId', 'backupProtectedItems:listByVault', 'backupPolicies:listByVault'], + settings: { + vm_daily_backup_retention_period: { + name: 'VM Daily Backup Retention Period', + description: 'Number of days that a VM backup will be retained until it is permanently deleted (7 or above)', + regex: '^([7-9]|1[0-9]|2[0-9]|30)$', + default: '30' + } + }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftrecoveryservices:vaults:write', 'microsoftrecoveryservices:vaults:delete', 'microsoftrecoveryservices:vaults:backupfabrics:protectioncontainers:protecteditems:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + retentionPeriod: parseInt(settings.vm_daily_backup_retention_period || this.settings.vm_daily_backup_retention_period.default) + }; + + async.each(locations.virtualMachines, function(location, rcb) { + const virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + const recoveryVaults = helpers.addSource(cache, source, + ['recoveryServiceVaults', 'listBySubscriptionId', location]); + + if (!recoveryVaults || recoveryVaults.err || !recoveryVaults.data) { + helpers.addResult(results, 3, 'Unable to query for backup recovery vaults: ' + helpers.addError(recoveryVaults), location); + return rcb(); + } + + if (!recoveryVaults.data.length) { + helpers.addResult(results, 2, 'No backup recovery vaults found', location); + return rcb(); + } + + const vmPoliciesMap = new Map(); + const backupPoliciesMap = new Map(); + + for (const vault of recoveryVaults.data) { + const backupProtectedItems = helpers.addSource(cache, source, + ['backupProtectedItems', 'listByVault', location, vault.id]); + + if (!backupProtectedItems || backupProtectedItems.err || !backupProtectedItems.data) { + helpers.addResult(results, 3, 'Unable to query for backup retention policies : ' + helpers.addError(backupProtectedItems), location); + return rcb(); + } + + const backupPolicies = helpers.addSource(cache, source, + ['backupPolicies', 'listByVault', location, vault.id]); + + if (!backupPolicies || backupPolicies.err || !backupPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for backup retention policies : ' + helpers.addError(backupPolicies), location); + return rcb(); + } + + for (const bpItem of backupProtectedItems.data) { + if (bpItem.virtualMachineId && vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase())) { + vmPoliciesMap.get(bpItem.virtualMachineId.toLowerCase()).push(bpItem.policyId); + } else if (bpItem.virtualMachineId) { + vmPoliciesMap.set(bpItem.virtualMachineId.toLowerCase(), [bpItem.policyId]); + } + } + + for (const backupPolicy of backupPolicies.data) { + backupPoliciesMap.set(backupPolicy.id, backupPolicy); + } + } + + async.each(virtualMachines.data, function(virtualMachine, scb) { + const vmPolicies = vmPoliciesMap.get(virtualMachine.id.toLowerCase()); + if (vmPolicies && vmPolicies.length) { + let retentionDays = 0; + for (const vmPolicy of vmPolicies) { + const backupPolicy = backupPoliciesMap.get(vmPolicy); + + if (backupPolicy && backupPolicy.retentionPolicy && backupPolicy.retentionPolicy.dailySchedule && + backupPolicy.retentionPolicy.dailySchedule.retentionDuration && backupPolicy.retentionPolicy.dailySchedule.retentionDuration.count && + backupPolicy.retentionPolicy.dailySchedule.retentionDuration.count > retentionDays) { + retentionDays = backupPolicy.retentionPolicy.dailySchedule.retentionDuration.count; + } + } + + if (retentionDays >= config.retentionPeriod) { + helpers.addResult(results, 0, `VM daily backups are configured to be retained for ${retentionDays} of ${config.retentionPeriod} days desired limit`, location, virtualMachine.id); + } else { + helpers.addResult(results, 2, `VM daily backups are configured to be retained for ${retentionDays} of ${config.retentionPeriod} days desired limit`, location, virtualMachine.id); + } + } else { + helpers.addResult(results, 2, 'No backup policies are configured for the virtual machine', location, virtualMachine.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDailyBackupRetention.spec.js b/plugins/azure/virtualmachines/vmDailyBackupRetention.spec.js new file mode 100644 index 000000000..69d698763 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDailyBackupRetention.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +var dailyBackupRetention = require('./vmDailyBackupRetention'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const recoveryVaults = [ + { + 'name': 'test-vault', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault', + 'type': 'Microsoft.RecoveryServices/vaults' + } +]; + +const backupProtectedItems = [ + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2/protectedItems/VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'name': 'VM;iaasvmcontainerv2;KHULNASOFT-RESOURCE-GROUP;test-vm-2', + 'type': 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems', + 'virtualMachineId': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'policyId': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy' + } +]; + +const backupPolicy = [ + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy', + 'name': 'DailyPolicy', + 'type': 'Microsoft.RecoveryServices/vaults/backupPolicies', + 'retentionPolicy': { + 'retentionPolicyType': 'LongTermRetentionPolicy', + 'dailySchedule': { + 'retentionDuration': { + 'count': 30, + 'durationType': 'Days' + } + } + } + }, + { + 'id': '/Subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.RecoveryServices/vaults/test-vault/backupPolicies/DailyPolicy', + 'name': 'DailyPolicy', + 'type': 'Microsoft.RecoveryServices/vaults/backupPolicies', + 'retentionPolicy': { + 'retentionPolicyType': 'LongTermRetentionPolicy', + 'dailySchedule': { + 'retentionDuration': { + 'count': 14, + 'durationType': 'Days' + } + } + } + } +]; + +const createCache = (virtualMachines, recoveryVaults, backupProtectedItem, backupPolicies) => { + let machines = {}; + let vaults = {}; + let protectedItems = {}; + let policy = {}; + if (virtualMachines) { + machines['data'] = virtualMachines; + } + if (recoveryVaults) { + vaults['data'] = recoveryVaults; + if (recoveryVaults.length && backupProtectedItem) { + protectedItems[recoveryVaults[0].id] = { + 'data': backupProtectedItem + }; + } + + if (recoveryVaults.length && backupPolicies) { + policy[recoveryVaults[0].id] = { + 'data': backupPolicies + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machines + } + }, + recoveryServiceVaults: { + listBySubscriptionId: { + 'eastus': vaults + } + }, + backupProtectedItems: { + listByVault: { + 'eastus': protectedItems + } + }, + backupPolicies: { + listByVault: { + 'eastus': policy + } + } + }; +}; + +describe('dailyBackupRetention', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]], []); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup recovery vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup recovery vaults', function(done) { + const cache = createCache([virtualMachines[0]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup recovery vaults'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup product items', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup retention policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for backup retention policies', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for backup retention policies'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no backup product items found for virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[1]], [backupPolicy[0]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No backup policies are configured for the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if backup retention period is configured', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]], [backupPolicy[0]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM daily backups are configured to be retained for 30 of 30 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if backup retention period is not configured', function(done) { + const cache = createCache([virtualMachines[0]], [recoveryVaults[0]], [backupProtectedItems[0]], [backupPolicy[1]]); + dailyBackupRetention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM daily backups are configured to be retained for 14 of 30 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskCMKRotation.js b/plugins/azure/virtualmachines/vmDiskCMKRotation.js new file mode 100644 index 000000000..be04f42e5 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskCMKRotation.js @@ -0,0 +1,67 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Disk CMK Rotation', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that customer-managed keys (CMK) are automatically rotated for virtual machine disks.', + more_info: 'Automatic key rotation helps ensure your keys are secure. A disk references a key via its disk encryption set. When you enable automatic rotation for a disk encryption set, the system will automatically update all managed disks, snapshots, and images referencing the disk encryption set to use the new version of the key within one hour.', + recommended_action: 'Enable automatic key rotation for all VM disk encryption sets.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption#automatic-key-rotation-of-customer-managed-keys', + apis: ['disks:list','diskEncryptionSet:get'], + realtime_triggers: ['microsoftcompute:disks:write','microsoftcompute:disks:delete','microsoftcompute:diskencryptionsets:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disk volumes found', location); + return rcb(); + } + + + disks.data.forEach((disk) => { + if (!disk.id) return; + + if (disk.encryption && disk.encryption.type && + disk.encryption.type.toLowerCase() === 'encryptionatrestwithplatformkey') { + helpers.addResult(results, 0, 'Disk is encrypted using a platform managed key', location, disk.id); + + } else { + if (disk.encryption && disk.encryption.diskEncryptionSetId) { + + var diskEncryptionSet = helpers.addSource(cache, source, ['diskEncryptionSet', 'get', location, disk.id]); + + if (diskEncryptionSet && diskEncryptionSet.data && diskEncryptionSet.data.rotationToLatestKeyVersionEnabled) { + helpers.addResult(results, 0, 'Disk has automatic key rotation enabled', location, disk.id); + + } else { + helpers.addResult(results, 2, 'Disk does not have automatic key rotation enabled', location, disk.id); + } + } + + + } + }); + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskCMKRotation.spec.js b/plugins/azure/virtualmachines/vmDiskCMKRotation.spec.js new file mode 100644 index 000000000..5a180ea80 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskCMKRotation.spec.js @@ -0,0 +1,109 @@ +var expect = require('chai').expect; +var vmDiskAutoKeyRotationCMK = require('./vmDiskCMKRotation'); + +const disks = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk", + "name": "test-disk", + "location": "eastus", + "encryption": { + "type": "EncryptionAtRestWithPlatformKey" + } + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk-cmk", + "name": "test-disk-cmk", + "location": "eastus", + "encryption": { + "diskEncryptionSetId": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/diskEncryptionSets/test-disk-es" + } + } +]; + +const diskEncryptionSet = { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/diskEncryptionSets/test-disk-es", + "name": "test-disk-es", + "location": "eastus", + "rotationToLatestKeyVersionEnabled": true +}; + +const createCache = (disks, diskEncryptionSet) => { + const diskId = (disks && disks.length) ? disks[0].id : null; + return { + disks: { + list: { + 'eastus': { + data: disks + } + } + }, + diskEncryptionSet: { + get: { + 'eastus': { + [diskId]: { + data: diskEncryptionSet + } + } + } + } + }; +}; + +describe('vmDiskAutoKeyRotationCMK', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([], null); + vmDiskAutoKeyRotationCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(null, null); + vmDiskAutoKeyRotationCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk volumes:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if disk is using platform managed key', function(done) { + const cache = createCache([disks[0]], null); + vmDiskAutoKeyRotationCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk is encrypted using a platform managed key'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if disk auto key rotation for customer managed key is enabled', function(done) { + const cache = createCache([disks[1]], diskEncryptionSet); + vmDiskAutoKeyRotationCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk has automatic key rotation enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if disk auto key rotation for customer managed key is disabled', function(done) { + const cache = createCache([disks[1]], { rotationToLatestKeyVersionEnabled: false }); + vmDiskAutoKeyRotationCMK.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk does not have automatic key rotation enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmDiskDataEncryption.js b/plugins/azure/virtualmachines/vmDiskDataEncryption.js index ff2da2a94..a70c4836a 100644 --- a/plugins/azure/virtualmachines/vmDiskDataEncryption.js +++ b/plugins/azure/virtualmachines/vmDiskDataEncryption.js @@ -5,10 +5,12 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Data Disk Encryption', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', description: 'Ensure that Data Disk Encryption is enabled for virtual machines', more_info: 'Encrypting VM Data Disks (non-boot volume) ensures that its entire contents are fully unrecoverable without a key, protecting the volume from unwarranted reads', recommended_action: 'Enable VM Data Disk Encryption on all virtual machines', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-apply-disk-encryption', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-apply-disk-encryption', apis: ['disks:list'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + @@ -17,6 +19,7 @@ module.exports = { 'Encryption should be enabled for all VM disks storing this ' + 'type of data.' }, + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], run: function(cache, settings, callback) { var results = []; @@ -38,7 +41,7 @@ module.exports = { helpers.addResult(results, 0, 'No existing disks found', location); } else { var found = false; - for(var i in disks.data) { + for (var i in disks.data) { var disk = disks.data[i]; if (disk.name && disk.name.length && diff --git a/plugins/azure/virtualmachines/vmDiskDataEncryption.spec.js b/plugins/azure/virtualmachines/vmDiskDataEncryption.spec.js new file mode 100644 index 000000000..a785b01f4 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskDataEncryption.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var vmDiskDataEncryption = require('./vmDiskDataEncryption'); + +const disks = [ + { + 'name': 'test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + }, + { + 'name': 'test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'type': 'Microsoft.Compute/disks' + }, + { + 'name': 'test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + } +]; + +const createCache = (disks) => { + let disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('vmDiskDataEncryption', function() { + describe('run', function() { + it('should give passing result if no disks', function(done) { + const cache = createCache([]); + vmDiskDataEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disks', function(done) { + const cache = createCache(); + vmDiskDataEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no Data disks found', function(done) { + const cache = createCache([disks[2]]); + vmDiskDataEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No data disks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Data disk encryption is enabled', function(done) { + const cache = createCache([disks[0]]); + vmDiskDataEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Data disk encryption is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Data disk encryption is disabled', function(done) { + const cache = createCache([disks[1]]); + vmDiskDataEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Data disk encryption is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskDeleteConfig.js b/plugins/azure/virtualmachines/vmDiskDeleteConfig.js new file mode 100644 index 000000000..2c77c93f7 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskDeleteConfig.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Disks Deletion Config', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensure the option to automatically delete disks is enabled when the associated VM is terminated.', + more_info: 'Disks persist independently from VMs. Enabling this option ensures that all disks associated with a VM are deleted automatically when the VM is terminated, enhancing security.', + recommended_action: 'Configure VMs to automatically delete disks when the VM is terminated.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/delete?tabs=portal2%2Ccli3%2Cportal4%2Cportal5', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.storageProfile && virtualMachine.storageProfile.osDisk && virtualMachine.storageProfile.osDisk.deleteOption && virtualMachine.storageProfile.osDisk.deleteOption === 'Delete') { + helpers.addResult(results, 0, 'Automatic disks delete with VM is enabled', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Automatic disks delete with VM is not enabled', location, virtualMachine.id); + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmDiskDeleteConfig.spec.js b/plugins/azure/virtualmachines/vmDiskDeleteConfig.spec.js new file mode 100644 index 000000000..883087094 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskDeleteConfig.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var autoDeleteDisks = require('./vmDiskDeleteConfig'); + +const virtualMachines = [ + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'name': 'test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'deleteOption': 'Delete' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'name': 'test-vm-2', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'deleteOption': 'Detach' + } + } + } +]; + +const createCache = (virtualMachines) => { + let vm = {}; + if (virtualMachines) { + vm['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': vm + } + } + }; +}; + +describe('autoDeleteDisks', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + autoDeleteDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + autoDeleteDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if auto-delete disks is configured', function(done) { + const cache = createCache([virtualMachines[0]]); + autoDeleteDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automatic disks delete with VM is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if auto-delete disks is not configured', function(done) { + const cache = createCache([virtualMachines[1]]); + autoDeleteDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automatic disks delete with VM is not enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmDiskDoubleEncryption.js b/plugins/azure/virtualmachines/vmDiskDoubleEncryption.js new file mode 100644 index 000000000..6ef82579c --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskDoubleEncryption.js @@ -0,0 +1,59 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Disk Double Encryption', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that VM disks are encrypted at rest using both platform and customer managed keys.', + more_info: 'Using double encryption for VM disks adds an extra layer of protection using a different encryption algorithm/mode at the infrastructure layer using platform managed encryption keys and provides an additional level of security if one of the keys is compromised.', + recommended_action: 'Recreate VM disks with double encryption enabled.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption#double-encryption-at-rest', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for disks: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disks found', location); + return rcb(); + } + for (let disk of disks.data) { + if (!disk.id) continue; + + if (disk.encryption && disk.encryption.type && disk.encryption.type.toLowerCase() === 'encryptionatrestwithplatformandcustomerkeys'){ + helpers.addResult(results, 0, 'VM disk is double encrypted using both platform and customer managed keys', location, disk.id); + } else { + let message = 'VM disk does not have double encryption enabled'; + if (disk.encryption && disk.encryption.type) { + if (disk.encryption.type.toLowerCase() === 'encryptionatrestwithcustomerkey') { + message = 'VM disk is encrypted using only customer managed key'; + } else if (disk.encryption.type.toLowerCase() === 'encryptionatrestwithplatformkey') { + message = 'VM disk is encrypted using only platform managed key'; + } + } + helpers.addResult(results, 2, message, location, disk.id); + } + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskDoubleEncryption.spec.js b/plugins/azure/virtualmachines/vmDiskDoubleEncryption.spec.js new file mode 100644 index 000000000..fe8d84104 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskDoubleEncryption.spec.js @@ -0,0 +1,84 @@ +var expect = require('chai').expect; +var vmDiskDoubleEncryption = require('./vmDiskDoubleEncryption'); + +const disks = [ + { + 'name': 'test-vm_disk1_151553523', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_disk1_151553523', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformAndCustomerKeys' + } + }, + + { + 'name': 'test-vm_OsDisk_1_53523231', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_53523231', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + } +]; + +const createCache = (disks) => { + let disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('vmDiskDoubleEncryption', function() { + describe('run', function() { + it('should give passing result if no disks', function(done) { + const cache = createCache([]); + vmDiskDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disks', function(done) { + const cache = createCache(); + vmDiskDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if double encryption is enabled', function(done) { + const cache = createCache([disks[0]]); + vmDiskDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk is double encrypted using both platform and customer managed keys'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if double encryption is not enabled', function(done) { + const cache = createCache([disks[1]]); + vmDiskDoubleEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM disk is encrypted using only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskHasTags.js b/plugins/azure/virtualmachines/vmDiskHasTags.js new file mode 100644 index 000000000..9a760cbf2 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Disk Has Tags', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Azure virtual machine disks have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify VM Disk and add tags.', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disk volumes found', location); + return rcb(); + } + for (let disk of disks.data) { + if (!disk.id) continue; + + if (disk.tags && Object.entries(disk.tags).length > 0){ + helpers.addResult(results, 0, 'VM disk has tags', location, disk.id); + } else { + helpers.addResult(results, 2, 'VM disk does not have tags', location, disk.id); + } + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskHasTags.spec.js b/plugins/azure/virtualmachines/vmDiskHasTags.spec.js new file mode 100644 index 000000000..dcd4618c3 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskHasTags.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var diskHasTags = require('./vmDiskHasTags'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'tags': {'key': 'test'}, + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + } + + }, +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('diskHasTags', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + diskHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(); + diskHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk volumes'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume has BYOK encryption enabled only', function(done) { + const cache = createCache([disks[0]]); + diskHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM disk has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk volume has BYOK encryption disabled', function(done) { + const cache = createCache([disks[1]]); + diskHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM disk does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskOSEncryption.js b/plugins/azure/virtualmachines/vmDiskOSEncryption.js index ab9c4a8de..bd7b65f73 100644 --- a/plugins/azure/virtualmachines/vmDiskOSEncryption.js +++ b/plugins/azure/virtualmachines/vmDiskOSEncryption.js @@ -5,10 +5,12 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM OS Disk Encryption', category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', description: 'Ensures that VM OS Disk Encryption is enabled for virtual machines', more_info: 'Encrypting VM OS disks (boot volume) ensures that the entire contents are fully unrecoverable without a key, protecting the volume from unwarranted reads.', recommended_action: 'Enable VM OS Disk Encryption on all virtual machines', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-apply-disk-encryption', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-apply-disk-encryption', apis: ['disks:list'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + @@ -17,6 +19,7 @@ module.exports = { 'Encryption should be enabled for all VM OS disks storing this ' + 'type of data.' }, + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], run: function(cache, settings, callback) { var results = []; @@ -36,7 +39,7 @@ module.exports = { helpers.addResult(results, 0, 'No existing disks found', location); } else { var found = false; - for(var i in disks.data) { + for (var i in disks.data) { var disk = disks.data[i]; if (disk.name && disk.name.length && diff --git a/plugins/azure/virtualmachines/vmDiskOSEncryption.spec.js b/plugins/azure/virtualmachines/vmDiskOSEncryption.spec.js new file mode 100644 index 000000000..7db17fa2c --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskOSEncryption.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var vmDiskOSEncryption = require('./vmDiskOSEncryption'); + +const disks = [ + { + 'name': 'test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_disk1_8fa6d20523294d6d808958c906551aa5', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + }, + { + 'name': 'test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'type': 'Microsoft.Compute/disks' + }, + { + 'name': 'test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'id': '/subscriptions/123/resourceGroups/AKHTAR-RG/providers/Microsoft.Compute/disks/test-vm_OsDisk_1_e6b7c388f6e0463a8a626a57fce96801', + 'type': 'Microsoft.Compute/disks', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } + } +]; + +const createCache = (disks) => { + let disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('vmDiskOSEncryption', function() { + describe('run', function() { + it('should give passing result if no disks', function(done) { + const cache = createCache([]); + vmDiskOSEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disks', function(done) { + const cache = createCache(); + vmDiskOSEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no OS disks found', function(done) { + const cache = createCache([disks[0]]); + vmDiskOSEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No OS disks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if OS disk encryption is enabled', function(done) { + const cache = createCache([disks[2]]); + vmDiskOSEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OS disk encryption is enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if OS disk encryption is disabled', function(done) { + const cache = createCache([disks[1]]); + vmDiskOSEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OS disk encryption is disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskPublicAccess.js b/plugins/azure/virtualmachines/vmDiskPublicAccess.js new file mode 100644 index 000000000..9216c3a65 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskPublicAccess.js @@ -0,0 +1,59 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Disk Public Access', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that Azure virtual machine disks are not accessible publicly.', + more_info: 'Private endpoints safeguard against unauthorized access and cyber threats, preserving the integrity and confidentiality of your data while aligning with compliance and security best practices by restricting the export and import of managed disks and only allowing access over a private link from clients on your Azure virtual network.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disks-enable-private-links-for-import-export-portal', + recommended_action: 'Disable public access for all Azure virtual machine disks.', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing disk volumes found', location); + return rcb(); + } + for (let disk of disks.data) { + if (!disk.id) continue; + + if (disk.networkAccessPolicy) { + if (disk.networkAccessPolicy.toLowerCase() === 'allowall') { + helpers.addResult(results, 2, 'Disk is publicly accessible', location, disk.id); + + } else if (disk.networkAccessPolicy.toLowerCase() === 'allowprivate') { + helpers.addResult(results, 0, 'Disk is not publicly accessible', location, disk.id); + + } else { + helpers.addResult(results, 0, 'Disk is not publicly or privately accessible', location, disk.id); + + } + } + + + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmDiskPublicAccess.spec.js b/plugins/azure/virtualmachines/vmDiskPublicAccess.spec.js new file mode 100644 index 000000000..7999a7638 --- /dev/null +++ b/plugins/azure/virtualmachines/vmDiskPublicAccess.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var vmDiskPublicAccess = require('./vmDiskPublicAccess'); + +const disks = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk", + "name": "test-disk", + "location": "eastus", + "networkAccessPolicy": "AllowAll" + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk-private", + "name": "test-disk-private", + "location": "eastus", + "networkAccessPolicy": "AllowPrivate" + }, + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk-none", + "name": "test-disk-none", + "location": "eastus", + "networkAccessPolicy": "DenyAll" + } +]; + +const createCache = (disks) => { + return { + disks: { + list: { + 'eastus': { + data: disks + } + } + } + }; +}; + +describe('vmDiskPublicAccess', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + vmDiskPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(null); + vmDiskPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine disk volumes:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if disk is publicly accessible', function(done) { + const cache = createCache([disks[0]]); + vmDiskPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if disk is privately accessible using private endpoints', function(done) { + const cache = createCache([disks[1]]); + vmDiskPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if disk is not publicly or privately accessible', function(done) { + const cache = createCache([disks[2]]); + vmDiskPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk is not publicly or privately accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmEncryptionAtHost.js b/plugins/azure/virtualmachines/vmEncryptionAtHost.js new file mode 100644 index 000000000..a0e4e0bb9 --- /dev/null +++ b/plugins/azure/virtualmachines/vmEncryptionAtHost.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Encryption At Host', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Encryption at host ensures that data on Azure Virtual Machine disks- including temporary and cached data- is encrypted at the physical host level before being persisted. This provides end-to-end encryption independent of the guest OS, and does not require Azure Disk Encryption (ADE). Enabling this setting can help meet certain compliance and data residency requirements.', + more_info: 'The data for temporary disk and OS/data disk caches is stored on the VM host. Enabling encryption at host for Azure Virtual Machine disks allows the data to be end-to-end encrypted, ensuring compliance and bolstering overall security with Azure Disk Encryption.', + recommended_action: 'Ensure that all Azure Virtual Machines have encryption at host enabled for disks.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption#encryption-at-host---end-to-end-encryption-for-your-vm-data', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.securityProfile && virtualMachine.securityProfile.encryptionAtHost) { + helpers.addResult(results, 0, 'Encryption at host is enabled for virtual machine disks', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Encryption at host is not enabled for virtual machine disks', location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmEncryptionAtHost.spec.js b/plugins/azure/virtualmachines/vmEncryptionAtHost.spec.js new file mode 100644 index 000000000..a39b93d69 --- /dev/null +++ b/plugins/azure/virtualmachines/vmEncryptionAtHost.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var vmEncryptionAtHost = require('./vmEncryptionAtHost'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'imageReference': { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/galleries/myGallery/images/test-def-1/versions/1.0.0', + 'exactVersion': '1.0.0' + } + }, + 'securityProfile': { + 'encryptionAtHost': true + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'imageReference': { + 'publisher': 'Canonical', + 'offer': 'UbuntuServer', + 'sku': '18.04-LTS', + 'version': 'latest', + 'exactVersion': '18.04.202007160' + } + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmEncryptionAtHost', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmEncryptionAtHost.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + vmEncryptionAtHost.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VM has encryption at host enabled for disks', function(done) { + const cache = createCache([virtualMachines[0]]); + vmEncryptionAtHost.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Encryption at host is enabled for virtual machine disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM does not have encryption at host enabled for disks', function(done) { + const cache = createCache([virtualMachines[1]]); + vmEncryptionAtHost.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Encryption at host is not enabled for virtual machine disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmEndpointProtection.js b/plugins/azure/virtualmachines/vmEndpointProtection.js index fe364bd05..f127b3939 100644 --- a/plugins/azure/virtualmachines/vmEndpointProtection.js +++ b/plugins/azure/virtualmachines/vmEndpointProtection.js @@ -5,10 +5,12 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Endpoint Protection', category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', description: 'Ensures that VM Endpoint Protection is enabled for all virtual machines', more_info: 'Installing endpoint protection systems provides for real-time protection capabilities that help identify and remove viruses, spyware, and other malicious software, with configurable alerts for malicious or unwanted software.', recommended_action: 'Install endpoint protection on all virtual machines.', - link: 'https://docs.microsoft.com/en-us/azure/security-center/security-center-install-endpoint-protection', + link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-install-endpoint-protection', apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], compliance: { pci: 'PCI requires the use of anti-virus and anti-malware solutions. Enabling ' + @@ -16,6 +18,7 @@ module.exports = { hipaa: 'HIPAA requires protection of all network systems, including monitoring ' + 'all network traffic for malicious, inappropriate or unusual traffic.' }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write', 'microsoftcompute:virtualmachines:extensions:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/virtualmachines/vmEndpointProtection.spec.js b/plugins/azure/virtualmachines/vmEndpointProtection.spec.js new file mode 100644 index 000000000..b67b8cda7 --- /dev/null +++ b/plugins/azure/virtualmachines/vmEndpointProtection.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +var vmEndpointProtection = require('./vmEndpointProtection'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Linux' + } + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Windows' + } + } + } +]; + +const virtualMachineExtension = [ + { + 'name': 'NetworkExtension', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/NetworkExtension', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'location': 'eastus', + 'autoUpgradeMinorVersion': true, + 'provisioningState': 'Succeeded', + 'type': 'NetworkWatcherAgentLinux', + 'settings': {} + }, + { + 'name': 'AntimalwareExtension', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/AntiMalwareExtension', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'location': 'eastus', + 'autoUpgradeMinorVersion': true, + 'provisioningState': 'Succeeded', + 'type': 'IaaSAntimalware', + 'settings': { + 'AntimalwareEnabled': true + } + } + +]; + +const createCache = (virtualMachines, virtualMachineExtension) => { + let machine = {}; + let extension = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachines.length && virtualMachineExtension) { + extension[virtualMachines[0].id] = { + 'data': virtualMachineExtension + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + virtualMachineExtensions: { + list: { + 'eastus': extension + } + } + }; +}; + +describe('vmEndpointProtection', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no virtual machine extensions for windows image', function(done) { + const cache = createCache([virtualMachines[1]], []); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Microsoft VM does not offer endpoint protection'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if no virtual machine extensions for non windows image', function(done) { + const cache = createCache([virtualMachines[0]], []); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No VM Extensions found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]]); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM Extensions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Endpoint protection is installed on the virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[1]]); + vmEndpointProtection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Endpoint protection is installed on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if The Microsoft VM does not offer endpoint protection', function(done) { + const cache = createCache([virtualMachines[1]], [virtualMachineExtension[0]]); + vmEndpointProtection.run(cache, { vm_approved_extensions: 'TestExtension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Microsoft VM does not offer endpoint protection'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Endpoint protection is not installed on the virtual machine', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + vmEndpointProtection.run(cache, { vm_approved_extensions: 'Extension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Endpoint protection is not installed on the virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmHasTags.js b/plugins/azure/virtualmachines/vmHasTags.js new file mode 100644 index 000000000..f0baef3df --- /dev/null +++ b/plugins/azure/virtualmachines/vmHasTags.js @@ -0,0 +1,58 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Virtual Machine Has Tags', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Azure virtual machines have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify affected virtual machine and add tags.', + link: 'https://learn.microsoft.com/bs-latn-ba/azure/virtual-machines/tag-portal', + apis: ['virtualMachines:listAll', 'virtualMachines:get'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb){ + + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines found', location); + return rcb(); + } + + for (let virtualMachine of virtualMachines.data) { + if (!virtualMachine.id) continue; + + const virtualMachineData = helpers.addSource(cache, source, ['virtualMachines', 'get', location, virtualMachine.id]); + + if (!virtualMachineData || !virtualMachineData.data || virtualMachineData.err) { + helpers.addResult(results, 3, 'unable to query for virtual machine data', location, virtualMachine.id); + continue; + } + if (virtualMachineData.data.tags) { + helpers.addResult(results, 0, 'Virtual Machine has tags', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Virtual Machine does not have tags', location, virtualMachine.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmHasTags.spec.js b/plugins/azure/virtualmachines/vmHasTags.spec.js new file mode 100644 index 000000000..33c33efe1 --- /dev/null +++ b/plugins/azure/virtualmachines/vmHasTags.spec.js @@ -0,0 +1,106 @@ +var expect = require('chai').expect; +var vmHasTags = require('./vmHasTags'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const virtualMachinesData = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'tags': { 'key': 'value'} + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + } +]; + +const createCache = (virtualMachines, virtualMachineDetails) => { + let machine = {}; + let machineDetails = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachineDetails && virtualMachines.length) { + machineDetails[virtualMachines[0].id]= { + 'data': virtualMachineDetails + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + }, + get: { + 'eastus': machineDetails + } + } + }; +}; + +describe('vmHasTags', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + vmHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine details', function(done) { + const cache = createCache([virtualMachines[0]], null); + vmHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('unable to query for virtual machine data'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VM has tags associated', function(done) { + const cache = createCache([virtualMachines[0]], virtualMachinesData[0]); + vmHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM has does not have tags', function(done) { + const cache = createCache([virtualMachines[0]], virtualMachinesData[1]); + vmHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmImageHasTags.js b/plugins/azure/virtualmachines/vmImageHasTags.js new file mode 100644 index 000000000..c8bc313b1 --- /dev/null +++ b/plugins/azure/virtualmachines/vmImageHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Image Has Tags', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Microsoft Azure virtual machine images have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify virtual machine image and add tags', + apis: ['images:list'], + realtime_triggers: ['microsoftcompute:images:write', 'microsoftcompute:images:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.images, function(location, rcb) { + const snapshots = helpers.addSource(cache, source, + ['images', 'list', location]); + + if (!snapshots) return rcb(); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine image :' + helpers.addError(snapshots), location); + return rcb(); + } + + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No virtual machine image found', location); + return rcb(); + } + for (let image of snapshots.data){ + if (!image.id) continue; + + if (image.tags && Object.entries(image.tags).length > 0){ + helpers.addResult(results, 0, 'VM Image has tags associated', location, image.id); + } else { + helpers.addResult(results, 2, 'VM Image does not have tags associated', location, image.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmImageHasTags.spec.js b/plugins/azure/virtualmachines/vmImageHasTags.spec.js new file mode 100644 index 000000000..b5b0fdc16 --- /dev/null +++ b/plugins/azure/virtualmachines/vmImageHasTags.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var vmImageHasTags = require('./vmImageHasTags'); + +const images = [ + { + 'name': 'test-image', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/images/test-ali-ss', + 'type': 'Microsoft.Compute/image', + 'location': 'eastus', + 'diskSizeGB': 30, + 'tags': { 'key': 'value' } + }, + { + 'name': 'test-image', + 'id': '/subscriptions/123/resourceGroups/ALI-RECOURCE_GROUP/providers/Microsoft.Compute/images/test-ali-ss', + 'type': 'Microsoft.Compute/image', + 'location': 'eastus', + 'diskSizeGB': 30, + 'tags': {} + } +]; + +const createCache = (images) => { + let image = {}; + if (images) { + image['data'] = images; + } + return { + images: { + list: { + 'eastus': image + } + } + }; +}; + +describe('vmImageHasTags', function() { + describe('run', function() { + it('should give passing result if no images found', function(done) { + const cache = createCache([]); + vmImageHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No virtual machine image found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for images', function(done) { + const cache = createCache(null); + vmImageHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine image :'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if image has tags associated', function(done) { + const cache = createCache([images[0]]); + + vmImageHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM Image has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if image does not have tags associated', function(done) { + const cache = createCache([images[1]]); + + vmImageHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM Image does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmInstanceLimit.js b/plugins/azure/virtualmachines/vmInstanceLimit.js index d8b7542fe..56da65a25 100644 --- a/plugins/azure/virtualmachines/vmInstanceLimit.js +++ b/plugins/azure/virtualmachines/vmInstanceLimit.js @@ -4,9 +4,11 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'VM Instance Limit', category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', description: 'Determines if the number of VM instances is close to the Azure per-region limit', more_info: 'Azure limits regions to certain numbers of resources. Exceeding those limits could prevent resources from launching.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machines/windows/overview', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/overview', recommended_action: 'Contact Azure support to increase the number of instances available', apis: ['virtualMachines:listAll'], settings: { @@ -29,6 +31,7 @@ module.exports = { default: 25000 } }, + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], run: function(cache, settings, callback) { var config = { diff --git a/plugins/azure/virtualmachines/vmInstanceLimit.spec.js b/plugins/azure/virtualmachines/vmInstanceLimit.spec.js new file mode 100644 index 000000000..a4c52b7ac --- /dev/null +++ b/plugins/azure/virtualmachines/vmInstanceLimit.spec.js @@ -0,0 +1,102 @@ +var expect = require('chai').expect; +var vmInstanceLimit = require('./vmInstanceLimit'); + +const virtualMachines = [ + { + 'name': 'test-vm-1' + }, + { + 'name': 'test-vm-1' + }, + { + 'name': 'test-vm-1' + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmInstanceLimit', function() { + describe('run', function() { + it('should give passing result if No existing Virtual Machines', function(done) { + const cache = createCache([]); + vmInstanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query Virtual Machines', function(done) { + const cache = createCache(); + vmInstanceLimit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vm instances percentage are below the limits', function(done) { + const cache = createCache(virtualMachines); + const settings = { + instance_limit_percentage_fail: 90, + instance_limit_percentage_warn: 75, + instance_limit: 5 + }; + vmInstanceLimit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Region contains 3 of 5 (60%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing results if number of vm instances has reached the fail limit', function(done) { + const cache = createCache(virtualMachines); + const settings = { + instance_limit_percentage_fail: 50, + instance_limit_percentage_warn: 35, + instance_limit: 5 + }; + vmInstanceLimit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Region contains 3 of 5 (60%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give warn result if number of vm instances has reached the warn limit', function(done) { + const cache = createCache(virtualMachines); + const settings = { + instance_limit_percentage_fail: 80, + instance_limit_percentage_warn: 50, + instance_limit: 5 + }; + vmInstanceLimit.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('Region contains 3 of 5 (60%) available instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmManagedDisks.js b/plugins/azure/virtualmachines/vmManagedDisks.js new file mode 100644 index 000000000..8c5f8cdea --- /dev/null +++ b/plugins/azure/virtualmachines/vmManagedDisks.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VM Managed Disks Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'High', + description: 'Ensures that Azure virtual machines are configured to use Azure managed disks.', + more_info: 'Azure managed disks are block-level storage volumes that are managed by Azure are like physical disks in an on-premises server but, virtualized. Azure managed disks provide high durability and security.', + recommended_action: 'Migrate virtual machine disks to Azure managed disks', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/managed-disks-overview', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + for (let virtualMachine of virtualMachines.data) { + if (virtualMachine.storageProfile && virtualMachine.storageProfile.osDisk && + virtualMachine.storageProfile.osDisk.managedDisk && virtualMachine.storageProfile.osDisk.managedDisk.id) { + helpers.addResult(results, 0, 'Virtual machine is configured to use Azure managed disks', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Virtual machine is not configured to use Azure managed disks', location, virtualMachine.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmManagedDisks.spec.js b/plugins/azure/virtualmachines/vmManagedDisks.spec.js new file mode 100644 index 000000000..a9e8d14f7 --- /dev/null +++ b/plugins/azure/virtualmachines/vmManagedDisks.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var vmManagedDisks = require('./vmManagedDisks'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Linux', + 'name': 'test-vm-disk', + 'diskSizeGB': 4 + }, + 'dataDisks': [ + { + 'name': 'test-vm-data-disk', + 'diskSizeGB': 4 + } + ] + } + }, + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'storageProfile': { + 'osDisk': { + 'osType': 'Linux', + 'name': 'test-vm-disk', + 'managedDisk': { + 'storageAccountType': 'Premium_LRS', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource_group/providers/Microsoft.Compute/disks/test-vm-disk' + }, + 'diskSizeGB': 30 + }, + 'dataDisks': [ + { + 'name': 'test-vm-data-disk', + 'managedDisk': { + 'storageAccountType': 'Standard_LRS', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/disks/test-vm-data-disk' + }, + 'diskSizeGB': 32, + } + ] + } + } +]; + +const createCache = (virtualMachines) => { + let machine = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmManagedDisks', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmManagedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + vmManagedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtualMachines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual machine is using managed disks', function(done) { + const cache = createCache([virtualMachines[1]]); + vmManagedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual machine is configured to use Azure managed disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual machine is not using managed disks', function(done) { + const cache = createCache([virtualMachines[0]]); + vmManagedDisks.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual machine is not configured to use Azure managed disks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmNetworkExposure.js b/plugins/azure/virtualmachines/vmNetworkExposure.js new file mode 100644 index 000000000..fc237daa3 --- /dev/null +++ b/plugins/azure/virtualmachines/vmNetworkExposure.js @@ -0,0 +1,133 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Internet Exposure', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Info', + description: 'Check if Azure virtual machines are exposed to the internet.', + more_info: 'Virtual machines exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security group and firewall rules.', + link: 'https://learn.microsoft.com/en-us/azure/security/fundamentals/virtual-machines-overview', + recommended_action: 'Secure VM instances by restricting access with properly configured security group and firewall rules.', + apis: ['virtualMachines:listAll', 'networkInterfaces:listAll', 'networkSecurityGroups:listAll', 'virtualNetworks:listAll', 'loadBalancers:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftnetwork:networkinterfaces:write', 'microsoftcompute:virtualmachines:delete', 'microsoftnetwork:networkinterfaces:delete', 'microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete', 'microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for virtualMachines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + var networkInterfaces = helpers.addSource(cache, source, + ['networkInterfaces', 'listAll', location]); + + if (!networkInterfaces || networkInterfaces.err || !networkInterfaces.data || !networkInterfaces.data.length) { + helpers.addResult(results, 3, 'Unable to query for network interfaces: ' + helpers.addError(networkInterfaces), location); + return rcb(); + } + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + + if (!networkSecurityGroups || networkSecurityGroups.err || !networkSecurityGroups.data) { + helpers.addResult(results, 3, 'Unable to query for Network Security Groups: ' + helpers.addError(networkSecurityGroups), location); + return rcb(); + } + + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + + virtualMachines.data.forEach(virtualMachine => { + let vm_interfaces = []; + let securityGroups = []; + let loadBalancers = []; + if (virtualMachine.networkProfile && virtualMachine.networkProfile.networkInterfaces && + virtualMachine.networkProfile.networkInterfaces.length > 0) { + let interfaceIDs = virtualMachine.networkProfile.networkInterfaces.map(nic => nic.id); + vm_interfaces = networkInterfaces.data.filter(nic => interfaceIDs.includes(nic.id)); + if (networkSecurityGroups && networkSecurityGroups.data && networkSecurityGroups.data.length) { + let securityGroupIDs = vm_interfaces.filter(interface => interface.networkSecurityGroup && interface.networkSecurityGroup.id).map(nic => nic.networkSecurityGroup.id); + let allSubnetIDs = vm_interfaces.reduce((acc, nic) => { + let subnetIds = nic.ipConfigurations.map(ipConfig => ipConfig.properties.subnet.id); + return acc.concat(subnetIds); + }, []); + + if (virtualNetworks && !virtualNetworks.err && virtualNetworks.data && virtualNetworks.data.length) { + virtualNetworks.data.forEach(vnet => { + if (vnet.subnets && vnet.subnets.length) { + vnet.subnets.forEach(subnet => { + if (allSubnetIDs.includes(subnet.id) && subnet.properties && subnet.properties.networkSecurityGroup && subnet.properties.networkSecurityGroup.id) { + securityGroupIDs.push(subnet.properties.networkSecurityGroup.id); + } + }); + } + }); + + } + securityGroups = networkSecurityGroups.data.filter(nsg => securityGroupIDs.includes(nsg.id)); + } + + // get load balancers + for (let nic of vm_interfaces) { + if (nic.ipConfigurations && nic.ipConfigurations.length) { + nic.ipConfigurations.map(ipConfig => { + if (ipConfig.properties) { + if (ipConfig.properties.loadBalancerInboundNatRules && ipConfig.properties.loadBalancerInboundNatRules.length) { + ipConfig.properties.loadBalancerInboundNatRules.forEach(rule => { + let id = rule.id; + let match = id.match(/\/subscriptions\/.+?(?=\/inboundNatRules)/); + + if (match && match[0]) { + if (!loadBalancers.includes(match[0])) { + loadBalancers.push(match[0]); + } + } + }); + } + if (ipConfig.properties.loadBalancerBackendAddressPools && ipConfig.properties.loadBalancerBackendAddressPools.length) { + ipConfig.properties.loadBalancerBackendAddressPools.forEach(pool => { + let id = pool.id; + let match = id.match(/\/subscriptions\/.+?(?=\/backendAddressPools)/); + if (match && match[0]) { + if (!loadBalancers.includes(match[0])) { + loadBalancers.push(match[0]); + } + } + }); + } + } + }); + } + } + } + let internetExposed = helpers.checkNetworkExposure(cache, source, vm_interfaces, securityGroups, location, results, {lbNames: loadBalancers}, virtualMachine); + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `VM is exposed to the internet through ${internetExposed}`, location, virtualMachine.id); + } else { + helpers.addResult(results, 0, 'VM is not exposed to the internet', location, virtualMachine.id); + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js b/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js new file mode 100644 index 000000000..55fd81db0 --- /dev/null +++ b/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js @@ -0,0 +1,22 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Info', + description: 'Ensures that no virtual machines in your Azure environment have excessive permissions.', + more_info: 'Virtual machines that use managed identities with excessive Azure AD permissions may pose security risks. It is a best practice to assign only the necessary permissions to the managed identities attached to virtual machines.', + link: 'https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token', + recommended_action: 'Review and restrict the Azure AD roles associated with managed identities used by virtual machines to follow the principle of least privilege.', + realtime_triggers: [ + 'Microsoft.Compute/virtualMachines/write', + 'Microsoft.Compute/virtualMachines/delete', + 'Microsoft.ManagedIdentity/userAssignedIdentities/assign/action', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; diff --git a/plugins/azure/virtualmachines/vmSecureBootEnabled.js b/plugins/azure/virtualmachines/vmSecureBootEnabled.js new file mode 100644 index 000000000..59d7541fe --- /dev/null +++ b/plugins/azure/virtualmachines/vmSecureBootEnabled.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Secure Boot Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that secure boot is enabled for Azure virtual machines (VM).', + more_info: 'Secure Boot, which is implemented in platform firmware, protects against the installation of malware-based rootkits and boot kits. Secure Boot works to ensure that only signed operating systems and drivers can boot. It establishes a "root of trust" for the software stack on your VM.', + recommended_action: 'Modify Virtual Machine and enable secure boot.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch#secure-boot', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.securityProfile && virtualMachine.securityProfile.uefiSettings && virtualMachine.securityProfile.uefiSettings.secureBootEnabled) { + helpers.addResult(results, 0, 'Secure Boot is enabled for virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Secure Boot is not enabled for virtual machine', location, virtualMachine.id); + + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmSecureBootEnabled.spec.js b/plugins/azure/virtualmachines/vmSecureBootEnabled.spec.js new file mode 100644 index 000000000..aa8003c39 --- /dev/null +++ b/plugins/azure/virtualmachines/vmSecureBootEnabled.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var selectSecureBoot = require('./vmSecureBootEnabled'); + +const virtualMachines = [ + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'name': 'test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'uefiSettings': { + 'secureBootEnabled': true + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'name': 'test-vm-2', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'uefiSettings': { + 'secureBootEnabled': false + } + } + } +]; + +const createCache = (virtualMachines) => { + let vm = {}; + if (virtualMachines) { + vm['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': vm + } + } + }; +}; + +describe('selectSecureBoot', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + selectSecureBoot.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + selectSecureBoot.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Secure Boot is enabled', function(done) { + const cache = createCache([virtualMachines[0]]); + selectSecureBoot.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secure Boot is enabled for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Secure Boot is not enabled', function(done) { + const cache = createCache([virtualMachines[1]]); + selectSecureBoot.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secure Boot is not enabled for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmSecurityType.js b/plugins/azure/virtualmachines/vmSecurityType.js new file mode 100644 index 000000000..0e2c4bb49 --- /dev/null +++ b/plugins/azure/virtualmachines/vmSecurityType.js @@ -0,0 +1,62 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Security Type', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Azure virtual machines have desired security type configured.', + more_info: 'Using advanced security features for virtual machines boost security by verifying the integrity of VMs during boot-up and safeguarding data in use. They defend against advanced threats, encrypt sensitive data, and ensure compliance with high security standards.', + recommended_action: 'Set the desired security type for all Azure virtual machines', + link: 'https://learn.microsoft.com/en-us/azure/confidential-computing', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + settings: { + desired_security_type: { + name: 'VM Desired Security Type', + description: 'Desired security type i.e. "trustedlaunch" or "confidentialvm".', + regex: '^(trustedlaunch|confidentialvm)$', + default: 'trustedlaunch' + + }, + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var securityTypes = ['trustedlaunch', 'confidentialvm']; + var config = settings.desired_security_type || this.settings.desired_security_type.default; + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + const configuredSecurityType = virtualMachine.securityProfile && virtualMachine.securityProfile.securityType ? virtualMachine.securityProfile.securityType.toLowerCase() : ''; + if (securityTypes.indexOf(configuredSecurityType) >= securityTypes.indexOf(config)) { + helpers.addResult(results, 0, `${configuredSecurityType} is configured as security type for virtual machine`, location, virtualMachine.id); + } else { + helpers.addResult(results, 2, `${config} is not configured as security type for virtual machine`, location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmSecurityType.spec.js b/plugins/azure/virtualmachines/vmSecurityType.spec.js new file mode 100644 index 000000000..956315ad2 --- /dev/null +++ b/plugins/azure/virtualmachines/vmSecurityType.spec.js @@ -0,0 +1,83 @@ +var expect = require('chai').expect; +var selectTrustedLaunch = require('./vmSecurityType'); + +const virtualMachines = [ + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'name': 'test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'securityType': 'TrustedLaunch' + } + }, + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'name': 'test-vm-2', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'securityType': 'NotTrustedLaunch' + } + } +]; + +const createCache = (virtualMachines) => { + let vm = {}; + if (virtualMachines) { + vm['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': vm + } + } + }; +}; + +describe('selectTrustedLaunch', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + selectTrustedLaunch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + selectTrustedLaunch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Trusted Launch is selected', function(done) { + const cache = createCache([virtualMachines[0]]); + selectTrustedLaunch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('trustedlaunch is configured as security type for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Trusted Launch is not selected', function(done) { + const cache = createCache([virtualMachines[1]]); + selectTrustedLaunch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('trustedlaunch is not configured as security type for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmVTPMEnabled.js b/plugins/azure/virtualmachines/vmVTPMEnabled.js new file mode 100644 index 000000000..3f26ae9bb --- /dev/null +++ b/plugins/azure/virtualmachines/vmVTPMEnabled.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM vTPM Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Virtual Trusted Platform Module (vTPM) is enabled for Azure virtual machines.', + more_info: 'vTPM is TPM2.0 compliant and enhances security by validating VM boot integrity and providing a secure storage mechanism for keys and secrets. The vTPM enables attestation by measuring the entire boot chain of your VM (UEFI, OS, system, and drivers).', + recommended_action: 'Modify virtual machine and enable vTPM.', + link: 'https://learn.microsoft.com/en-us/azure/confidential-computing/virtual-tpms-in-azure-confidential-vm', + apis: ['virtualMachines:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machines found', location); + return rcb(); + } + + virtualMachines.data.forEach(virtualMachine => { + if (virtualMachine.securityProfile && virtualMachine.securityProfile.uefiSettings && virtualMachine.securityProfile.uefiSettings.vTpmEnabled) { + helpers.addResult(results, 0, 'vTPM is enabled for virtual machine', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'vTPM is not enabled for virtual machine', location, virtualMachine.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmVTPMEnabled.spec.js b/plugins/azure/virtualmachines/vmVTPMEnabled.spec.js new file mode 100644 index 000000000..c8b965818 --- /dev/null +++ b/plugins/azure/virtualmachines/vmVTPMEnabled.spec.js @@ -0,0 +1,87 @@ +var expect = require('chai').expect; +var selectVTPM = require('./vmVTPMEnabled'); + +const virtualMachines = [ + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'name': 'test-vm', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'uefiSettings': { + 'vTpmEnabled': true + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm-2', + 'name': 'test-vm-2', + 'type': 'Microsoft.Compute/virtualMachines', + 'securityProfile': { + 'uefiSettings': { + 'vTpmEnabled': false + } + } + } +]; + +const createCache = (virtualMachines) => { + let vm = {}; + if (virtualMachines) { + vm['data'] = virtualMachines; + } + return { + virtualMachines: { + listAll: { + 'eastus': vm + } + } + }; +}; + +describe('selectVTPM', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + selectVTPM.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(null); + selectVTPM.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if vTPM is enabled', function(done) { + const cache = createCache([virtualMachines[0]]); + selectVTPM.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('vTPM is enabled for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if vTPM is not enabled', function(done) { + const cache = createCache([virtualMachines[1]]); + selectVTPM.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('vTPM is not enabled for virtual machine'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js new file mode 100644 index 000000000..64f4dadbb --- /dev/null +++ b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js @@ -0,0 +1,78 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Windows AntiMalware Extension', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Virtual Machine windows instances have IaaS AntiMalware extension installed.', + more_info: 'The VM Windows AntiMalware Extension provides real-time protection against viruses, spyware, and other malicious software for virtual machines running on the Windows operating system.', + recommended_action: 'Modify Virtual Machine and install IaaS AntiMalware extension.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/extensions/iaas-antimalware-windows', + apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write', 'microsoftcompute:virtualmachines:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb){ + var virtualMachines = helpers.addSource(cache, source, + ['virtualMachines', 'listAll', location]); + + if (!virtualMachines) return rcb(); + + if (virtualMachines.err || !virtualMachines.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machines: ' + helpers.addError(virtualMachines), location); + return rcb(); + } + + if (!virtualMachines.data.length) { + helpers.addResult(results, 0, 'No Virtual Machines found', location); + return rcb(); + } + + for (let virtualMachine of virtualMachines.data) { + if (!virtualMachine.id || (virtualMachine.osDisk && virtualMachine.osDisk.osType && + virtualMachine.osDisk.osType.toLowerCase() != 'windows')) { + continue; + } + + const virtualMachineExtensions = helpers.addSource(cache, source, + ['virtualMachineExtensions', 'list', location, virtualMachine.id]); + + if (!virtualMachineExtensions || virtualMachineExtensions.err || !virtualMachineExtensions.data) { + helpers.addResult(results, 3, 'Unable to query for VM Extensions: ' + helpers.addError(virtualMachineExtensions), location, virtualMachine.id); + continue; + } + + if (!virtualMachineExtensions.data.length) { + helpers.addResult(results, 2, 'No VM Extensions found', location, virtualMachine.id); + continue; + } + + let found = virtualMachineExtensions.data.find(vmExt => + vmExt.name && ( + vmExt.name.toLowerCase() === 'iaasantimalware' || + vmExt.name.toLowerCase() === 'iaasantimalwareext' || + vmExt.name.toLowerCase().includes('antimalware') + ) && vmExt.provisioningState === 'Succeeded' + ); + + if (found) { + helpers.addResult(results, 0, 'Windows Virtual Machine has IaaS Antimalware extension installed', location, virtualMachine.id); + } else { + helpers.addResult(results, 2, 'Windows Virtual Machine does not have IaaS Antimalware extension installed', location, + virtualMachine.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js new file mode 100644 index 000000000..1f067a2e5 --- /dev/null +++ b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var vmWindowsAntiMalwareExtension = require('./vmWindowsAntiMalwareExtension'); + +const virtualMachines = [ + { + 'name': 'test-vm', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm', + 'type': 'Microsoft.Compute/virtualMachines' + } +]; + +const virtualMachineExtension = [ + { + 'name': 'TestExtension', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/TestExtension', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'IaaSAntimalware', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntimalware', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'IaaSAntiMalwareExt', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntiMalwareExt', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'IaaSAntimalware', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntimalware', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Failed' + } +]; + +const createCache = (virtualMachines, virtualMachineExtension) => { + let machine = {}; + let extension = {}; + if (virtualMachines) { + machine['data'] = virtualMachines; + if (virtualMachines.length && virtualMachineExtension) { + extension[virtualMachines[0].id] = { + 'data': virtualMachineExtension + }; + } + } + return { + virtualMachines: { + listAll: { + 'eastus': machine + } + }, + virtualMachineExtensions: { + list: { + 'eastus': extension + } + } + }; +}; + +describe('vmWindowsAntiMalwareExtension', function() { + describe('run', function() { + it('should give passing result if no virtual machines', function(done) { + const cache = createCache([]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Virtual Machines found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machines', function(done) { + const cache = createCache(); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machines'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]], []); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No VM Extensions found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine extensions', function(done) { + const cache = createCache([virtualMachines[0]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM Extensions'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if windows vm has antimalware extension installed', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[1]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Windows Virtual Machine has IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if windows vm has IaaSAntiMalwareExt extension installed', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[2]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Windows Virtual Machine has IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if antimalware extension has failed provisioning state', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[3]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Windows Virtual Machine does not have IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if windows vm not have antimalware extension installed', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Windows Virtual Machine does not have IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.js b/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.js new file mode 100644 index 000000000..c3ce14df4 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automatic Instance Repairs Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that automatic instance repairs is enabled for Azure virtual machine scale sets.', + more_info: 'Enabling automatic instance repairs for Azure virtual machine scale sets helps achieve high availability for applications by maintaining a set of healthy instances.', + recommended_action: 'Enable automatic instance repairs for Azure virtual machine scale sets', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-automatic-instance-repairs', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine scale sets : ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines scale sets', location); + return rcb(); + } + + virtualMachineScaleSets.data.forEach(scaleSet => { + if (scaleSet.automaticRepairsPolicy && scaleSet.automaticRepairsPolicy.enabled) { + helpers.addResult(results, 0, 'Automatic instance repairs is enabled for virtual machine scale set', location, scaleSet.id); + } else { + helpers.addResult(results, 2, 'Automatic instance repairs is not enabled for virtual machine scale set', location, scaleSet.id); + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.spec.js b/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.spec.js new file mode 100644 index 000000000..a2cd12045 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoInstanceRepairsEnabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var autoInstanceRepairsEnabled = require('./autoInstanceRepairsEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-ali-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'location': 'eastus', + 'automaticRepairsPolicy': { + 'enabled': true + } + }, + { + 'name': 'test-ali-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'location': 'eastus', + 'automaticRepairsPolicy': { + 'enabled': false + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let scaleSet = {}; + if (virtualMachineScaleSets) { + scaleSet['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': scaleSet + } + } + }; +}; + +describe('autoInstanceRepairsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([], null); + autoInstanceRepairsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines scale sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(null, null); + autoInstanceRepairsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine scale sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual machine scale set has automatic instance repair enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + autoInstanceRepairsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automatic instance repairs is enabled for virtual machine scale set'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual machine scale set dows not have automatic instance repair enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + autoInstanceRepairsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automatic instance repairs is not enabled for virtual machine scale set'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.js b/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.js new file mode 100644 index 000000000..0431380f2 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.js @@ -0,0 +1,58 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Automatic OS Upgrades Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that automatic operating system (OS) upgrades are enabled for Microsoft Azure virtual machine scale sets.', + more_info: 'Enabling automatic OS image upgrades on your scale set helps ease update management by safely and automatically upgrading the OS disk for all instances in the scale set.', + recommended_action: 'Enable automatic OS upgrades under operating system settings', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-automatic-upgrade', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + virtualMachineScaleSets.data.forEach(virtualMachineScaleSet => { + if (virtualMachineScaleSet.upgradePolicy && + virtualMachineScaleSet.upgradePolicy.automaticOSUpgradePolicy && + virtualMachineScaleSet.upgradePolicy.automaticOSUpgradePolicy.enableAutomaticOSUpgrade) { + helpers.addResult(results, 0, + 'Automatic OS upgrades feature is enabled for virtual machine scale set', + location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Automatic OS upgrades feature is not enabled for virtual machine scale set', + location, virtualMachineScaleSet.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.spec.js b/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.spec.js new file mode 100644 index 000000000..7ff3b4f21 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoOsUpgradesEnabled.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +var plugin = require('./autoOsUpgradesEnabled'); + +const createCache = (err, list) => { + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': { + err: err, + data: list + } + } + } + } +}; + +describe('autoOsUpgradesEnabled', function() { + describe('run', function() { + it('should give unknown result if unable to query for Virtual Machine Scale Sets', function(done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets') + expect(results[0].region).to.equal('eastus') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no existing Virtual Machine Scale Sets found', function(done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found') + expect(results[0].region).to.equal('eastus') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if automatic OS upgrades feature is enabled for virtual machine scale set', function(done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Automatic OS upgrades feature is enabled for virtual machine scale set') + expect(results[0].region).to.equal('eastus') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/123/KHULNASOFT-RG/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-1", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "West US", + "upgradePolicy": { + "mode": "Automatic", + "automaticOSUpgradePolicy": { + "enableAutomaticOSUpgrade": true + } + } + }, + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if automatic OS upgrades feature is not enabled for virtual machine scale set', function(done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Automatic OS upgrades feature is not enabled for virtual machine scale set') + expect(results[0].region).to.equal('eastus') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "/subscriptions/123/KHULNASOFT-RG/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-1", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "West US", + "upgradePolicy": { + "mode": "Manual", + } + }, + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.js b/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.js new file mode 100644 index 000000000..55a721a15 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.js @@ -0,0 +1,90 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Autoscale Notifications Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Virtual Machine scale sets have autoscale notifications enabled.', + more_info: 'Autoscale automatically creates new instances when certain metrics are surpassed, or can destroy instances that are being underutilized. Autoscale notifications should be enabled to know about the status of autoscale operation.', + recommended_action: 'Ensure that autoscale notifications are enabled for all Virtual Machine Scale Sets', + link: 'https://learn.microsoft.com/en-us/azure/azure-monitor/autoscale/autoscale-overview', + apis: ['virtualMachineScaleSets:listAll', 'autoscaleSettings:listBySubscription'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + const autoscaleSettings = helpers.addSource(cache, source, + ['autoscaleSettings', 'listBySubscription', location]); + + if (!autoscaleSettings || autoscaleSettings.err || !autoscaleSettings.data) { + helpers.addResult(results, 3, + 'Unable to query for AutoScale settings: ' + helpers.addError(autoscaleSettings), location); + return rcb(); + } + + if (!autoscaleSettings.data.length) { + helpers.addResult(results, 2, + 'No Virtual Machine Scale Sets have autoscale enabled', location); + return rcb(); + } + + var asMap = {}; + autoscaleSettings.data.forEach(function(autoscaleSetting) { + if (autoscaleSetting.targetResourceUri) { + asMap[autoscaleSetting.targetResourceUri.toLowerCase()] = autoscaleSetting; + } + }); + + virtualMachineScaleSets.data.forEach(virtualMachineScaleSet => { + let autoScaleNotifications = []; + if (virtualMachineScaleSet.id && + asMap[virtualMachineScaleSet.id.toLowerCase()] && + asMap[virtualMachineScaleSet.id.toLowerCase()].notifications && + asMap[virtualMachineScaleSet.id.toLowerCase()].notifications.length) { + autoScaleNotifications = asMap[virtualMachineScaleSet.id.toLowerCase()].notifications; + } + + let found = autoScaleNotifications.find(notification => + (notification.email && ( + notification.email.sendToSubscriptionAdministrator || + notification.email.sendToSubscriptionCoAdministrators || + (notification.email.customEmails && notification.email.customEmails.length))) || + (notification.webhooks && notification.webhooks.length)); + if (found) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has autoscale notifications enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has autoscale notifications disabled', location, virtualMachineScaleSet.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.spec.js b/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.spec.js new file mode 100644 index 000000000..e25f1bfe6 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/autoscaleNotificationsEnabled.spec.js @@ -0,0 +1,225 @@ +var expect = require('chai').expect; +var autoscaleNotificationsEnabled = require('./autoscaleNotificationsEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-ali-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'location': 'eastus' + } +]; + +const autoScaleSettings = [ + { + 'id': '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/khulnasoft-resource-group/providers/microsoft.insights/autoscalesettings/test-vmss-Autoscale', + 'targetResourceUri': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'name': 'test-vmss-Autoscale', + 'type': 'Microsoft.Insights/autoscaleSettings', + 'notifications': [ + { + 'operation': 'Scale', + 'email': { + 'sendToSubscriptionAdministrator': true, + 'sendToSubscriptionCoAdministrators': false, + 'customEmails': [] + }, + 'webhooks': [] + } + ] + }, + { + 'id': '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/khulnasoft-resource-group/providers/microsoft.insights/autoscalesettings/test-vmss-Autoscale', + 'targetResourceUri': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'name': 'test-vmss-Autoscale', + 'type': 'Microsoft.Insights/autoscaleSettings', + 'notifications': [ + { + 'operation': 'Scale', + 'email': { + 'sendToSubscriptionAdministrator': false, + 'sendToSubscriptionCoAdministrators': true, + 'customEmails': [] + }, + 'webhooks': [] + } + ] + }, + { + 'id': '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/khulnasoft-resource-group/providers/microsoft.insights/autoscalesettings/test-vmss-Autoscale', + 'targetResourceUri': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'name': 'test-vmss-Autoscale', + 'type': 'Microsoft.Insights/autoscaleSettings', + 'notifications': [ + { + 'operation': 'Scale', + 'email': { + 'sendToSubscriptionAdministrator': false, + 'sendToSubscriptionCoAdministrators': false, + 'customEmails': ['testemail@test.com'] + }, + 'webhooks': [] + } + ] + }, + { + 'id': '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/khulnasoft-resource-group/providers/microsoft.insights/autoscalesettings/test-vmss-Autoscale', + 'targetResourceUri': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'name': 'test-vmss-Autoscale', + 'type': 'Microsoft.Insights/autoscaleSettings', + 'notifications': [ + { + 'operation': 'Scale', + 'email': { + 'sendToSubscriptionAdministrator': false, + 'sendToSubscriptionCoAdministrators': false, + 'customEmails': [] + }, + 'webhooks': ['http://webhookendpoint.com/webhook'] + } + ] + }, + { + 'id': '/subscriptions/dce7d0ad-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/khulnasoft-resource-group/providers/microsoft.insights/autoscalesettings/test-vmss-Autoscale', + 'targetResourceUri': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'name': 'test-vmss-Autoscale', + 'type': 'Microsoft.Insights/autoscaleSettings', + 'notifications': [ + { + 'operation': 'Scale', + 'email': { + 'sendToSubscriptionAdministrator': false, + 'sendToSubscriptionCoAdministrators': false, + 'customEmails': [] + }, + 'webhooks': [] + } + ] + }, +]; + +const createCache = (virtualMachineScaleSets, autoscaleSettings) => { + let scaleSet = {}; + let autoScaleSetting = {}; + if (virtualMachineScaleSets) { + scaleSet['data'] = virtualMachineScaleSets; + } + if (autoscaleSettings) { + autoScaleSetting['data'] = autoscaleSettings; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': scaleSet + } + }, + autoscaleSettings: { + listBySubscription: { + 'eastus': autoScaleSetting + } + } + }; +}; + +describe('autoscaleNotificationsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(null); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no autoscale settings', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], []); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No Virtual Machine Scale Sets have autoscale enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for autoscale settings', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for AutoScale settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no autoscale email to admins enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [autoScaleSettings[0]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has autoscale notifications enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no autoscale email to co-admins enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [autoScaleSettings[1]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has autoscale notifications enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no autoscale email to custom emails enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [autoScaleSettings[2]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has autoscale notifications enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no autoscale email to admins enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [autoScaleSettings[3]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has autoscale notifications enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no autoscale notifications are disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [autoScaleSettings[4]]); + autoscaleNotificationsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has autoscale notifications disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.js b/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.js new file mode 100644 index 000000000..f86631dee --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.js @@ -0,0 +1,73 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Health Monitoring Extension HTTPS Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + description: 'Ensures that Virtual Machine Scale Set has HTTPS enabled for health monitoring.', + more_info: 'Enabling Application Health Extension in Virtual Machine Scale Set instance reports on application health from inside based on HTTPS responses received from the application. This allows to initiate repairs on unhealthy instances and to determine if an instance is eligible for upgrade operations.', + recommended_action: 'Modify virtual machine scale set extensions and enable HTTPS for health monitoring.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-health-extension', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + const scaleSetExtensions = virtualMachineScaleSet.virtualMachineProfile && + virtualMachineScaleSet.virtualMachineProfile.extensionProfile && + virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + ? virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + : []; + + const healthMonitoringEnabled = scaleSetExtensions.length + ? scaleSetExtensions.some((extension) => ( + extension.properties && extension.properties.type && + (extension.properties.type === 'ApplicationHealthWindows' || extension.properties.type === 'ApplicationHealthLinux')) + ) : false; + + + if (healthMonitoringEnabled) { + const hasHTTPSProtocol = scaleSetExtensions.some((extension) => ( + extension.properties && extension.properties.settings && extension.properties.settings.protocol && + extension.properties.settings.protocol.toLowerCase() === 'https' + )); + if (hasHTTPSProtocol) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has HTTPS enabled for health monitoring extension', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set does not have HTTPS enabled for health monitoring extension', location, virtualMachineScaleSet.id); + } + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has health monitoring disabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.spec.js b/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.spec.js new file mode 100644 index 000000000..59b7c74f3 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/healthMonitoringExtensionHttps.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var healthMonitoringExtensionHttps = require('./healthMonitoringExtensionHttps'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'healthRepairExtension', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'ApplicationHealthLinux', + 'typeHandlerVersion': '1.0', + 'settings': { + 'protocol': 'http', + 'port': 80, + 'requestPath': '/' + } + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'healthRepairExtension', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'ApplicationHealthWindows', + 'typeHandlerVersion': '1.0', + 'settings': { + 'protocol': 'https', + 'port': 443, + 'requestPath': '/' + } + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [] + } + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('healthMonitoringExtensionHttps', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + healthMonitoringExtensionHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + healthMonitoringExtensionHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set does not have HTTPS enabled for health monitoring', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + healthMonitoringExtensionHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set does not have HTTPS enabled for health monitoring extension'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set has HTTPS enabled for health monitoring', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + healthMonitoringExtensionHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has HTTPS enabled for health monitoring extension'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has health monitoring disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[2]]); + healthMonitoringExtensionHttps.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has health monitoring disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.js b/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.js new file mode 100644 index 000000000..24b57bbcf --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'No Empty Scale Sets', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that virtual machine scale sets have virtual machine instances attached.', + more_info: 'Azure virtual machine scale sets let you create and manage a group of load balanced VMs. Scale sets with no vm instances should be deleted to save cost of unused resources', + recommended_action: 'Delete virtual machine scale sets that have no virtual machine instances', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview', + apis: ['virtualMachineScaleSets:listAll', 'virtualMachineScaleSetVMs:list'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:delete:action'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachines, function(location, rcb) { + var virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine scale sets : ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing virtual machines scale sets', location); + return rcb(); + } + + virtualMachineScaleSets.data.forEach(scaleSet => { + var scaleSetVMs = helpers.addSource(cache, source, + ['virtualMachineScaleSetVMs', 'list', location, scaleSet.id]); + + if (!scaleSetVMs || scaleSetVMs.err || !scaleSetVMs.data) { + helpers.addResult(results, 3, 'Unable to query for virtual machine scale set VM instances : ' + helpers.addError(scaleSetVMs), location); + } else { + + if (scaleSetVMs.data.length) { + helpers.addResult(results, 0, 'Virtual machine scale set has VM instances attached', location, scaleSet.id); + } else { + helpers.addResult(results, 2, 'Virtual machine scale set has no VM instances attached', location, scaleSet.id); + } + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.spec.js b/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.spec.js new file mode 100644 index 000000000..f29b10e26 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/noEmptyScaleSets.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var noEmptyScaleSets = require('./noEmptyScaleSets'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-ali-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'location': 'eastus' + } +]; + +const virtualMachineScaleSetVMs = [ + { + 'name': 'test-vmss_0', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss/virtualMachines/0', + 'type': 'Microsoft.Compute/virtualMachineScaleSets/virtualMachines' + } +]; + +const createCache = (virtualMachineScaleSets, virtualMachineScaleSetVMs) => { + let scaleSet = {}; + let vm = {}; + if (virtualMachineScaleSets) { + scaleSet['data'] = virtualMachineScaleSets; + if (virtualMachineScaleSets.length > 0 && virtualMachineScaleSetVMs) { + vm[virtualMachineScaleSets[0].id] = { + data: virtualMachineScaleSetVMs + }; + } + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': scaleSet + } + }, + virtualMachineScaleSetVMs: { + list: { + 'eastus': vm + } + } + }; +}; + +describe('noEmptyScaleSets', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([], null); + noEmptyScaleSets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing virtual machines scale sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(null, null); + noEmptyScaleSets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine scale sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for attached vm instances', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], null); + noEmptyScaleSets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for virtual machine scale set VM instances'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual machine scale set has VM instances attached', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], [virtualMachineScaleSetVMs[1]]); + noEmptyScaleSets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual machine scale set has VM instances attached'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual machine scale set has no VM instances attached', function(done) { + const cache = createCache([virtualMachineScaleSets[0]], []); + noEmptyScaleSets.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual machine scale set has no VM instances attached'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js new file mode 100644 index 000000000..70451d48e --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Entra ID Authentication Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure Entra ID authentication is enabled for Virtual Machine Scale Sets.', + more_info: 'Enabling Azure Entra ID authentication for VM Scale Sets ensures access from one central point and simplifies access permission management. It allows conditional access by using Role-Based Access Control (RBAC) policies, and enable MFA.', + recommended_action: 'Enable Entra ID authentication for all Virtual Machines scale sets.', + link: 'https://learn.microsoft.com/en-us/entra/identity/devices/howto-vm-sign-in-azure-ad-linux', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + if (!virtualMachineScaleSet.id) continue; + + const scaleSetExtensions = virtualMachineScaleSet.virtualMachineProfile && virtualMachineScaleSet.virtualMachineProfile.extensionProfile && + virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + ? virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + : []; + const adAuthentication = scaleSetExtensions.length + ? scaleSetExtensions.some((extension) => (extension.properties && extension.properties.type && + (extension.properties.type.toLowerCase() === 'aadloginforwindows' || + extension.properties.type.toLowerCase() === 'aadloginforlinux' || + extension.properties.type.toLowerCase() === 'aadsshloginforlinux') + )) + : false; + + if (adAuthentication) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has Entra ID authentication enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has Entra ID authentication disabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js new file mode 100644 index 000000000..7fdf14e2a --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var scaleSetAdAuthEnabled = require('./scaleSetAdAuthEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'AADSSHLoginForLinux', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'AADSSHLoginForLinux', + 'typeHandlerVersion': '1.0', + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'AADLoginForWindows', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'AADLoginForWindows', + 'typeHandlerVersion': '1.0', + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [] + } + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('scaleSetAdAuthEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if linux Virtual Machine Scale Set has Entra ID authentication enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give passing result if windows Virtual Machine Scale Set has Entra ID authentication enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has Entra ID authentication disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[2]]); + scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.js b/plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.js similarity index 90% rename from plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.js rename to plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.js index 85e318807..1e433e96b 100644 --- a/plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.js +++ b/plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Scale Sets Autoscale Enabled', - category: 'Virtual Machines', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that Virtual Machine scale sets have autoscale enabled for high availability', more_info: 'Autoscale automatically creates new instances when certain metrics are surpassed, or can destroy instances that are being underutilized. This creates a highly available scale set.', recommended_action: 'Ensure that autoscale is enabled for all Virtual Machine Scale Sets.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-autoscale-overview', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-autoscale-overview', apis: ['virtualMachineScaleSets:listAll', 'autoscaleSettings:listBySubscription'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.spec.js b/plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.spec.js similarity index 100% rename from plugins/azure/virtualmachines/scaleSetAutoscaleEnabled.spec.js rename to plugins/azure/virtualmachinescaleset/scaleSetAutoscaleEnabled.spec.js diff --git a/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.js b/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.js new file mode 100644 index 000000000..c4f662ccf --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.js @@ -0,0 +1,63 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Health Monitoring Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that health monitoring is enabled for virtual machine scale sets.', + more_info: 'Scale set health monitoring feature reports on VM health from inside the scale set instance and can be configured to probe on an application endpoint and update the status of the application on that instance. That instance status is checked by Azure to determine whether an instance is eligible for upgrade operations.', + recommended_action: 'Enable health monitoring for virtual machine scale sets.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-health-extension', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + async.each(virtualMachineScaleSets.data, (virtualMachineScaleSet, scb) => { + const scaleSetExtensions = virtualMachineScaleSet.virtualMachineProfile && virtualMachineScaleSet.virtualMachineProfile.extensionProfile && + virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + ? virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + : []; + + const healthMonitoring = scaleSetExtensions.length + ? scaleSetExtensions.some((extension) => (extension.name === 'healthRepairExtension' || extension.type === 'ApplicationHealthLinux')) + : false; + + if (healthMonitoring) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has health monitoring enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has health monitoring disabled', location, virtualMachineScaleSet.id); + } + + scb(); + }, function() { + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.spec.js b/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.spec.js new file mode 100644 index 000000000..7b042d849 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scaleSetHealthMonitoring.spec.js @@ -0,0 +1,102 @@ +var expect = require('chai').expect; +var scaleSetHealthMonitoring = require('./scaleSetHealthMonitoring'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'healthRepairExtension', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'ApplicationHealthLinux', + 'typeHandlerVersion': '1.0', + 'settings': { + 'protocol': 'http', + 'port': 80, + 'requestPath': '/' + } + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [] + } + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('scaleSetHealthMonitoring', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + scaleSetHealthMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + scaleSetHealthMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set has health monitoring enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + scaleSetHealthMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has health monitoring enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has health monitoring disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + scaleSetHealthMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has health monitoring disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/scaleSetMultiAz.js b/plugins/azure/virtualmachinescaleset/scaleSetMultiAz.js similarity index 87% rename from plugins/azure/virtualmachines/scaleSetMultiAz.js rename to plugins/azure/virtualmachinescaleset/scaleSetMultiAz.js index f43c89813..f2913a643 100644 --- a/plugins/azure/virtualmachines/scaleSetMultiAz.js +++ b/plugins/azure/virtualmachinescaleset/scaleSetMultiAz.js @@ -3,12 +3,15 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Scale Set Multi Az', - category: 'Virtual Machines', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'High', description: 'Ensures that Virtual Machine Scale Sets are created to be cross-AZ for high availability', more_info: 'Having Virtual Machine Scale Sets in multiple zones increases durability and availability. If there is a catastrophic instance in one zone, the scale set will still be available.', recommended_action: 'Multiple zones can only be created when instantiating a new Scale Set. Ensure that the Scale Set is in multiple zones when creating a new Scale Set.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-autoscale-overview', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-autoscale-overview', apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], run: function(cache, settings, callback) { const results = []; diff --git a/plugins/azure/virtualmachines/scaleSetMultiAz.spec.js b/plugins/azure/virtualmachinescaleset/scaleSetMultiAz.spec.js similarity index 100% rename from plugins/azure/virtualmachines/scaleSetMultiAz.spec.js rename to plugins/azure/virtualmachinescaleset/scaleSetMultiAz.spec.js diff --git a/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.js b/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.js new file mode 100644 index 000000000..28a3c3956 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.js @@ -0,0 +1,56 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Secure Boot Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that secure boot is enabled for Virtual Machine Scale Sets.', + more_info: 'Secure Boot, which is implemented in platform firmware, protects against the installation of malware-based rootkits and boot kits. Secure Boot works to ensure that only signed operating systems and drivers can boot. It establishes a "root of trust" for the software stack on your VMSS.', + recommended_action: 'Modify virtual machine scale set configurations and enable secure boot', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch#secure-boot', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + + if (virtualMachineScaleSet.virtualMachineProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.uefiSettings && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.uefiSettings.secureBootEnabled) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has secure boot enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set have secure boot disabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.spec.js b/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.spec.js new file mode 100644 index 000000000..0f4ad5ed1 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scalesetSecureBootEnabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var scaleSetSecureBootEnabled = require('./scalesetSecureBootEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + }, + "encryptionAtHost": true, + "securityType": "TrustedLaunch" + }, + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": false, + "vTpmEnabled": false + }, + "encryptionAtHost": true, + "securityType": "Standard" + }, + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('scaleSetSecureBootEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + scaleSetSecureBootEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + scaleSetSecureBootEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set has secure boot enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + scaleSetSecureBootEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has secure boot enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has secure boot disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + scaleSetSecureBootEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set have secure boot disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.js b/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.js new file mode 100644 index 000000000..d83f6a308 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.js @@ -0,0 +1,56 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets vTPM Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that Virtual Trusted Platform Module (vTPM) is enabled for Virtual Machine Scale Sets.', + more_info: 'vTPM is TPM2.0 compliant and enhances security by validating VM boot integrity and providing a secure storage mechanism for keys and secrets. The vTPM enables attestation by measuring the entire boot chain of your VM (UEFI, OS, system, and drivers).', + recommended_action: 'Modify virtual machine scale set configurations and enable vTPM', + link: 'https://learn.microsoft.com/en-us/windows/security/hardware-security/tpm/trusted-platform-module-overview', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + + if (virtualMachineScaleSet.virtualMachineProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.uefiSettings && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.uefiSettings.vTpmEnabled) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has vTPM enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has vTPM disabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.spec.js b/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.spec.js new file mode 100644 index 000000000..32a93c42e --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/scalesetVTPMEnabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var scalesetVTPMEnabled = require('./scalesetVTPMEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + }, + "encryptionAtHost": true, + "securityType": "TrustedLaunch" + }, + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": false + }, + "encryptionAtHost": true, + "securityType": "Standard" + }, + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('scalesetVTPMEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + scalesetVTPMEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + scalesetVTPMEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set has vTPM enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + scalesetVTPMEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has vTPM enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has vTPM disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + scalesetVTPMEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has vTPM disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.js b/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.js new file mode 100644 index 000000000..ab8118dbd --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.js @@ -0,0 +1,51 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + + +module.exports = { + title: 'VM Scale Set Has Tags', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Azure Virtual Machine scale sets have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + recommended_action: 'Modify VM scale set and add tags.', + apis: ['vmScaleSet:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vmScaleSet, function(location, rcb) { + + var vmScaleSets = helpers.addSource(cache, source, ['vmScaleSet', 'listAll', location]); + + if (!vmScaleSets) return rcb(); + + if (vmScaleSets.err || !vmScaleSets.data) { + helpers.addResult(results, 3, 'Unable to query for VM scale sets: ' + helpers.addError(vmScaleSets), location); + return rcb(); + } + if (!vmScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing VM scale sets found', location); + return rcb(); + } + for (let set of vmScaleSets.data) { + if (!set.id) continue; + + if (set.tags && Object.entries(set.tags).length > 0){ + helpers.addResult(results, 0, 'VM scale set has tags', location, set.id); + } else { + helpers.addResult(results, 2, 'VM scale set does not have tags', location, set.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.spec.js b/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.spec.js new file mode 100644 index 000000000..e622abb67 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmScaleSetHasTags.spec.js @@ -0,0 +1,79 @@ +var expect = require('chai').expect; +var vmScaleSetHasTags = require('./vmScaleSetHasTags'); + +const vmScaleSet = [ + { "name": 'test', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachineScaleSets/test", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "centralus", + "tags": { + "key" : "value" + } + }, + { "name": 'test', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachineScaleSets/test", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "centralus", + "tags": {} + }, +]; + +const createCache = (vmScaleSet) => { + return { + vmScaleSet: { + listAll: { + 'eastus': { + data: vmScaleSet + } + } + } + }; +}; + +describe('vmScaleSetHasTags', function() { + describe('run', function() { + it('should give passing result if no scale set found', function(done) { + const cache = createCache([]); + vmScaleSetHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing VM scale sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for scale set', function(done) { + const cache = createCache(); + vmScaleSetHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for VM scale sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if scale set has tags', function(done) { + const cache = createCache([vmScaleSet[0]]); + vmScaleSetHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VM scale set has tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM scale set does not have tags', function(done) { + const cache = createCache([vmScaleSet[1]]); + vmScaleSetHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM scale set does not have tags'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.js b/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.js new file mode 100644 index 000000000..2e88b017a --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.js @@ -0,0 +1,81 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Scale Set Approved Extensions', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'High', + description: 'Ensures that approved Virtual Machine Scale Set extensions are installed.', + more_info: 'Extensions are small applications that provide post-deployment configuration and automation on Azure VMs. Extensions installed should be approved by the organization to meet the organizational security requirements.', + recommended_action: 'Uninstall unapproved virtual machine scale set extensions', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/extensions/overview', + apis: ['virtualMachineScaleSets:listAll'], + settings: { + vmss_approved_extensions: { + name: 'Approved Virtual Machine Scale set extensions', + description: 'List of comma separated approved extension names', + regex: '^.*$', + default: '' + } + }, + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + approvedExtensions: settings.vmss_approved_extensions || this.settings.vmss_approved_extensions.default + }; + + if (!config.approvedExtensions.length) return callback(null, results, source); + + var extensionsList = config.approvedExtensions.split(','); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + for (let virtualMachineScaleSet of virtualMachineScaleSets.data){ + + const scaleSetExtensions = virtualMachineScaleSet.virtualMachineProfile && virtualMachineScaleSet.virtualMachineProfile.extensionProfile && + virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + ? virtualMachineScaleSet.virtualMachineProfile.extensionProfile.extensions + : []; + + if (!scaleSetExtensions.length) { + helpers.addResult(results, 0, 'No VMSS Extensions found', location); + continue; + } + + scaleSetExtensions.forEach(function(vmssEx) { + let found = extensionsList.some(extension => extension.trim() === vmssEx.name); + + if (found) { + helpers.addResult(results, 0, `${vmssEx.name} extension is approved by the organization`, location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, `${vmssEx.name} extension is not approved by the organization`, location, virtualMachineScaleSet.id); + } + }); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.spec.js b/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.spec.js new file mode 100644 index 000000000..be9c9ca0d --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssApprovedExtensions.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var vmssApprovedExtensions = require('./vmssApprovedExtensions'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'healthRepairExtension', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'ApplicationHealthLinux', + 'typeHandlerVersion': '1.0', + 'settings': { + 'protocol': 'http', + 'port': 80, + 'requestPath': '/' + } + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [ + { + 'name': 'errorextension', + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + 'extensionProfile': { + 'extensions': [] + } + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmssApprovedExtensions', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + vmssApprovedExtensions.run(cache, { vmss_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + vmssApprovedExtensions.run(cache, { vmss_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if no VMSS Extensions found', function(done) { + const cache = createCache([virtualMachineScaleSets[2]]); + vmssApprovedExtensions.run(cache, { vmss_approved_extensions: 'ext' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VMSS Extensions found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if installed extensions are approved by the organization', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + vmssApprovedExtensions.run(cache, { vmss_approved_extensions: 'healthRepairExtension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('extension is approved by the organization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if installed extensions are not approved by the organization', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + vmssApprovedExtensions.run(cache, { vmss_approved_extensions: 'healthRepairExtension' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('extension is not approved by the organization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.js b/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.js new file mode 100644 index 000000000..827924780 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Boot Diagnostics Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that boot diagnostics is enabled for Virtual Machine Scale Set.', + more_info: 'Boot diagnostics is a debugging feature for Azure virtual machines (VM) scale sets that allows diagnosis of VM scale set boot failures. Boot diagnostics enables a user to observe the state of their scale set as it is booting up by collecting serial log information and screenshots.', + recommended_action: 'Enable boot diagnostics for virtual machine scale set.', + link: 'https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/boot-diagnostics', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + if (!virtualMachineScaleSet.id) continue; + + if (virtualMachineScaleSet.virtualMachineProfile && + virtualMachineScaleSet.virtualMachineProfile.diagnosticsProfile && + virtualMachineScaleSet.virtualMachineProfile.diagnosticsProfile.bootDiagnostics && + virtualMachineScaleSet.virtualMachineProfile.diagnosticsProfile.bootDiagnostics.enabled) { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has boot diagnostics enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set does not have boot diagnostics enabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.spec.js b/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.spec.js new file mode 100644 index 000000000..395b1fafb --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssBootDiagnosticsEnabled.spec.js @@ -0,0 +1,112 @@ +var expect = require('chai').expect; +var vmssBootDiagnosticsEnabled = require('./vmssBootDiagnosticsEnabled'); + +const vmScaleSet = [ + { "name": 'test', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachineScaleSets/test", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "centralus", + "virtualMachineProfile": { + "osProfile": { + "computerNamePrefix": 'aks-agentpool-209948-vmss', + "adminUsername": 'azureuser', + "secrets": [], + "allowExtensionOperations": true, + "requireGuestProvisionSignal": true + }, + "storageProfile": { + "osDisk": ["Object"], + "imageReference": ["Object"], + "diskControllerType": 'SCSI' + }, + "diagnosticsProfile": { + "bootDiagnostics": { + "enabled": true + } + }, + }, + }, + { "name": 'test', + "id": "/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Compute/virtualMachineScaleSets/test", + "type": "Microsoft.Compute/virtualMachineScaleSets", + "location": "centralus", + "tags": {}, + "virtualMachineProfile": { + "osProfile": { + "computerNamePrefix": 'aks-agentpool-209948-vmss', + "adminUsername": 'azureuser', + "secrets": [], + "allowExtensionOperations": true, + "requireGuestProvisionSignal": true + }, + "storageProfile": { + "osDisk": ["Object"], + "imageReference": ["Object"], + "diskControllerType": 'SCSI' + }, + "diagnosticsProfile": { + }, + }, + }, +]; +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmssBootDiagnosticsEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + vmssBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + vmssBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if boot diagnostics are enabled for virtual machine scale sets', function(done) { + const cache = createCache([vmScaleSet[0]]); + vmssBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has boot diagnostics enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }); + + it('should give failing result if boot diagnostics are disabled for virtual machine scale sets', function(done) { + const cache = createCache([vmScaleSet[1]]); + vmssBootDiagnosticsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set does not have boot diagnostics enabled'); + expect(results[0].region).to.equal('eastus'); + done() + }); + }) + }) +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js new file mode 100644 index 000000000..4ddb27d33 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js @@ -0,0 +1,49 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VM Scale Set Managed Identity Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Azure Virtual Machine Scale Sets have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/qs-configure-portal-windows-vmss', + recommended_action: 'Modify VM Scale Set and enable managed identity.', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.vmScaleSet, function(location, rcb) { + + var vmScaleSets = helpers.addSource(cache, source, ['virtualMachineScaleSets', 'listAll', location]); + + if (!vmScaleSets) return rcb(); + + if (vmScaleSets.err || !vmScaleSets.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(vmScaleSets), location); + return rcb(); + } + if (!vmScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + for (let scaleSet of vmScaleSets.data) { + if (!scaleSet.id) continue; + + if (scaleSet.identity && scaleSet.identity.type){ + helpers.addResult(results, 0, 'Virtual Machine Scale Set has managed identity enabled', location, scaleSet.id); + } else { + helpers.addResult(results, 2, 'Virtual Machine Scale Set does not have managed identity enabled', location, scaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.spec.js b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.spec.js new file mode 100644 index 000000000..ec95d912c --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.spec.js @@ -0,0 +1,82 @@ +var expect = require('chai').expect; +var vmssManagedIdentityEnabled = require('./vmssManagedIdentityEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + "identity": { + "type": "SystemAssigned", + "principalId": "123454", + "tenantId": "1234548" + }, + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmssManagedIdentityEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + vmssManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + vmssManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if VM scale set has managed identity enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + vmssManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if VM scale set does not have managed identity enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + vmssManagedIdentityEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set does not have managed identity enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.js b/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.js new file mode 100644 index 000000000..1b73c4f70 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Scale Sets Trusted Launch Enabled', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Low', + description: 'Ensures that trusted launch security is enabled for Virtual Machine Scale Set.', + more_info: 'Enabling trusted launch works in seamless way to improve the security of VM scale sets. Trusted launch protects against advanced and persistent attack techniques. It is composed of several, coordinated infrastructure technologies that can be enabled independently, providing another layer of defense against sophisticated threats.', + recommended_action: 'Remove existing Virtual Machine Scale Set and create a new one with trusted launch enabled.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, (location, rcb) => { + const virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, + 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let virtualMachineScaleSet of virtualMachineScaleSets.data) { + if (!virtualMachineScaleSet.id) continue; + + if (virtualMachineScaleSet.virtualMachineProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.securityType && + virtualMachineScaleSet.virtualMachineProfile.securityProfile.securityType.toLowerCase() == 'trustedlaunch') { + helpers.addResult(results, 0, + 'Virtual Machine Scale Set has trusted launch enabled', location, virtualMachineScaleSet.id); + } else { + helpers.addResult(results, 2, + 'Virtual Machine Scale Set has trusted launch disabled', location, virtualMachineScaleSet.id); + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.spec.js b/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.spec.js new file mode 100644 index 000000000..c3808268b --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssTrustedLaunchEnabled.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var vmssTrustedLaunchEnabled = require('./vmssTrustedLaunchEnabled'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + }, + "encryptionAtHost": true, + "securityType": "TrustedLaunch" + }, + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/KHULNASOFT-RESOURCE-GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "securityProfile": { + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + }, + "encryptionAtHost": true, + "securityType": "Standard" + }, + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmssTrustedLaunchEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + vmssTrustedLaunchEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + vmssTrustedLaunchEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Virtual Machine Scale Set has trusted launch enabled', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + vmssTrustedLaunchEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Machine Scale Set has trusted launch enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Virtual Machine Scale Set has trusted launch disabled', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + vmssTrustedLaunchEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine Scale Set has trusted launch disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.js b/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.js new file mode 100644 index 000000000..02c859232 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.js @@ -0,0 +1,68 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'VMSS Windows AntiMalware Extension', + category: 'Virtual Machine Scale Set', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that Virtual Machine Scale Set windows instances have IaaS AntiMalware extension installed', + more_info: 'The VM Scale Set Windows AntiMalware Extension provides real-time protection against viruses, spyware, and other malicious software for virtual machine scale sets running on the Windows operating system.', + recommended_action: 'Modify Virtual Machine Scale Set and install IaaS AntiMalware extension.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/extensions/iaas-antimalware-windows', + apis: ['virtualMachineScaleSets:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualMachineScaleSets, function(location, rcb) { + var virtualMachineScaleSets = helpers.addSource(cache, source, + ['virtualMachineScaleSets', 'listAll', location]); + + if (!virtualMachineScaleSets) return rcb(); + + if (virtualMachineScaleSets.err || !virtualMachineScaleSets.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Machine Scale Sets: ' + helpers.addError(virtualMachineScaleSets), location); + return rcb(); + } + + if (!virtualMachineScaleSets.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Machine Scale Sets found', location); + return rcb(); + } + + for (let scaleSet of virtualMachineScaleSets.data) { + if (!scaleSet.id || + (scaleSet.virtualMachineProfile && + scaleSet.virtualMachineProfile.storageProfile && + scaleSet.virtualMachineProfile.storageProfile.osDisk && + scaleSet.virtualMachineProfile.storageProfile.osDisk.osType && + scaleSet.virtualMachineProfile.storageProfile.osDisk.osType.toLowerCase() != 'windows')) continue; + + const scaleSetExtensions = scaleSet.virtualMachineProfile && + scaleSet.virtualMachineProfile.extensionProfile && + scaleSet.virtualMachineProfile.extensionProfile.extensions ? + scaleSet.virtualMachineProfile.extensionProfile.extensions : []; + + if (!scaleSetExtensions.length) { + helpers.addResult(results, 2, 'No VMSS Extensions found', location, scaleSet.id); + } else { + let found = scaleSetExtensions.find(vmExt => vmExt.name && vmExt.name.toLowerCase() === 'iaasantimalware'); + if (found) { + helpers.addResult(results, 0, 'Windows Virtual Machine Scale Set has IaaS Antimalware extension installed', location, scaleSet.id); + } else { + helpers.addResult(results, 2, 'Windows Virtual Machine Scale Set does not have IaaS Antimalware extension installed', location, scaleSet.id); + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.spec.js b/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.spec.js new file mode 100644 index 000000000..355f79e24 --- /dev/null +++ b/plugins/azure/virtualmachinescaleset/vmssWindowsAntiMalwareExt.spec.js @@ -0,0 +1,141 @@ +var expect = require('chai').expect; +var vmssWindowsAntiMalwareExt = require('./vmssWindowsAntiMalwareExt'); + +const virtualMachineScaleSets = [ + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "storageProfile": { + "osDisk": { + "osType": "windows", + }, + }, + 'extensionProfile': { + 'extensions': [ + { + 'name': 'iaasantimalware', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'AADSSHLoginForLinux', + 'typeHandlerVersion': '1.0', + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "storageProfile": { + "osDisk": { + "osType": "windows", + }, + }, + 'extensionProfile': { + 'extensions': [ + { + 'name': 'AADLoginForWindows', + 'properties': { + 'autoUpgradeMinorVersion': false, + 'publisher': 'Microsoft.ManagedServices', + 'type': 'AADLoginForWindows', + 'typeHandlerVersion': '1.0', + } + } + ] + } + } + }, + { + 'name': 'test-vmss', + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss', + 'type': 'Microsoft.Compute/virtualMachineScaleSets', + 'virtualMachineProfile': { + "storageProfile": { + "osDisk": { + "osType": "windows", + }, + }, + 'extensionProfile': { + 'extensions': [] + } + } + } +]; + +const createCache = (virtualMachineScaleSets) => { + let machine = {}; + if (virtualMachineScaleSets) { + machine['data'] = virtualMachineScaleSets; + } + return { + virtualMachineScaleSets: { + listAll: { + 'eastus': machine + } + } + }; +}; + +describe('vmssWindowsAntiMalwareExt', function() { + describe('run', function() { + it('should give passing result if no virtual machine scale sets', function(done) { + const cache = createCache([]); + vmssWindowsAntiMalwareExt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Machine Scale Sets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual machine scale sets', function(done) { + const cache = createCache(); + vmssWindowsAntiMalwareExt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Machine Scale Sets'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if windows Virtual Machine Scale Set has anti malware extension installed', function(done) { + const cache = createCache([virtualMachineScaleSets[0]]); + vmssWindowsAntiMalwareExt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Windows Virtual Machine Scale Set has IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give failing result if Virtual Machine Scale Set does not have anti malware extension installed', function(done) { + const cache = createCache([virtualMachineScaleSets[1]]); + vmssWindowsAntiMalwareExt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Windows Virtual Machine Scale Set does not have IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give failing result if no extensions installed', function(done) { + const cache = createCache([virtualMachineScaleSets[2]]); + vmssWindowsAntiMalwareExt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No VMSS Extensions found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.js b/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.js new file mode 100644 index 000000000..cafeb2532 --- /dev/null +++ b/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'DDoS Standard Protection Enabled', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that DDoS Standard Protection is enabled for Microsoft Azure Virtual Networks', + more_info: 'DDoS Protection Standard offers enhanced Distributed Denial-of-Service (DDoS) mitigation capabilities via adaptive tuning, attack alert notifications, and telemetry to protect against the impacts of large DDoS attacks for all the protected resources available within your Azure Virtual Networks.', + recommended_action: 'Enable DDoS protection for virtual networks', + link: 'https://azure.microsoft.com/en-us/blog/azure-ddos-protection-for-virtual-networks-generally-available/', + apis: ['virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, (location, rcb) => { + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + virtualNetworks.data.forEach(virtualNetwork => { + if (virtualNetwork.enableDdosProtection) { + helpers.addResult(results, 0, + 'DDoS Standard Protection is enabled for Microsoft Azure Virtual Network', location, virtualNetwork.id); + } else { + helpers.addResult(results, 2, + 'DDoS Standard Protection is not enabled for Microsoft Azure Virtual Network', location, virtualNetwork.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.spec.js b/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.spec.js new file mode 100644 index 000000000..87b26da8a --- /dev/null +++ b/plugins/azure/virtualnetworks/ddosStandardProtectionEnabled.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var ddosStandardProtectionEnabled = require('./ddosStandardProtectionEnabled'); + +const virtualNetworks = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (virtualNetworks) => { + return { + virtualNetworks: { + listAll: { + 'eastus': { + data: virtualNetworks + } + } + } + }; +}; + +const createErrorCache = () => { + return { + virtualNetworks: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('ddosStandardProtectionEnabled', function() { + describe('run', function() { + it('should give passing result if no virtual networks', function(done) { + const cache = createCache([]); + ddosStandardProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if DDoS standard protection is not enabled for virtual network', function(done) { + const cache = createCache([virtualNetworks[1]]); + ddosStandardProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DDoS Standard Protection is not enabled for Microsoft Azure Virtual Network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Virtual Networks', function(done) { + const cache = createErrorCache(); + ddosStandardProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if DDoS standard protection is enabled for virtual network', function(done) { + const cache = createCache([virtualNetworks[0]]); + ddosStandardProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DDoS Standard Protection is enabled for Microsoft Azure Virtual Network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/managedNatGateway.js b/plugins/azure/virtualnetworks/managedNatGateway.js new file mode 100644 index 000000000..6a1be08a1 --- /dev/null +++ b/plugins/azure/virtualnetworks/managedNatGateway.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Managed NAT Gateway In Use', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure Azure Virtual Network Managed NAT (Network Address Translation) Gateway service is enabled for Virtual Network.', + more_info: 'To meet your organization\'s security compliance requirements.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/nat-overview', + recommended_action: 'Enable Virtual Network NAT gateway for Virtual Networks', + apis: ['virtualNetworks:listAll', 'natGateways:listBySubscription'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftnetwork:natgateways:write','microsoftnetwork:natgateways:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, function(location, rcb){ + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + let natSubnets = []; + var natGateways = helpers.addSource(cache, source, + ['natGateways', 'listBySubscription', location]); + + if (!natGateways || natGateways.err || !natGateways.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Network NAT Gateways: ' + helpers.addError(natGateways), location); + return rcb(); + } + + if (natGateways.data.length) { + natGateways.data.forEach(natGateway => { + if (natGateway.subnets && natGateway.subnets.length) { + for (let subnet of natGateway.subnets) { + natSubnets.push(subnet.id); + } + } + }); + } + + virtualNetworks.data.forEach(virtualNetwork => { + if (!virtualNetwork.id) return; + let found = (virtualNetwork.subnets && virtualNetwork.subnets.length) ? virtualNetwork.subnets.some(subnet => natSubnets.includes(subnet.id)) : false; + + if (found) { + helpers.addResult(results, 0, 'Virtual Network Managed NAT (Network Address Translation) Gateway service is enabled for Virtual Network', location, virtualNetwork.id); + } else { + helpers.addResult(results, 2, 'Virtual Network Managed NAT (Network Address Translation) Gateway service is disabled for Virtual Network', location, virtualNetwork.id); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/managedNatGateway.spec.js b/plugins/azure/virtualnetworks/managedNatGateway.spec.js new file mode 100644 index 000000000..e85cccad9 --- /dev/null +++ b/plugins/azure/virtualnetworks/managedNatGateway.spec.js @@ -0,0 +1,126 @@ +var expect = require('chai').expect; +var managedNatGateway = require('./managedNatGateway'); + +const natGateways = [ + { + 'name': 'test-nat-gateway', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/natGateways/test-nat-gateway', + 'type': 'Microsoft.Network/natGateways', + 'subnets': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/default' + } + ] + } +]; + +const virtualNetworks = [ + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'subnets': [ + { + 'name': 'default', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/default', + 'type': 'Microsoft.Network/virtualNetworks/subnets' + } + ] + }, + { + 'name': 'test-vnet-2', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet-2', + 'type': 'Microsoft.Network/virtualNetworks', + 'subnets': [ + { + 'name': 'default', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet-2/subnets/default', + 'type': 'Microsoft.Network/virtualNetworks/subnets' + } + ] + } +]; + +const createCache = (virtualNetworks, natGateways) => { + let network = {}; + let natGateway = {}; + + if (virtualNetworks) { + network['data'] = virtualNetworks; + } + if (natGateways) { + natGateway['data'] = natGateways; + } + + return { + virtualNetworks: { + listAll: { + 'eastus': network + } + }, + natGateways: { + listBySubscription: { + 'eastus': natGateway + } + }, + }; +}; + +describe('managedNatGateway', function() { + describe('run', function() { + it('should give passing result if No existing virtual networks found', function(done) { + const cache = createCache([]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual networks', function(done) { + const cache = createCache(); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Virtual Network NAT Gateways', function(done) { + const cache = createCache([virtualNetworks[0]]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Network NAT Gateways'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if NAT Gateway is configured for Virtual Network', function(done) { + const cache = createCache([virtualNetworks[0]], [natGateways[0]]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Network Managed NAT (Network Address Translation) Gateway service is enabled for Virtual Network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if NAT Gateway is not configured for Virtual Network', function(done) { + const cache = createCache([virtualNetworks[1]], [natGateways[0]]); + managedNatGateway.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Network Managed NAT (Network Address Translation) Gateway service is disabled for Virtual Network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/multipleSubnets.js b/plugins/azure/virtualnetworks/multipleSubnets.js index aa4419bb3..e46e130f4 100644 --- a/plugins/azure/virtualnetworks/multipleSubnets.js +++ b/plugins/azure/virtualnetworks/multipleSubnets.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/azure/'); module.exports = { title: 'Multiple Subnets', category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures that Virtual Networks have multiple networks to provide a layered architecture', more_info: 'A single network within a Virtual Network increases the risk of a broader blast radius in the event of a compromise.', - link: 'https://docs.microsoft.com/en-us/azure/virtual-network/virtual-network-vnet-plan-design-arm', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/virtual-network-vnet-plan-design-arm', recommended_action: 'Create multiple networks/subnets in each Virtual Network and change the architecture to take advantage of public and private tiers.', apis: ['virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], run: function(cache, settings, callback) { var results = []; diff --git a/plugins/azure/virtualnetworks/multipleSubnets.spec.js b/plugins/azure/virtualnetworks/multipleSubnets.spec.js new file mode 100644 index 000000000..f683f6d0d --- /dev/null +++ b/plugins/azure/virtualnetworks/multipleSubnets.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var multipleSubnets = require('./multipleSubnets'); + +const listVnets = [ + { + "name": "cloudsplit-vnet", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet", + "type": "Microsoft.Network/virtualNetworks", + "subnets": [ + { + "name": "default", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet/subnets/default", + "type": "Microsoft.Network/virtualNetworks/subnets" + }, + { + "name": "default1", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet/subnets/default", + "type": "Microsoft.Network/virtualNetworks/subnets" + } + ] + }, + { + "name": "cloudsplit-vnet", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet", + "type": "Microsoft.Network/virtualNetworks", + "subnets": [ + { + "name": "default", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet/subnets/default", + "type": "Microsoft.Network/virtualNetworks/subnets" + } + ] + }, + { + "name": "cloudsplit-vnet", + "id": "/subscriptions/123/resourceGroups/khulnasoft-rg/providers/Microsoft.Network/virtualNetworks/cloudsplit-vnet", + "type": "Microsoft.Network/virtualNetworks", + "subnets": [] + } +]; + +const createCache = (vnets, err) => { + return { + virtualNetworks: { + listAll: { + 'eastus': { + err: err, + data: vnets + } + } + } + } +}; + +describe('multipleSubnets', function() { + describe('run', function() { + it('should give passing result if No existing Virtual Networks found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [] + ); + + multipleSubnets.run(cache, {}, callback); + }); + + it('should give failing result if only one subnet in the Virtual Network is used', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Only one subnet in the Virtual Network is used'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listVnets[1]] + ); + + multipleSubnets.run(cache, {}, callback); + }); + + it('should give passing result if there are more than one subnets in Virtual Network', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('different subnets used in the Virtual Network'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listVnets[0]] + ); + + multipleSubnets.run(cache, {}, callback); + }); + + it('should give passing result if the Virtual Network does not have any subnets', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The Virtual Network does not have any subnets'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listVnets[2]] + ); + + multipleSubnets.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Virtual Networks', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + listVnets, + { message: 'unable to query Virtual Networks'} + ); + + multipleSubnets.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/noGatewayConnections.js b/plugins/azure/virtualnetworks/noGatewayConnections.js new file mode 100644 index 000000000..4503b1f68 --- /dev/null +++ b/plugins/azure/virtualnetworks/noGatewayConnections.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'No Network Gateways Connections', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that virtual network gateways do not have any established connections.', + more_info: 'To meet your organization\'s security compliance requirements.', + link: 'https://learn.microsoft.com/en-us/azure/vpn-gateway/tutorial-site-to-site-portal', + recommended_action: 'Delete network gateway connections', + apis: ['resourceGroups:list', 'networkGatewayConnections:listByResourceGroup'], + realtime_triggers: ['microsoftnetwork:virtualnetworkgateways:write','microsoftnetwork:virtualnetworkgateways:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.resourceGroups, function(location, rcb) { + let resourceGroups = helpers.addSource(cache, source, + ['resourceGroups', 'list', location]); + + if (!resourceGroups) return rcb(); + + if (resourceGroups.err || !resourceGroups.data) { + helpers.addResult(results, 3, 'Unable to query for resource groups: ' + helpers.addError(resourceGroups), location); + return rcb(); + } + + if (!resourceGroups.data.length) { + helpers.addResult(results, 0, 'No existing resource groups found', location); + return rcb(); + } + + async.each(resourceGroups.data, function(resourceGroup, scb) { + let networkGatewayConnections = helpers.addSource(cache, source, + ['networkGatewayConnections', 'listByResourceGroup', location, resourceGroup.id]); + + if (!networkGatewayConnections || networkGatewayConnections.err || !networkGatewayConnections.data) { + helpers.addResult(results, 3, 'Unable to query for network gateway connections : ' + helpers.addError(networkGatewayConnections), location); + return scb(); + } + + if (!networkGatewayConnections.data.length) { + helpers.addResult(results, 0, 'No connections found for network gateways', location); + return scb(); + } + + networkGatewayConnections.data.forEach(networkGatewayConnection => { + if (networkGatewayConnection.virtualNetworkGateway1 && networkGatewayConnection.virtualNetworkGateway1.id && + networkGatewayConnection.virtualNetworkGateway2 && networkGatewayConnection.virtualNetworkGateway2.id) { + let gateway1 = networkGatewayConnection.virtualNetworkGateway1.id.split('/'); + gateway1 = gateway1[gateway1.length - 1]; + let gateway2 = networkGatewayConnection.virtualNetworkGateway2.id.split('/'); + gateway2 = gateway2[gateway2.length - 1]; + helpers.addResult(results, 2, `${gateway1} has an established connection with ${gateway2}`, location, networkGatewayConnection.virtualNetworkGateway1.id); + helpers.addResult(results, 2, `${gateway2} has an established connection with ${gateway1}`, location, networkGatewayConnection.virtualNetworkGateway2.id); + } + }); + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/noGatewayConnections.spec.js b/plugins/azure/virtualnetworks/noGatewayConnections.spec.js new file mode 100644 index 000000000..59b5107a8 --- /dev/null +++ b/plugins/azure/virtualnetworks/noGatewayConnections.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +var noGatewayConnections = require('./noGatewayConnections'); + +const resourceGroups = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group', + 'name': 'khulnasoft-resource-group', + 'type': 'Microsoft.Resources/resourceGroups', + 'location': 'eastus' + } +]; + +const networkGatewayConnections = [ + { + 'name': 'test-connection', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/connections/test-connection', + 'type': 'Microsoft.Network/connections', + 'virtualNetworkGateway1': { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworkGateways/test-gateway' + }, + 'virtualNetworkGateway2': { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworkGateways/test-gateway-2' + } + } +]; + +const createCache = (resourceGroups, networkGatewayConnections) => { + let groups = {}; + let connections = {}; + + if (resourceGroups) { + groups['data'] = resourceGroups; + if (resourceGroups.length && networkGatewayConnections) { + connections[resourceGroups[0].id] = { + 'data': networkGatewayConnections + }; + } + } + + return { + resourceGroups: { + list: { + 'eastus': groups + } + }, + networkGatewayConnections: { + listByResourceGroup: { + 'eastus': connections + } + }, + }; +}; + +describe('noGatewayConnections', function() { + describe('run', function() { + it('should give passing result if No existing resource groups found', function(done) { + const cache = createCache([]); + noGatewayConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing resource groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for resource groups', function(done) { + const cache = createCache(); + noGatewayConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for resource groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if No network gateway connections found', function(done) { + const cache = createCache([resourceGroups[0]], []); + noGatewayConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No connections found for network gateways'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for network gateway connections', function(done) { + const cache = createCache([resourceGroups[0]]); + noGatewayConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for network gateway connections'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if there are network gateway connections', function(done) { + const cache = createCache([resourceGroups[0]], [networkGatewayConnections[0]]); + noGatewayConnections.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('test-gateway has an established connection with test-gateway-2'); + + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('test-gateway-2 has an established connection with test-gateway'); + + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.js b/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.js new file mode 100644 index 000000000..e2d839669 --- /dev/null +++ b/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.js @@ -0,0 +1,122 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'No Network Gateways In Use', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that Virtual Networks are using subnets and network security groups instead of virtual network gateways.', + more_info: 'Use subnets and network security groups to control network traffic instead of using virtual network gateways to meet your organization\'s security and compliance requirements.', + link: 'https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-about-vpngateways', + recommended_action: 'Configure subnets and network security groups instead of virtual network gateways', + apis: ['resourceGroups:list','virtualNetworks:listAll','virtualNetworkGateways:listByResourceGroup'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftnetwork:virtualnetworkgateways:write','microsoftnetwork:virtualnetworkgateways:delete'], + settings: { + vNet_gatewayType: { + name: 'Virtual Network Gateway Type', + description: 'Gateway type that should not be configured with virtual networks i.e. Vpn, ExpressRoute.', + regex: '^(vpn|expressroute)$', + default: '', + } + }, + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = settings.vNet_gatewayType || this.settings.vNet_gatewayType.default; + + async.each(locations.virtualNetworks, function(location, rcb){ + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + var resourceGroups = helpers.addSource(cache, source, + ['resourceGroups', 'list', location]); + + if (!resourceGroups || resourceGroups.err || !resourceGroups.data) { + helpers.addResult(results, 3, 'Unable to query for resource groups: ' + helpers.addError(resourceGroups), location); + return rcb(); + } + + if (!resourceGroups.data.length) { + helpers.addResult(results, 0, 'No existing resource groups found', location); + return rcb(); + } + + let gatewaysList = []; + resourceGroups.data.forEach(resourceGroup => { + var virtualNetworkGateways = helpers.addSource(cache, source, + ['virtualNetworkGateways', 'listByResourceGroup', location, resourceGroup.id]); + + if (!virtualNetworkGateways || virtualNetworkGateways.err || !virtualNetworkGateways.data) { + helpers.addResult(results, 3, 'Unable to query for virtual Network Gateways: ' + helpers.addError(virtualNetworkGateways), location); + return; + } + + if (virtualNetworkGateways.data.length) { + for (let virtualNetworkGateway of virtualNetworkGateways.data) { + gatewaysList.push({id:virtualNetworkGateway.id, name: virtualNetworkGateway.name, gatewayType: virtualNetworkGateway.gatewayType}); + } + } + }); + + virtualNetworks.data.forEach(virtualNetwork => { + let gatewayUsed = true; + let subnetFound = false; + let restrictGateways = []; + if (virtualNetwork.subnets.length) { + for (let subnet of virtualNetwork.subnets) { + if (subnet.properties && subnet.properties.ipConfigurations && subnet.properties.ipConfigurations.length) { + let gatewayFound = false; + for (let gateway of gatewaysList) { + if (subnet.properties.ipConfigurations.some(configuration => (configuration.id.toLowerCase().indexOf(gateway.id.toLowerCase()) > -1))) { + gatewayFound = true; + if (config.toLowerCase() == gateway.gatewayType.toLowerCase()) { + restrictGateways.push(gateway.name); + } + } + + } + + if (gatewayFound) { + subnetFound = true; + } + } + } + } else { + gatewayUsed = false; + } + + if ((gatewayUsed && !subnetFound) || !gatewayUsed) { + helpers.addResult(results, 0, 'Virtual network is not using network gateways', location, virtualNetwork.id); + } else { + if (config.toLowerCase() =='vpn'|| config.toLowerCase() =='expressroute') { + if (restrictGateways.length) { + helpers.addResult(results, 2, `Virtual network is using following ${config} network gateways: ${restrictGateways.join(',')}`, location, virtualNetwork.id); + } else { + helpers.addResult(results, 0, `Virtual network is not using any ${config} network gateways`, location, virtualNetwork.id); + } + } else { + helpers.addResult(results, 2, 'Virtual network is using network gateways', location, virtualNetwork.id); + } + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.spec.js b/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.spec.js new file mode 100644 index 000000000..dccd42248 --- /dev/null +++ b/plugins/azure/virtualnetworks/noNetworkGatewaysInUse.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var noNetworkGatewaysInUse = require('./noNetworkGatewaysInUse'); + +const virtualNetworks = [ + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'location': 'eastus', + 'subnets': [ + { + 'name': 'GatewaySubnet', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/GatewaySubnet', + 'properties': { + 'ipConfigurations': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworkGateways/test-gateway/ipConfigurations/default' + } + ], + } + } + ], + } +]; + +const resourceGroups = [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group', + 'name': 'khulnasoft-resource-group', + 'type': 'Microsoft.Resources/resourceGroups', + 'location': 'eastus' + } +]; + +const virtualNetworkGateways = [ + { + 'name': 'test-gateway', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworkGateways/test-gateway', + 'type': 'Microsoft.Network/virtualNetworkGateways', + 'gatewayType': 'Vpn' + } +]; + +const createCache = (virtualNetworks, resourceGroups, virtualNetworkGateways) => { + let networks = {}; + let groups = {}; + let gateways = {}; + + if (virtualNetworks) { + networks['data'] = virtualNetworks; + } + + if (resourceGroups) { + groups['data'] = resourceGroups; + if (resourceGroups.length && virtualNetworkGateways) { + gateways[resourceGroups[0].id] = { + 'data': virtualNetworkGateways + }; + } + } + + return { + virtualNetworks: { + listAll: { + 'eastus': networks + } + }, + resourceGroups: { + list: { + 'eastus': groups + } + }, + virtualNetworkGateways: { + listByResourceGroup: { + 'eastus': gateways + } + }, + }; +}; + +describe('noNetworkGatewaysInUse', function() { + describe('run', function() { + it('should give passing result if No existing Virtual Networks found', function(done) { + const cache = createCache([]); + noNetworkGatewaysInUse.run(cache, { govcloud: false }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Virtual Networks', function(done) { + const cache = createCache(); + noNetworkGatewaysInUse.run(cache, { govcloud: false }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if No existing resource groups found', function(done) { + const cache = createCache([virtualNetworks[0]], []); + noNetworkGatewaysInUse.run(cache, { govcloud: false }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing resource groups found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for resource groups', function(done) { + const cache = createCache([virtualNetworks[0]]); + noNetworkGatewaysInUse.run(cache, { govcloud: false }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for resource groups'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual network is not using network gateways', function(done) { + const cache = createCache([virtualNetworks[0]], [resourceGroups[0]], []); + noNetworkGatewaysInUse.run(cache, { govcloud: false }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual network is not using network gateways'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + }); +}); diff --git a/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.js b/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.js new file mode 100644 index 000000000..f37c097d2 --- /dev/null +++ b/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Public IP Address DDos Protection', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that DDoS IP Protection is enabled for Microsoft Azure Public IP Addresses', + more_info: 'Enabling DDoS IP Protection on public IP addresses mitigates potential attacks, differentiating between malicious and legitimate traffic, by interacting with the client, and blocking malicious traffic.', + recommended_action: 'Enable IP specific DDoS protection for all public IP addresses.', + link: 'https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-ip-protection-portal', + apis: ['publicIpAddresses:list'], + realtime_triggers: ['microsoftnetwork:publicipaddresses:write','microsoftnetwork:publicipaddresses:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.publicIpAddresses, (location, rcb) => { + var publicIpAddresses = helpers.addSource(cache, source, + ['publicIpAddresses', 'list', location]); + + if (!publicIpAddresses) return rcb(); + + if (publicIpAddresses.err || !publicIpAddresses.data) { + helpers.addResult(results, 3, 'Unable to query for Public IP Addresses: ' + helpers.addError(publicIpAddresses), location); + return rcb(); + } + + if (!publicIpAddresses.data.length) { + helpers.addResult(results, 0, 'No existing Public IP Addresses found', location); + return rcb(); + } + + publicIpAddresses.data.forEach(ipAddress => { + if (ipAddress.ddosSettings && ipAddress.ddosSettings.protectionMode && ipAddress.ddosSettings.protectionMode.toLowerCase()== 'enabled') { + helpers.addResult(results, 0, + 'Public IP Address has IP specific DDoS protection enabled', location, ipAddress.id); + } else { + helpers.addResult(results, 2, + 'Public IP Address does not have IP specific DDoS protection enabled', location, ipAddress.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.spec.js b/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.spec.js new file mode 100644 index 000000000..76ac728c5 --- /dev/null +++ b/plugins/azure/virtualnetworks/publicIpDdosProtectionEnabled.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var publicIpDdosProtectionEnabled = require('./publicIpDdosProtectionEnabled'); + +const publicIpAddresses = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/publicIpAddresses/test-vnet', + "type": 'Microsoft.Network/publicIpAddresses', + "location": 'eastus', + "ddosSettings": { + "protectionMode": "Enabled" + } + + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/publicIpAddresses/test-vnet', + "type": 'Microsoft.Network/publicIpAddresses', + "location": 'eastus', + "provisioningState": 'Succeeded', + "ddosSettings": { + "protectionMode": "VirtualNetworkInherited" + } + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/publicIpAddresses/test-vnet', + "type": 'Microsoft.Network/publicIpAddresses', + "location": 'eastus', + "provisioningState": 'Succeeded', + } +]; + +const createCache = (publicIpAddresses) => { + return { + publicIpAddresses: { + list: { + 'eastus': { + data: publicIpAddresses + } + } + } + }; +}; + +const createErrorCache = () => { + return { + publicIpAddresses: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('publicIpDdosProtectionEnabled', function () { + describe('run', function () { + it('should give passing result if no Public Ip Addresses found', function (done) { + const cache = createCache([]); + publicIpDdosProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Public IP Addresses found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Public Ip Address does not have DDoS ip protection enabled', function (done) { + const cache = createCache([publicIpAddresses[1]]); + publicIpDdosProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Public IP Address does not have IP specific DDoS protection enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Public Ip Address does not have DDoS ip protection enabled in case of default value', function (done) { + const cache = createCache([publicIpAddresses[2]]); + publicIpDdosProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Public IP Address does not have IP specific DDoS protection enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Public Ip Addresses found', function (done) { + const cache = createErrorCache(); + publicIpDdosProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Public IP Addresses:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Public Ip Address has DDoS ip protection enabled', function (done) { + const cache = createCache([publicIpAddresses[0]]); + publicIpDdosProtectionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Public IP Address has IP specific DDoS protection enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/routeTableHasTags.js b/plugins/azure/virtualnetworks/routeTableHasTags.js new file mode 100644 index 000000000..d6687dbf7 --- /dev/null +++ b/plugins/azure/virtualnetworks/routeTableHasTags.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Route Table Has Tags', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that Microsoft Azure Network route tables have tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify route tables and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['routeTables:listAll'], + realtime_triggers: ['microsoftnetwork:routetables:write','microsoftnetwork:routetables:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.routeTables, (location, rcb) => { + var routeTables = helpers.addSource(cache, source, + ['routeTables', 'listAll', location]); + + if (!routeTables) return rcb(); + + if (routeTables.err || !routeTables.data) { + helpers.addResult(results, 3, 'Unable to query for route tables: ' + helpers.addError(routeTables), location); + return rcb(); + } + + if (!routeTables.data.length) { + helpers.addResult(results, 0, 'No existing Route table found', location); + return rcb(); + } + + for (let routeTable of routeTables.data) { + if (!routeTable.id) continue; + + if (routeTable.tags && Object.entries(routeTable.tags).length > 0){ + helpers.addResult(results, 0, 'Route table has tags associated', location, routeTable.id); + } else { + helpers.addResult(results, 2, 'Route table does not have tags associated', location, routeTable.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/routeTableHasTags.spec.js b/plugins/azure/virtualnetworks/routeTableHasTags.spec.js new file mode 100644 index 000000000..6cd244300 --- /dev/null +++ b/plugins/azure/virtualnetworks/routeTableHasTags.spec.js @@ -0,0 +1,89 @@ +var expect = require('chai').expect; +var routeTableHasTags = require('./routeTableHasTags'); + +const RouteTables = [ + { + "name": "testrt", + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/routeTables/testrt", + "type": "Microsoft.Network/routeTables", + "location": "westus", + "tags": { "key": "value" }, + }, + { + "name": "testrt2", + "id": "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.Network/routeTables/testrt", + "type": "Microsoft.Network/routeTables", + "location": "westus", + "tags": {}, + } +]; + +const createCache = (rt) => { + return { + routeTables: { + listAll: { + 'eastus': { + data: rt + } + } + } + }; +}; + +const createErrorCache = () => { + return { + routeTables: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('routeTableHasTags', function() { + describe('run', function() { + it('should give passing result if no route table found', function(done) { + const cache = createCache([]); + routeTableHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Route table found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if route table does not have tags associated', function(done) { + const cache = createCache([RouteTables[1]]); + routeTableHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Route table does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Virtual Networks', function(done) { + const cache = createErrorCache(); + routeTableHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for route tables:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if route table has tags associated', function(done) { + const cache = createCache([RouteTables[0]]); + routeTableHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Route table has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.js b/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.js new file mode 100644 index 000000000..e037a2d91 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.js @@ -0,0 +1,52 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'VNET Flow Logs Enabled', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Virtual Network has flow logs enabled.', + more_info: 'Enabling flow logs for Microsoft Azure Virtual Networks is essential for comprehensive network visibility, security enhancement, and optimizing resources by providing detailed insights into traffic patterns and potential threats.', + recommended_action: 'Modify virtual networks and enable flow logs.', + link: 'https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-overview', + apis: ['virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, (location, rcb) => { + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + for (let virtualNetwork of virtualNetworks.data) { + if (!virtualNetwork.id) continue; + + if (virtualNetwork.flowLogs && virtualNetwork.flowLogs.length){ + helpers.addResult(results, 0, 'Virtual Network has flow logs enabled', location, virtualNetwork.id); + } else { + helpers.addResult(results, 2, 'Virtual Network does not have flow logs enabled', location, virtualNetwork.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.spec.js b/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.spec.js new file mode 100644 index 000000000..8778be001 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkFlowLogs.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var virtualNetworkFlowLogs = require('./virtualNetworkFlowLogs'); + +const virtualNetworks = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true, + "flowLogs":[ + { + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/networkWatchers/NetworkWatcher_eastus/flowLogs/test-flowlog', + + } + ], + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (virtualNetworks) => { + return { + virtualNetworks: { + listAll: { + 'eastus': { + data: virtualNetworks + } + } + } + }; +}; + +const createErrorCache = () => { + return { + virtualNetworks: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('virtualNetworkFlowLogs', function() { + describe('run', function() { + it('should give passing result if no virtual networks', function(done) { + const cache = createCache([]); + virtualNetworkFlowLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual Network does not have flow logs enabled', function(done) { + const cache = createCache([virtualNetworks[1]]); + virtualNetworkFlowLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Network does not have flow logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Virtual Networks', function(done) { + const cache = createErrorCache(); + virtualNetworkFlowLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual Network has flow logs enabled', function(done) { + const cache = createCache([virtualNetworks[0]]); + virtualNetworkFlowLogs.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Network has flow logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/virtualNetworkHasTags.js b/plugins/azure/virtualnetworks/virtualNetworkHasTags.js new file mode 100644 index 000000000..742933f5d --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Virtual Network Has Tags', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that Microsoft Azure Virtual Network has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify virtual networks and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, (location, rcb) => { + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + for (let virtualNetwork of virtualNetworks.data) { + if (!virtualNetwork.id) continue; + + if (virtualNetwork.tags && Object.entries(virtualNetwork.tags).length > 0){ + helpers.addResult(results, 0, 'Virtual Network has tags associated', location, virtualNetwork.id); + } else { + helpers.addResult(results, 2, 'Virtual Network does not have tags associated', location, virtualNetwork.id); + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/virtualNetworkHasTags.spec.js b/plugins/azure/virtualnetworks/virtualNetworkHasTags.spec.js new file mode 100644 index 000000000..980a3e897 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkHasTags.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var virtualNetworkHasTags = require('./virtualNetworkHasTags'); + +const virtualNetworks = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (virtualNetworks) => { + return { + virtualNetworks: { + listAll: { + 'eastus': { + data: virtualNetworks + } + } + } + }; +}; + +const createErrorCache = () => { + return { + virtualNetworks: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('virtualNetworkHasTags', function() { + describe('run', function() { + it('should give passing result if no virtual networks', function(done) { + const cache = createCache([]); + virtualNetworkHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if virtual Network does not have tags associated', function(done) { + const cache = createCache([virtualNetworks[1]]); + virtualNetworkHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Network does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for Virtual Networks', function(done) { + const cache = createErrorCache(); + virtualNetworkHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if virtual Network has tags associated', function(done) { + const cache = createCache([virtualNetworks[0]]); + virtualNetworkHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Network has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/virtualNetworkPeering.js b/plugins/azure/virtualnetworks/virtualNetworkPeering.js new file mode 100644 index 000000000..4932808b4 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkPeering.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Virtual Network Peering', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that Virtual Network has peering connection only with a virtual network in whitelisted subscription.', + more_info: 'Virtual networks should only have peering connections with whitelisted virtual networks to meet your organization\'s security compliance requirements.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/virtual-network-peering-overview', + recommended_action: 'Delete Peering Connection with the subscription which are not whitelisted', + apis: ['virtualNetworks:listAll', 'virtualNetworkPeerings:list'], + settings: { + whitelisted_peering_subscriptions: { + name: 'Whitelisted Peering Subscriptions', + description: 'Subscription Ids for remote virtual networks which should be allowed for peering', + regex: '/^([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12},?)+$/', + default: '' + }, + enable_virtual_network_peering: { + name: 'Virtual Network Peering', + description: 'This is an opt-in plugin. This value should be set to true to enable this plugin', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + const config = { + peeringEnabled: settings.enable_virtual_network_peering || this.settings.enable_virtual_network_peering.default, + whiteListedSubscriptions: settings.whitelisted_peering_subscriptions || this.settings.whitelisted_peering_subscriptions.default + }; + + if (config.peeringEnabled === 'false' && !config.whiteListedSubscriptions.length) { + return callback(null, results, source); + } + + async.each(locations.virtualNetworks, function(location, rcb){ + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + async.each(virtualNetworks.data, function(virtualNetwork, scb){ + var virtualNetworkPeerings = helpers.addSource(cache, source, + ['virtualNetworkPeerings', 'list', location, virtualNetwork.id]); + + if (!virtualNetworkPeerings || virtualNetworkPeerings.err || !virtualNetworkPeerings.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Network Peerings: ' + helpers.addError(virtualNetworkPeerings), location); + return scb(); + } + + if (!virtualNetworkPeerings.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Network Peerings found', location); + return scb(); + } + + let unknownSubscriptions = []; + virtualNetworkPeerings.data.forEach(peering => { + let subscriptionId = ''; + if (peering.remoteVirtualNetwork && peering.remoteVirtualNetwork.id) { + subscriptionId = peering.remoteVirtualNetwork.id.split('/')[2]; + if (!(config.whiteListedSubscriptions && config.whiteListedSubscriptions.includes(subscriptionId))) { + unknownSubscriptions.push(subscriptionId); + } + } + }); + + if (unknownSubscriptions.length) { + helpers.addResult(results, 2, `Virtual network has peering with these unknown subscriptions: ${unknownSubscriptions.join(', ')}`, location, virtualNetwork.id); + } else { + helpers.addResult(results, 0, 'Virtual network is connected with a virtual network in whitelisted subscription', location, virtualNetwork.id); + } + + scb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/virtualNetworkPeering.spec.js b/plugins/azure/virtualnetworks/virtualNetworkPeering.spec.js new file mode 100644 index 000000000..f2167475d --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworkPeering.spec.js @@ -0,0 +1,126 @@ +var expect = require('chai').expect; +var virtualNetworkPeering = require('./virtualNetworkPeering'); + +const virtualNetworks = [ + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + } +]; + +const virtualNetworkPeerings = [ + { + 'name': 'test-peer', + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/virtualNetworkPeerings/test-peer', + 'type': 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings', + 'remoteVirtualNetwork': { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet-2' + } + } +]; + +const createCache = (virtualNetworks, virtualNetworkPeerings) => { + let network = {}; + let peer = {}; + + if (virtualNetworks) { + network['data'] = virtualNetworks; + if (virtualNetworks.length && virtualNetworkPeerings) { + peer[virtualNetworks[0].id] = { + 'data': virtualNetworkPeerings + }; + } + } + + return { + virtualNetworks: { + listAll: { + 'eastus': network + } + }, + virtualNetworkPeerings: { + list: { + 'eastus': peer + } + }, + }; +}; + +describe('virtualNetworkPeering', function() { + describe('run', function() { + it('should not run plugin if subscription id is not provided and opt in is set to false', function(done) { + const cache = createCache([]); + virtualNetworkPeering.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + it('should give passing result if No existing virtual networks found', function(done) { + const cache = createCache([]); + virtualNetworkPeering.run(cache, { enable_virtual_network_peering: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual networks', function(done) { + const cache = createCache(); + virtualNetworkPeering.run(cache, { enable_virtual_network_peering: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if No existing Virtual Network Peerings found', function(done) { + const cache = createCache([virtualNetworks[0]], []); + virtualNetworkPeering.run(cache, { enable_virtual_network_peering: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Network Peerings found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Virtual Network Peerings', function(done) { + const cache = createCache([virtualNetworks[0]]); + virtualNetworkPeering.run(cache, { enable_virtual_network_peering: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Network Peerings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if subscription is whitelisted', function(done) { + const cache = createCache([virtualNetworks[0]], [virtualNetworkPeerings[0]]); + virtualNetworkPeering.run(cache, { whitelisted_peering_subscriptions: '123' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual network is connected with a virtual network in whitelisted subscription'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if subscription is not whitelisted', function(done) { + const cache = createCache([virtualNetworks[0]], [virtualNetworkPeerings[0]]); + virtualNetworkPeering.run(cache, { enable_virtual_network_peering: 'true' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual network has peering with these unknown subscriptions: 123'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.js b/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.js new file mode 100644 index 000000000..8ce8dc756 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Virtual Networks Logging Enabled', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure that Microsoft Virtual Networks have diagnostic logs enabled.', + more_info: 'Diagnostic logs provide valuable insights into the operation and health of Virtual Networks. By enabling diagnostic logs, you can monitor network traffic, troubleshoot connectivity issues, and gain visibility into network performance.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/monitor-virtual-network', + recommended_action: 'Modify the virtual network settings and enable diagnostic logs.', + apis: ['virtualNetworks:listAll', 'diagnosticSettings:listByVirtualNetworks'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftinsights:diagnosticsettings:write','microsoftinsights:diagnosticsettings:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, function(location, rcb){ + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + for (let vn of virtualNetworks.data) { + if (!vn.id) continue; + + var diagnosticSettings = helpers.addSource(cache, source, + ['diagnosticSettings', 'listByVirtualNetworks', location, vn.id]); + + if (!diagnosticSettings || diagnosticSettings.err || !diagnosticSettings.data) { + helpers.addResult(results, 3, `Unable to query for Virtual Network diagnostic settings: ${helpers.addError(diagnosticSettings)}`, + location, vn.id); + continue; + } + + var found = diagnosticSettings.data.find(ds => ds.logs && ds.logs.length); + + if (found) { + helpers.addResult(results, 0, 'Virtual Network has diagnostic logs enabled', location, vn.id); + } else { + helpers.addResult(results, 2, 'Virtual Network does not have diagnostic logs enabled', location, vn.id); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.spec.js b/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.spec.js new file mode 100644 index 000000000..79d0e66d6 --- /dev/null +++ b/plugins/azure/virtualnetworks/virtualNetworksLoggingEnabled.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +var virtualNetworksLoggingEnabled = require('./virtualNetworksLoggingEnabled'); + +const virtualNetworks = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + "type": 'Microsoft.Network/virtualNetworks', + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true + } +]; + +const diagnosticSettings = [ + { + id: '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + eventHubName: null, + metrics: [], + logs: [ + { + category: 'NetworkSecurityGroupEvent', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + }, + { + category: 'NetworkSecurityGroupRuleCounter', + categoryGroup: null, + enabled: true, + retentionPolicy: [Object] + } + ], + logAnalyticsDestinationType: null + }, + { + id: '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/providers/microsoft.insights/diagnosticSettings/gio-test-setting', + type: 'Microsoft.Insights/diagnosticSettings', + name: 'gio-test-setting', + location: 'eastus', + kind: null, + tags: null, + identity: null, + metrics: [], + logs: [], + logAnalyticsDestinationType: null + }, +]; + +const createCache = (vn, ds) => { + const id = (vn && vn.length) ? vn[0].id : null; + return { + virtualNetworks: { + listAll: { + 'eastus': { + data: vn + } + } + }, + diagnosticSettings: { + listByVirtualNetworks: { + 'eastus': { + [id]: { + data: ds + } + } + } + + }, + }; +}; + +describe('virtualNetworksLoggingEnabled', function() { + describe('run', function() { + it('should give passing result if no Virtual Network found', function(done) { + const cache = createCache([], null); + virtualNetworksLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual networks', function(done) { + const cache = createCache(null, null); + virtualNetworksLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for diagnostic settings', function(done) { + const cache = createCache([virtualNetworks[0]], null); + virtualNetworksLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Network diagnostic settings'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if diagnostic logs enabled', function(done) { + const cache = createCache([virtualNetworks[0]], [diagnosticSettings[0]]); + virtualNetworksLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Virtual Network has diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if diagnostic logs not enabled', function(done) { + const cache = createCache([virtualNetworks[0]], [diagnosticSettings[1]]); + virtualNetworksLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Network does not have diagnostic logs enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/waf/wafPolicyHasTags.js b/plugins/azure/waf/wafPolicyHasTags.js new file mode 100644 index 000000000..9154e269f --- /dev/null +++ b/plugins/azure/waf/wafPolicyHasTags.js @@ -0,0 +1,53 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'WAF Policy Has Tags', + category: 'Application Gateway', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensure that each Microsoft Azure WAF Policy has tags associated.', + more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', + recommended_action: 'Modify WAF policies and add tags.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources', + apis: ['wafPolicies:listAll'], + realtime_triggers: ['microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write','microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete','microsoftresources:tags:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.wafPolicies, (location, rcb) => { + var virtualNetworks = helpers.addSource(cache, source, + ['wafPolicies', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for WAF policies: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing WAF policies found', location); + return rcb(); + } + + for (let virtualNetwork of virtualNetworks.data) { + if (!virtualNetwork.id) continue; + + if (virtualNetwork.tags && Object.entries(virtualNetwork.tags).length > 0){ + helpers.addResult(results, 0, 'WAF policy has tags associated', location, virtualNetwork.id); + } else { + helpers.addResult(results, 2, 'WAF policy does not have tags associated', location, virtualNetwork.id); + } + + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/waf/wafPolicyHasTags.spec.js b/plugins/azure/waf/wafPolicyHasTags.spec.js new file mode 100644 index 000000000..35828a449 --- /dev/null +++ b/plugins/azure/waf/wafPolicyHasTags.spec.js @@ -0,0 +1,95 @@ +var expect = require('chai').expect; +var wafPolicyHasTags = require('./wafPolicyHasTags'); + +const wafPolicy = [ + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": { "key": "value" }, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": true + }, + { + "name": 'test-vnet', + "id": '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies', + "type": 'Microsoft.Network/waf', + "tags": {}, + "location": 'eastus', + "provisioningState": 'Succeeded', + "virtualNetworkPeerings": [], + "enableDdosProtection": false + } +]; + +const createCache = (waf) => { + return { + wafPolicies: { + listAll: { + 'eastus': { + data: waf + } + } + } + }; +}; + +const createErrorCache = () => { + return { + wafPolicies: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('wafPolicyHasTags', function() { + describe('run', function() { + it('should give passing result if no WAF policy found', function(done) { + const cache = createCache([]); + wafPolicyHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing WAF policies found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if WAF policy does not have tags associated', function(done) { + const cache = createCache([wafPolicy[1]]); + wafPolicyHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('WAF policy does not have tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if Unable to query for WAF policy', function(done) { + const cache = createErrorCache(); + wafPolicyHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for WAF policies:'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if WAF policy has tags associated', function(done) { + const cache = createCache([wafPolicy[0]]); + wafPolicyHasTags.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('WAF policy has tags associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/github/orgs/orgDefaultPermission.js b/plugins/github/orgs/orgDefaultPermission.js index 4096f8e4c..727fde806 100644 --- a/plugins/github/orgs/orgDefaultPermission.js +++ b/plugins/github/orgs/orgDefaultPermission.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Org Default Permission', types: ['org'], + severity: 'Medium', category: 'Orgs', description: 'Checks the default permission given to new users added to an organization.', more_info: 'The default permission given to new organization users should be set to none. Read permissions risk exposing private repositories, while write or admin permissions risk sensitive access to repositories for new users.', diff --git a/plugins/github/orgs/orgExcessiveOwners.js b/plugins/github/orgs/orgExcessiveOwners.js index 0150ce5a2..a07ba10a1 100644 --- a/plugins/github/orgs/orgExcessiveOwners.js +++ b/plugins/github/orgs/orgExcessiveOwners.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Org Excessive Owners', types: ['org'], + severity: 'Medium', category: 'Orgs', description: 'Checks whether the organization has an excessive number of owners relative to its size.', more_info: 'Having too many owners of a Git organization increases the risk of a serious compromise from lost credentials.', diff --git a/plugins/github/orgs/orgMfaRequired.js b/plugins/github/orgs/orgMfaRequired.js index 410b18508..982618024 100644 --- a/plugins/github/orgs/orgMfaRequired.js +++ b/plugins/github/orgs/orgMfaRequired.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Org MFA Required', types: ['org'], + severity: 'Medium', category: 'Orgs', description: 'Checks whether multi-factor authentication is required at the org-level.', more_info: 'MFA should be enabled and enforced for all users of an organization.', diff --git a/plugins/github/orgs/orgPlanLimit.js b/plugins/github/orgs/orgPlanLimit.js index 0aa757c7e..e78518107 100644 --- a/plugins/github/orgs/orgPlanLimit.js +++ b/plugins/github/orgs/orgPlanLimit.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Org Plan Limit', types: ['org'], + severity: 'Medium', category: 'Orgs', description: 'Checks that the number of seats is not close to the limit of available licensed seats.', more_info: 'Running out of licenses will prevent developers from adding new users.', diff --git a/plugins/github/repos/repoDeployKeysRotated.js b/plugins/github/repos/repoDeployKeysRotated.js index 677540744..3125993e7 100644 --- a/plugins/github/repos/repoDeployKeysRotated.js +++ b/plugins/github/repos/repoDeployKeysRotated.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Repo Deploy Keys Rotated', types: ['org', 'user'], + severity: 'Low', category: 'Repos', description: 'Ensures deploy keys associated with a repository are rotated regularly.', more_info: 'Deploy keys can have significant access to a repository and should be rotated on a regular basis.', diff --git a/plugins/github/repos/repoOutsideCollaborators.js b/plugins/github/repos/repoOutsideCollaborators.js index dc0d0d62b..5888aae2f 100644 --- a/plugins/github/repos/repoOutsideCollaborators.js +++ b/plugins/github/repos/repoOutsideCollaborators.js @@ -4,6 +4,7 @@ var helpers = require('../../../helpers/github'); module.exports = { title: 'Repo Outside Collaborators', types: ['org'], + severity: 'Medium', category: 'Repos', description: 'Ensures organization repositories do not have outside collaborators with admin or push permissions.', more_info: 'Allowing outside collaborators admin or push access to organization repositories places the organization at risk from non-member contributions that can be pushed without review.', diff --git a/plugins/github/users/gpgKeysRotated.js b/plugins/github/users/gpgKeysRotated.js index a1f7dfcb7..719c067e6 100644 --- a/plugins/github/users/gpgKeysRotated.js +++ b/plugins/github/users/gpgKeysRotated.js @@ -5,6 +5,7 @@ module.exports = { title: 'GPG Keys Rotated', category: 'Users', types: ['user'], + severity: 'Low', description: 'Ensures GitHub GPG keys are rotated frequently.', more_info: 'GitHub GPG keys are used to cryptographically sign code commits and should be rotated every 180 days.', link: 'https://help.github.com/articles/generating-a-new-gpg-key/', diff --git a/plugins/github/users/publicKeysRotated.js b/plugins/github/users/publicKeysRotated.js index fdfe8fd72..9b00cba10 100644 --- a/plugins/github/users/publicKeysRotated.js +++ b/plugins/github/users/publicKeysRotated.js @@ -5,6 +5,7 @@ module.exports = { title: 'Public Keys Rotated', category: 'Users', types: ['user'], + severity: 'Low', description: 'Ensures GitHub user keys are rotated frequently.', more_info: 'GitHub keys provide full access to repositories within an account and should be rotated every 180 days.', link: 'https://help.github.com/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent/', diff --git a/plugins/github/users/userMfaEnabled.js b/plugins/github/users/userMfaEnabled.js index 5291f7cbb..8b9581d94 100644 --- a/plugins/github/users/userMfaEnabled.js +++ b/plugins/github/users/userMfaEnabled.js @@ -5,6 +5,7 @@ module.exports = { title: 'User MFA Enabled', category: 'Users', types: ['user'], + severity: 'Medium', description: 'Ensures multi-factor authentication is enabled for the default user account', more_info: 'GitHub MFA provides additional account security by requiring an additional login device or code. All accounts should have MFA enabled.', link: 'https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/', diff --git a/plugins/github/users/userPrivateEmails.js b/plugins/github/users/userPrivateEmails.js index c30232102..ac9e3f957 100644 --- a/plugins/github/users/userPrivateEmails.js +++ b/plugins/github/users/userPrivateEmails.js @@ -5,7 +5,8 @@ module.exports = { title: 'User Private Emails', category: 'Users', types: ['user'], - description: 'Checks that the primary email addresse associated with a GitHub account is set to private visibility.', + severity: 'Medium', + description: 'Checks that the primary email address associated with a GitHub account is set to private visibility.', more_info: 'Email addresses added to GitHub should be set to private visibility to increase privacy and prevent account reconnaissance.', link: 'https://developer.github.com/v3/users/emails/#toggle-primary-email-visibility', recommended_action: 'Change the visibility of GitHub email addresses to private.', diff --git a/plugins/google/api/apiKeyAPIRestriction.js b/plugins/google/api/apiKeyAPIRestriction.js new file mode 100644 index 000000000..1246cdd36 --- /dev/null +++ b/plugins/google/api/apiKeyAPIRestriction.js @@ -0,0 +1,60 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'API Key API Restriction', + category: 'API', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure there are no unrestricted API keys available within your GCP project.', + more_info: 'To reduce the risk of attacks, Google Cloud API keys should be restricted to only call the APIs needed by your application.', + link: 'https://cloud.google.com/docs/authentication/api-keys#adding-api-restrictions', + recommended_action: 'Ensure that API restrictions are set for all Google Cloud API Keys.', + apis: ['apiKeys:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let apiKeys = helpers.addSource(cache, source, + ['apiKeys', 'list', 'global']); + + if (!apiKeys) return callback(null, results, source); + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, 'Unable to query API Keys for project', 'global', null, null, apiKeys.err); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No API Keys found', 'global'); + return callback(null, results, source); + } + + apiKeys.data.forEach(key => { + if (key.restrictions && key.restrictions.apiTargets + && key.restrictions.apiTargets.length + && !(key.restrictions.apiTargets.find(target => target.service === 'cloudapis.googleapis.com'))) { + helpers.addResult(results, 0, + 'API Key usage is restricted to required APIs', 'global', key.name); + } else { + helpers.addResult(results, 2, + 'API Key usage is not restricted to required APIs', 'global', key.name); + } + }); + + return callback(null, results, source); + } +}; + + + + diff --git a/plugins/google/api/apiKeyAPIRestriction.spec.js b/plugins/google/api/apiKeyAPIRestriction.spec.js new file mode 100644 index 000000000..b81057cf4 --- /dev/null +++ b/plugins/google/api/apiKeyAPIRestriction.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var plugin = require('./apiKeyAPIRestriction'); + + +const apiKeys = [ + { + "name": "projects/my-project/locations/global/keys/my-key-1", + "displayName": "API Key 1", + "restrictions": { + "apiTargets": [ + { 'service': 'apigateway.googleapis.com' } + ] + }, + }, + { + "name": "projects/my-project/locations/global/keys/my-key-2", + "displayName": "API key 2", + } +]; + +const createCache = (list, err) => { + return { + apiKeys: { + list: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + } + } +}; + +describe('apiKeyAPIRestriction', function () { + describe('run', function () { + + it('should give unknown result if unable to query api keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Keys for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no api keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No API Keys found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud api key is restricted', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Key usage is restricted'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [apiKeys[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud api key is not restricted', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Key usage is not restricted'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [apiKeys[1]], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/api/apiKeyActiveServices.js b/plugins/google/api/apiKeyActiveServices.js new file mode 100644 index 000000000..d4e5d6810 --- /dev/null +++ b/plugins/google/api/apiKeyActiveServices.js @@ -0,0 +1,70 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'API Key Active Services Only', + category: 'API', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure API Keys only exist for active services.', + more_info: 'API Keys should only be used for services in cases where other authentication methods are unavailable. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow to avoid risks associated with API Keys.', + link: 'https://cloud.google.com/docs/authentication/api-keys', + recommended_action: 'Ensure that API Keys only exist for active services.', + apis: ['projects:getWithNumber', 'apiKeys:list', 'services:listEnabled'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'getWithNumber', 'global']); + + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + let apiKeys = helpers.addSource(cache, source, + ['apiKeys', 'list', 'global']); + + if (!apiKeys) return callback(null, results, source); + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, 'Unable to query API Keys for project', 'global', null, null, apiKeys.err); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No API Keys found', 'global'); + return callback(null, results, source); + } + + let services = helpers.addSource(cache, source, + ['services', 'listEnabled', 'global']); + + if (!services || services.err || !services.data) { + helpers.addResult(results, 3, + 'Unable to query services for project: ' + helpers.addError(services), 'global', null, null, (services) ? services.err : null); + return callback(null, results, source); + } + + apiKeys.data.forEach(key => { + if (services.data.length && key.restrictions && key.restrictions.apiTargets + && key.restrictions.apiTargets.length + && key.restrictions.apiTargets.every( + target => target.service && services.data.find(service => service.name.includes(target.service)))) { + helpers.addResult(results, 0, + 'API Key usage is restricted to active services', 'global', key.name); + } else { + helpers.addResult(results, 2, + 'API Key usage is not restricted to active services', 'global', key.name); + } + }); + + return callback(null, results, source); + } +}; + + + + diff --git a/plugins/google/api/apiKeyActiveServices.spec.js b/plugins/google/api/apiKeyActiveServices.spec.js new file mode 100644 index 000000000..675f9ee8c --- /dev/null +++ b/plugins/google/api/apiKeyActiveServices.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +var plugin = require('./apiKeyActiveServices'); + +const services = [ + { + name: 'projects/12345/services/storage.googleapis.com', + state: 'ENABLED' + } +] +const apiKeys = [ + { + "name": "projects/my-project/locations/global/keys/my-key-1", + "displayName": "API Key 1", + "restrictions": { + "apiTargets": [ + { "service": 'storage.googleapis.com'} + ] + }, + }, + { + "name": "projects/my-project/locations/global/keys/my-key-2", + "displayName": "API key 2", + } +]; + +const createCache = (list, err, servicesList, servicesErr) => { + return { + apiKeys: { + list: { + 'global': { + err: err, + data: list + } + }, + }, + services: { + listEnabled: { + 'global': { + err: servicesErr, + data: servicesList + } + }, + }, + projects: { + getWithNumber: { + 'global': { + data: [ { + name: 'testproj', + projectNumber: 123456 + } ] + } + } + } + } +}; + +describe('apiKeyActiveServices', function () { + describe('run', function () { + + it('should give unknown result if unable to query api keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Keys for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'], + services, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no api keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No API Keys found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + services, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query services', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query services for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + apiKeys, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give passing result if google cloud api key usage is restricted to active services', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Key usage is restricted'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [apiKeys[0]], + null, + services, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud api key is not restricted to active services only', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Key usage is not restricted'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [apiKeys[1]], + null, + services, + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/api/apiKeyApplicationRestriction.js b/plugins/google/api/apiKeyApplicationRestriction.js new file mode 100644 index 000000000..61412a990 --- /dev/null +++ b/plugins/google/api/apiKeyApplicationRestriction.js @@ -0,0 +1,75 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'API Key Application Restriction', + category: 'API', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure there are no unrestricted API keys available within your GCP project.', + more_info: 'To reduce the risk of attacks, Google Cloud API keys should be restricted only to trusted hosts, HTTP referrers, and Android/iOS mobile applications.', + link: 'https://cloud.google.com/docs/authentication/api-keys#adding_application_restrictions', + recommended_action: 'Ensure that Application restrictions are set for all Google Cloud API Keys.', + apis: ['apiKeys:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let apiKeys = helpers.addSource(cache, source, + ['apiKeys', 'list', 'global']); + + if (!apiKeys) return callback(null, results, source); + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, 'Unable to query API Keys for project', 'global', null, null, apiKeys.err); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No API Keys found', 'global'); + return callback(null, results, source); + } + + apiKeys.data.forEach(key => { + let isRestricted = false; + + if (key.restrictions) { + if (key.restrictions.browserKeyRestrictions && key.restrictions.browserKeyRestrictions.allowedReferrers + && key.restrictions.browserKeyRestrictions.allowedReferrers.length) { + isRestricted = key.restrictions.browserKeyRestrictions.allowedReferrers.every(referrer => + referrer.match(/^(\*\.)?([\w-]+\.)+[\w-]+$/) + ); + } + if (key.restrictions.serverKeyRestrictions && key.restrictions.serverKeyRestrictions.allowedIps + && key.restrictions.serverKeyRestrictions.allowedIps.length) { + let allowedIps = key.restrictions.serverKeyRestrictions.allowedIps; + if (!(allowedIps.includes('0.0.0.0') || allowedIps.includes('0.0.0.0/0') || allowedIps.includes('::0'))) { + isRestricted = true; + } + } + } + if (isRestricted) { + helpers.addResult(results, 0, + 'API Key usage is restricted to trusted hosts, HTTP referrers, or applications', 'global', key.name); + } else { + helpers.addResult(results, 2, + 'API Key usage is not restricted to trusted hosts, HTTP referrers, or applications', 'global', key.name); + } + }); + + return callback(null, results, source); + } +}; + + + + diff --git a/plugins/google/api/apiKeyApplicationRestriction.spec.js b/plugins/google/api/apiKeyApplicationRestriction.spec.js new file mode 100644 index 000000000..c0ba64e8a --- /dev/null +++ b/plugins/google/api/apiKeyApplicationRestriction.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var plugin = require('./apiKeyApplicationRestriction'); + + +const apiKeys = [ + { + "name": "projects/my-project/locations/global/keys/my-key-1", + "displayName": "API Key 1", + "restrictions": { + "browserKeyRestrictions": { + "allowedReferrers" : [ "www.google.com"] + } + }, + }, + { + "name": "projects/my-project/locations/global/keys/my-key-2", + "displayName": "API key 2", + } +]; + +const createCache = (list, err) => { + return { + apiKeys: { + list: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + } + } +}; + +describe('apiKeyApplicationRestriction', function () { + describe('run', function () { + + it('should give unknown result if unable to query api keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Keys for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no api keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No API Keys found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud api key is restricted', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Key usage is restricted'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [apiKeys[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud api key is not restricted', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Key usage is not restricted'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [apiKeys[1]], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/api/apiKeyRotation.js b/plugins/google/api/apiKeyRotation.js new file mode 100644 index 000000000..4655d4539 --- /dev/null +++ b/plugins/google/api/apiKeyRotation.js @@ -0,0 +1,79 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'API Key Rotation', + category: 'API', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that your Google Cloud API Keys are periodically regenerated.', + more_info: 'Make sure that your Google API Keys are regenerated regularly to avoid data leaks and unauthorized access through outdated API Keys.', + link: 'https://cloud.google.com/docs/authentication/api-keys', + recommended_action: 'Ensure that all your Google Cloud API keys are regenerated (rotated) after a specific period.', + apis: ['apiKeys:list'], + settings: { + api_keys_rotation_warn_interval: { + name: 'API Keys Rotation Warn Interval', + description: 'Return a warning result when api keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '45' + }, + api_keys_rotation_fail_interval: { + name: 'API Keys Rotation Fail Interval', + description: 'Return a failing result when api keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var apiKeyRotationFailInterval = parseInt(settings.api_keys_rotation_fail_interval || this.settings.api_keys_rotation_fail_interval.default); + var apiKeyRotationWarnInterval = parseInt(settings.api_keys_rotation_warn_interval || this.settings.api_keys_rotation_warn_interval.default); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let apiKeys = helpers.addSource(cache, source, + ['apiKeys', 'list', 'global']); + + if (!apiKeys) return callback(null, results, source); + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, 'Unable to query API Keys for project', 'global', null, null, apiKeys.err); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No API Keys found', 'global'); + return callback(null, results, source); + } + + apiKeys.data.forEach(key => { + var diffInDays = helpers.daysBetween(key.createTime, new Date()); + + if (diffInDays > apiKeyRotationFailInterval) { + helpers.addResult(results, 2, + `API Key was last rotated ${diffInDays} days ago which is greater than ${apiKeyRotationFailInterval}`, 'global', key.name); + } else if (diffInDays > apiKeyRotationWarnInterval) { + helpers.addResult(results, 1, + `API Key was last rotated ${diffInDays} days ago which is greater than ${apiKeyRotationWarnInterval}`, 'global', key.name); + } else { + helpers.addResult(results, 0, + `API Key was last rotated ${diffInDays} days ago`, 'global', key.name); + } + }); + + return callback(null, results, source); + } +}; + + + + diff --git a/plugins/google/api/apiKeyRotation.spec.js b/plugins/google/api/apiKeyRotation.spec.js new file mode 100644 index 000000000..02a4a6aca --- /dev/null +++ b/plugins/google/api/apiKeyRotation.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./apiKeyRotation'); + +const apiKeys = [ + { + "name": "projects/my-project/locations/global/keys/my-key-1", + "displayName": "API Key 1", + "createTime": new Date(), + }, + { + "name": "projects/my-project/locations/global/keys/my-key-2", + "displayName": "API key 2", + "createTime": '2021-04-07T17:23:05.126949Z', + + }, + { + "name": "projects/my-project/locations/global/keys/my-key-3", + "displayName": "API key 3", + "createTime": new Date().setMonth(new Date().getMonth() - 2) + } +]; + +const createCache = (list, err) => { + return { + apiKeys: { + list: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + } + } +}; + +describe('restrictedAPIKeys', function () { + describe('run', function () { + + it('should give unknown result if unable to query api keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Keys for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no api keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No API Keys found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud api key is not outdated', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [apiKeys[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warning result if google cloud api key rotation date is older than warn interval', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('which is greater than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [apiKeys[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud api key is outdated', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is greater than'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [apiKeys[1]], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/api/projectAPIKeys.js b/plugins/google/api/projectAPIKeys.js new file mode 100644 index 000000000..d833e13a8 --- /dev/null +++ b/plugins/google/api/projectAPIKeys.js @@ -0,0 +1,53 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Project API Keys', + category: 'API', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensure there are no API keys created within GCP project.', + more_info: 'API Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead.', + link: 'https://cloud.google.com/docs/authentication/api-keys', + recommended_action: 'Ensure that there are no API Keys within the project.', + apis: ['apiKeys:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + let apiKeys = helpers.addSource(cache, source, + ['apiKeys', 'list', 'global']); + + if (!apiKeys) return callback(null, results, source); + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, 'Unable to query API Keys for project', 'global', null, null, apiKeys.err); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, + 'API Keys do not exist in the project', 'global', project); + } else { + helpers.addResult(results, 2, + 'API Keys exist in the project', 'global', project); + } + + return callback(null, results, source); + } +}; + + + + diff --git a/plugins/google/api/projectAPIKeys.spec.js b/plugins/google/api/projectAPIKeys.spec.js new file mode 100644 index 000000000..197023f93 --- /dev/null +++ b/plugins/google/api/projectAPIKeys.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var plugin = require('./projectAPIKeys'); + + +const apiKeys = [ + { + "name": "projects/my-project/locations/global/keys/my-key-1", + "displayName": "API Key 1", + "restrictions": { + "browserKeyRestrictions": { + "allowedReferrers" : [ "www.google.com"] + } + }, + }, + { + "name": "projects/my-project/locations/global/keys/my-key-2", + "displayName": "API key 2", + } +]; + +const createCache = (list, err) => { + return { + apiKeys: { + list: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + } + } +}; + +describe('projectAPIKeys', function () { + describe('run', function () { + + it('should give unknown result if unable to query api keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query API Keys for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no api keys exist in the project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('API Keys do not exist in the project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give failing result if api keys exist in project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('API Keys exist in the project'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [apiKeys[1]], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/bigquery/datasetAllUsersPolicy.js b/plugins/google/bigquery/datasetAllUsersPolicy.js new file mode 100644 index 000000000..38d2e4318 --- /dev/null +++ b/plugins/google/bigquery/datasetAllUsersPolicy.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataset All Users Policy', + category: 'BigQuery', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that BigQuery datasets do not allow public read, write or delete access.', + more_info: 'Granting permissions to allUsers or allAuthenticatedUsers allows anyone to access the dataset. Such access might not be desirable if sensitive data is being stored in the dataset.', + link: 'https://cloud.google.com/bigquery/docs/dataset-access-controls', + recommended_action: 'Ensure that each dataset is configured so that no member is set to allUsers or allAuthenticatedUsers.', + apis: ['datasets:list', 'datasets:get', 'projects:get'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy','bigquery.DatasetService.InsertDataset','bigquery.DatasetService.DeleteDataset'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.datasets, function(region, rcb){ + let datasetsGet = helpers.addSource(cache, source, + ['datasets', 'get', region]); + + if (!datasetsGet) return rcb(); + + if (datasetsGet.err || !datasetsGet.data) { + helpers.addResult(results, 3, 'Unable to query BigQuery datasets: ' + helpers.addError(datasetsGet), region); + return rcb(); + } + + if (!datasetsGet.data.length) { + helpers.addResult(results, 0, 'No BigQuery datasets found', region); + return rcb(); + } + + async.each(datasetsGet.data, (dataset, dcb) => { + if (!dataset.id) return dcb(); + + let resource = helpers.createResourceName('datasets', dataset.id.split(':')[1] || dataset.id, project); + var permissionStr = []; + if (dataset.access) { + for (let rolePermission of dataset.access) { + if (!rolePermission['role']) continue; + for (let property in rolePermission) { + + if (rolePermission[property] && + (rolePermission[property].toLowerCase() == 'allusers' || rolePermission[property].toLowerCase() == 'allauthenticatedusers')) { + permissionStr.push(`${rolePermission['role']} access to ${rolePermission[property]}`); + } + } + } + + if (permissionStr.length) { + helpers.addResult(results, 2, + `BigQuery dataset provides ${permissionStr.join(',')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'BigQuery dataset does not provide public access', region, resource); + } + } else { + helpers.addResult(results, 0, + 'BigQuery dataset does not provide public access', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/bigquery/datasetAllUsersPolicy.spec.js b/plugins/google/bigquery/datasetAllUsersPolicy.spec.js new file mode 100644 index 000000000..7e307eb0a --- /dev/null +++ b/plugins/google/bigquery/datasetAllUsersPolicy.spec.js @@ -0,0 +1,127 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./datasetAllUsersPolicy'); + +const datasetGet = [ + { + "kind": "bigquery#dataset", + "id": "khulnasoft-dev-cloudexploit:khulnasoft_ds", + "selfLink": "https://www.googleapis.com/bigquery/v2/projects/khulnasoft-dev-cloudexploit/datasets/khulnasoft_ds", + "datasetReference": { "datasetId": "khulnasoft_ds", "projectId": "khulnasoft-dev-cloudploit" }, + "access": [ + { "role": "WRITER", "specialGroup": "projectWriters" }, + { "role": "OWNER", "specialGroup": "projectOwners" }, + { "role": "READER", "specialGroup": "projectReaders" } + ], + "creationTime": "1619622395743", + "lastModifiedTime": "1619699668544", + "location": "US", + "type": "DEFAULT" + }, + { + "kind": "bigquery#dataset", + "id": "khulnasoft-dev-cloudexploit:khulnasoft_ds", + "selfLink": "https://www.googleapis.com/bigquery/v2/projects/khulnasoft-dev-cloudexploit/datasets/khulnasoft_ds", + "datasetReference": { "datasetId": "khulnasoft_ds", "projectId": "khulnasoft-dev-cloudploit" }, + "access": [ + { "role": "WRITER", "iamMember": "allUsers" }, + { "role": "WRITER", "specialGroup": "projectWriters" }, + { "role": "OWNER", "specialGroup": "projectOwners" }, + { "role": "READER", "specialGroup": "allAuthenticatedUsers" }, + { "role": "READER", "specialGroup": "projectReaders" } + ], + "creationTime": "1619622395743", + "lastModifiedTime": "1619699668544", + "location": "US", + "type": "DEFAULT" + } +]; + +const createCache = (err, data) => { + return { + datasets: { + get: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('datasetAllUsersPolicy', function () { + describe('run', function () { + it('should give unknown result if unable to query BigQuery datasets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query BigQuery datasets'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no datasets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No BigQuery datasets found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if BigQuery dataset does not provide public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('BigQuery dataset does not provide public access'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasetGet[0]] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if BigQuery dataset provides public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('BigQuery dataset provides'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasetGet[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/bigquery/datasetLabelsAdded.js b/plugins/google/bigquery/datasetLabelsAdded.js new file mode 100644 index 000000000..d78abd691 --- /dev/null +++ b/plugins/google/bigquery/datasetLabelsAdded.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataset Labels Added', + category: 'BigQuery', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that all BigQuery datasets have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/bigquery/docs/adding-labels', + recommended_action: 'Ensure labels are added to all BigQuery datasets.', + apis: ['datasets:list'], + realtime_triggers:['bigquery.DatasetService.InsertDataset','bigquery.DatasetService.UpdateDataset','bigquery.DatasetService.DeleteDataset'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.datasets, function(region, rcb){ + let datasets = helpers.addSource(cache, source, + ['datasets', 'list', region]); + + if (!datasets) return rcb(); + + if (datasets.err || !datasets.data) { + helpers.addResult(results, 3, 'Unable to query BigQuery datasets', region, null, null, datasets.err); + return rcb(); + } + + if (!datasets.data.length) { + helpers.addResult(results, 0, 'No BigQuery datasets found', region); + return rcb(); + } + + datasets.data.forEach(dataset => { + if (!dataset.id) return; + + let resource = helpers.createResourceName('datasets', dataset.id.split(':')[1] || dataset.id, project); + + if (dataset.labels && + Object.keys(dataset.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(dataset.labels).length} labels found for BigQuery dataset`, region, resource); + } else { + helpers.addResult(results, 2, + 'BigQuery dataset does not have any labels', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/bigquery/datasetLabelsAdded.spec.js b/plugins/google/bigquery/datasetLabelsAdded.spec.js new file mode 100644 index 000000000..19b3d3143 --- /dev/null +++ b/plugins/google/bigquery/datasetLabelsAdded.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var plugin = require('./datasetLabelsAdded'); + +const datasets = [ + { + kind: 'bigquery#dataset', + id: 'testproj:ds1', + datasetReference: { datasetId: 'ds1', projectId: 'testproj' }, + labels: { dataset1: 'label' }, + location: 'us-central1' + }, + { + kind: 'bigquery#dataset', + id: 'testproj:ds2', + datasetReference: { datasetId: 'ds2', projectId: 'testproj' }, + labels: {}, + location: 'us-central1' + } +]; + +const createCache = (err, data) => { + return { + datasets: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('datasetLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a dataset error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query BigQuery datasets'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no datasets are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No BigQuery datasets found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the dataset', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for BigQuery dataset'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasets[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the dataset', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('BigQuery dataset does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasets[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/bigquery/datasetsCMKEncrypted.js b/plugins/google/bigquery/datasetsCMKEncrypted.js new file mode 100644 index 000000000..4f806056c --- /dev/null +++ b/plugins/google/bigquery/datasetsCMKEncrypted.js @@ -0,0 +1,109 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Datasets CMK Encrypted', + category: 'BigQuery', + domain: 'Databases', + severity: 'High', + description: 'Ensure that BigQuery datasets are encrypted using desired encryption protection level.', + more_info: 'By default Google encrypts all datasets using Google-managed encryption keys. To have more control over the encryption process of your BigQuery datasets you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/bigquery/docs/customer-managed-encryption', + recommended_action: 'Ensure that each BigQuery dataset has desired encryption level.', + apis: ['datasets:list', 'datasets:get', 'keyRings:list', 'cryptoKeys:list'], + settings: { + bigquery_datasets_encryption_protection_level: { + name: 'BigQuery Dataset Encryption Protection Level', + description: 'Desired protection level for BigQuery datasets. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers:['bigquery.DatasetService.InsertDataset','bigquery.DatasetService.DeleteDataset'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.bigquery_datasets_encryption_protection_level || this.settings.bigquery_datasets_encryption_protection_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.datasets, function(region, rcb) { + let datasetsGet = helpers.addSource(cache, source, + ['datasets', 'get', region]); + + if (!datasetsGet) return rcb(); + + if (datasetsGet.err || !datasetsGet.data) { + helpers.addResult(results, 3, 'Unable to query BigQuery datasets: ' + helpers.addError(datasetsGet), region); + return rcb(); + } + + if (!datasetsGet.data.length) { + helpers.addResult(results, 0, 'No BigQuery datasets found', region); + return rcb(); + } + + async.each(datasetsGet.data, (dataset, dcb) => { + if (!dataset.id) return dcb(); + + let resource = helpers.createResourceName('datasets', dataset.id.split(':')[1] || dataset.id, project); + + let currentEncryptionLevel; + + if (dataset.defaultEncryptionConfiguration && dataset.defaultEncryptionConfiguration.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[dataset.defaultEncryptionConfiguration.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `BigQuery dataset has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `BigQuery dataset has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + + }); + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/bigquery/datasetsCMKEncrypted.spec.js b/plugins/google/bigquery/datasetsCMKEncrypted.spec.js new file mode 100644 index 000000000..079fa4ff4 --- /dev/null +++ b/plugins/google/bigquery/datasetsCMKEncrypted.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var plugin = require('./datasetsCMKEncrypted'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const datasetGet = [ + { + "kind": "bigquery#dataset", + "id": "test:test", + "selfLink": "https://www.googleapis.com/bigquery/v2/projects/project1/datasets/test_ds", + "datasetReference": { "datasetId": "test", "projectId": "test" }, + "creationTime": "1619622395743", + "lastModifiedTime": "1619699668544", + "location": "US", + "type": "DEFAULT", + "defaultEncryptionConfiguration": { + "kmsKeyName": 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + } + }, + { + "kind": "bigquery#dataset", + "id": "test:test", + "selfLink": "https://www.googleapis.com/bigquery/v2/projects/project-1/datasets/test_ds", + "datasetReference": { "datasetId": "test", "projectId": "test" }, + "creationTime": "1619622395743", + "lastModifiedTime": "1619699668544", + "location": "US", + "type": "DEFAULT" + } +]; + +const createCache = (err, data, keysErr, keysList) => { + return { + datasets: { + get: { + 'global': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('datasetsCMKEncrypted', function () { + describe('run', function () { + it('should give unknown result if unable to query BigQuery datasets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query BigQuery datasets'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no datasets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No BigQuery datasets found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if BigQuery dataset has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasetGet[0]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if BigQuery dataset does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [datasetGet[1]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/bigquery/tablesCMKEncrypted.js b/plugins/google/bigquery/tablesCMKEncrypted.js new file mode 100644 index 000000000..d6e18cca2 --- /dev/null +++ b/plugins/google/bigquery/tablesCMKEncrypted.js @@ -0,0 +1,121 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Tables CMK Encrypted', + category: 'BigQuery', + domain: 'Databases', + severity: 'High', + description: 'Ensure that BigQuery dataset tables are encrypted using desired encryption protection level.', + more_info: 'By default Google encrypts all dataset tables using Google-managed encryption keys. To have more control over the encryption process of your BigQuery dataset tables you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/bigquery/docs/customer-managed-encryption', + recommended_action: 'Ensure that each BigQuery dataset table has desired encryption level.', + apis: ['datasets:list', 'bigqueryTables:list', 'bigqueryTables:get', 'keyRings:list', 'cryptoKeys:list'], + settings: { + bigquery_tables_encryption_protection_level: { + name: 'BigQuery Table Encryption Protection Level', + description: 'Desired protection level for BigQuery tables. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['bigquery.TableService.InsertTable','bigquery.TableService.DeleteTable'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.bigquery_tables_encryption_protection_level || this.settings.bigquery_tables_encryption_protection_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.bigqueryTables, function(region, rcb) { + let datasets = helpers.addSource(cache, source, + ['datasets', 'list', region]); + + if (!datasets) return rcb(); + + if (!datasets.data) { + helpers.addResult(results, 3, 'Unable to query BigQuery datasets', region, null, null, datasets.err); + return rcb(); + } + + if (!datasets.data.length) { + helpers.addResult(results, 0, 'No BigQuery datasets found', region); + return rcb(); + } + + let tables = helpers.addSource(cache, source, + ['bigqueryTables', 'get', region]); + + if (!tables) return rcb(); + + if (!tables.data) { + helpers.addResult(results, 3, 'Unable to query BigQuery tables', region, null, null, tables.err); + return rcb(); + } + + if (!tables.data.length) { + helpers.addResult(results, 0, 'No BigQuery tables found', region); + return rcb(); + } + + tables.data.forEach(table => { + if (!table.id) return; + + let currentEncryptionLevel; + let resource = table.selfLink.split('v2/')[1]; + + if (table.encryptionConfiguration && table.encryptionConfiguration.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[table.encryptionConfiguration.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `BigQuery table has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `BigQuery table has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + }); + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/bigquery/tablesCMKEncrypted.spec.js b/plugins/google/bigquery/tablesCMKEncrypted.spec.js new file mode 100644 index 000000000..d2d6e7fa4 --- /dev/null +++ b/plugins/google/bigquery/tablesCMKEncrypted.spec.js @@ -0,0 +1,179 @@ +var expect = require('chai').expect; +var plugin = require('./tablesCMKEncrypted'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const tablesGet = [ + { + "kind": 'bigquery#table', + "id": 'myproject:ds1.t2', + "selfLink": 'https://content-bigquery.googleapis.com/bigquery/v2/projects/myproject/datasets/ds1/tables/t2', + "tableReference": { projectId: 'myproject', datasetId: 'ds1', tableId: 't2' }, + "schema": {}, + "numBytes": '0', + "numLongTermBytes": '0', + "numRows": '0', + "creationTime": '1672707589430', + "lastModifiedTime": '1672707589540', + "type": 'TABLE', + "location": 'us-central1', + "encryptionConfiguration": { + "kmsKeyName": 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + }, + "numTotalLogicalBytes": '0', + "numActiveLogicalBytes": '0', + "numLongTermLogicalBytes": '0' + }, + { + "kind": 'bigquery#table', + "id": 'myproject:ds1.t1', + "selfLink": 'https://content-bigquery.googleapis.com/bigquery/v2/projects/myproject/datasets/ds1/tables/t2', + "tableReference": { projectId: 'myproject', datasetId: 'ds1', tableId: 't1' }, + "schema": {}, + "numBytes": '0', + "numLongTermBytes": '0', + "numRows": '0', + "creationTime": '1672707589430', + "lastModifiedTime": '1672707589540', + "type": 'TABLE', + "location": 'us-central1', + "numTotalLogicalBytes": '0', + "numActiveLogicalBytes": '0', + "numLongTermLogicalBytes": '0' + } +]; + +const createCache = (err, data, keysErr, keysList) => { + return { + datasets: { + list: { + 'global': { + err: null, + data: [ + { + datasetId: 'ds1' + } + ] + } + } + }, + + bigqueryTables: { + get: { + 'global': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('tablesCMKEncrypted', function () { + describe('run', function () { + it('should give unknown result if unable to query BigQuery tables', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query BigQuery tables'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no big query tables found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No BigQuery tables found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if BigQuery table has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [tablesGet[0]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if BigQuery table does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [tablesGet[1]], + null, + cryptoKeys + ); + + + plugin.run(cache, {}, callback); + }) + }) +}); diff --git a/plugins/google/bigtable/bigtableInstanceLabelsAdded.js b/plugins/google/bigtable/bigtableInstanceLabelsAdded.js new file mode 100644 index 000000000..bbe12f4f1 --- /dev/null +++ b/plugins/google/bigtable/bigtableInstanceLabelsAdded.js @@ -0,0 +1,64 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'BigTable Instance Labels Added', + category: 'BigTable', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that all BigTable instances have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/bigtable/docs/creating-managing-labels', + recommended_action: 'Ensure labels are added to all BigTable instances.', + apis: ['bigtable:list'], + realtime_triggers: ['bigtable.admin.BigtableInstanceAdmin.PartialUpdateInstance','bigtable.admin.BigtableInstanceAdmin.CreateInstance','bigtable.admin.BigtableInstanceAdmin.DeleteInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.each(regions.bigtable, function(region, rcb){ + let instances = helpers.addSource( + cache, source, ['bigtable', 'list', region]); + + if (!instances) return rcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query BigTable instances', region, null, null, instances.err); + return rcb(); + } + + if (!instances.data.length) { + helpers.addResult(results, 0, 'No BigTable instances found', region); + return rcb(); + } + + instances.data.forEach(instance => { + + if (instance.labels && + Object.keys(instance.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(instance.labels).length} labels found for BigTable instance`, region, instance.name); + } else { + helpers.addResult(results, 2, + 'BigTable instance does not have any labels', region, instance.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/bigtable/bigtableInstanceLabelsAdded.spec.js b/plugins/google/bigtable/bigtableInstanceLabelsAdded.spec.js new file mode 100644 index 000000000..a41315371 --- /dev/null +++ b/plugins/google/bigtable/bigtableInstanceLabelsAdded.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var plugin = require('./bigtableInstanceLabelsAdded'); + +const instances = [ + { + name: 'projects/testproj/instances/test-1', + displayName: 'test-1', + state: 'READY', + type: 'PRODUCTION', + labels: { 'label': 'label' }, + createTime: '2022-10-15T22:02:43.720541615Z' + }, + { + name: 'projects/testproj/instances/test-2', + displayName: 'test-2', + state: 'READY', + type: 'PRODUCTION', + createTime: '2022-10-15T22:02:43.720541615Z' + }, + +]; + +const createCache = (err, data) => { + return { + bigtable: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('bigtableInstanceLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if an instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query BigTable instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no bigtable instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No BigTable instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for BigTable instance'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [instances[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('BigTable instance does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [instances[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/clb/clbCDNEnabled.js b/plugins/google/clb/clbCDNEnabled.js index bc66d7860..4c2c55128 100644 --- a/plugins/google/clb/clbCDNEnabled.js +++ b/plugins/google/clb/clbCDNEnabled.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'CLB CDN Enabled', category: 'CLB', + domain: 'Availability', + severity: 'Medium', description: 'Ensures that Cloud CDN is enabled on all load balancers', more_info: 'Cloud CDN increases speed and reliability as well as lowers server costs. Enabling CDN on load balancers creates a highly available system and is part of GCP best practices.', link: 'https://cloud.google.com/cdn/docs/quickstart', recommended_action: 'Enable Cloud CDN on all load balancers from the network services console.', apis: ['backendServices:list'], + realtime_triggers: ['compute.backendServices.patch','compute.backendServices.insert','compute.backendServices.delete'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.backendServices, function(region, rcb){ let backendServices = helpers.addSource(cache, source, ['backendServices', 'list', region]); @@ -23,7 +37,7 @@ module.exports = { if (backendServices.err || !backendServices.data) { helpers.addResult(results, 3, - 'Unable to query backend services: ' + helpers.addError(backendServices), region); + 'Unable to query backend services', region, null, null, backendServices.err); return rcb(); } @@ -32,20 +46,30 @@ module.exports = { return rcb(); } + let found = false; backendServices.data.forEach(backend => { + if (!backend.name) return; + + found = true; + let resource = helpers.createResourceName('backendServices', backend.name, project, 'global'); if (backend.enableCDN) { helpers.addResult(results, 0, - 'CDN is enabled on the backend service', region, backend.id); + 'CDN is enabled on the backend service', region, resource); } else { helpers.addResult(results, 2, - 'CDN is disabled on the backend service', region, backend.id); + 'CDN is disabled on the backend service', region, resource); } }); + if (!found) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + rcb(); }, function(){ // Global checking goes here callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/clb/clbCDNEnabled.spec.js b/plugins/google/clb/clbCDNEnabled.spec.js index e923e2bfb..21b5e3d00 100644 --- a/plugins/google/clb/clbCDNEnabled.spec.js +++ b/plugins/google/clb/clbCDNEnabled.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } } } }; diff --git a/plugins/google/clb/clbHttpsOnly.js b/plugins/google/clb/clbHttpsOnly.js index c6b31a8ed..bc19a74b7 100644 --- a/plugins/google/clb/clbHttpsOnly.js +++ b/plugins/google/clb/clbHttpsOnly.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'CLB HTTPS Only', category: 'CLB', - description: 'Ensures CLBs are configured to only accept connections on HTTPS ports', + domain: 'Availability', + severity: 'High', + description: 'Ensures that HTTP(S) CLBs are configured to only accept connections on HTTPS ports.', more_info: 'For maximum security, CLBs can be configured to only accept HTTPS connections. Standard HTTP connections will be blocked. This should only be done if the client application is configured to query HTTPS directly and not rely on a redirect from HTTP.', link: 'https://cloud.google.com/vpc/docs/vpc', recommended_action: 'Remove non-HTTPS listeners from the load balancer.', - apis: ['targetHttpProxies:list'], + apis: ['urlMaps:list', 'targetHttpProxies:list'], compliance: { pci: 'PCI requires strong cryptographic and security protocols ' + 'when transmitting user data over open, public networks, ' + @@ -17,6 +19,7 @@ module.exports = { 'load balancer HTTPS redirection should be used to ensure site visitors ' + 'are always connecting over a secure channel.', }, + realtime_triggers: ['compute.backendServices.patch','compute.backendServices.insert','compute.backendServices.delete'], run: function(cache, settings, callback) { var results = []; @@ -24,40 +27,46 @@ module.exports = { var regions = helpers.regions(); async.each(regions.targetHttpProxies, function(region, rcb){ - let httpProxies = helpers.addSource( - cache, source, ['targetHttpProxies', 'list', region]); + let urlMaps = helpers.addSource( + cache, source, ['urlMaps', 'list', region]); - if (!httpProxies) return rcb(); + if (!urlMaps) return rcb(); - if (httpProxies.err || !httpProxies.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(httpProxies), region); + if (urlMaps.err || !urlMaps.data) { + helpers.addResult(results, 3, 'Unable to query Load Balancer', region, null, null, urlMaps.err); return rcb(); } - if (!httpProxies.data.length) { - helpers.addResult(results, 0, 'No firewall rules found', region); + if (!urlMaps.data.length) { + helpers.addResult(results, 0, 'No Load Balancers found', region); return rcb(); } - var non_https_listener = []; - httpProxies.data.forEach(httpProxy => { - if (httpProxy.urlMap) { - var urlMap = httpProxy.urlMap.split('/'); - var lbName = urlMap[urlMap.length-1]; - non_https_listener.push(lbName); - } - }); - if (non_https_listener.length) { - msg = "The following Load Balancers are not HTTPS-only: "; - helpers.addResult( - results, 2, msg + non_https_listener.join(', '), region, null); - } else{ - helpers.addResult(results, 0, 'No listeners found', region, null); + let httpProxies = helpers.addSource( + cache, source, ['targetHttpProxies', 'list', region]); + + if (!httpProxies || httpProxies.err || !httpProxies.data) { + helpers.addResult(results, 3, 'Unable to query HTTP proxies', region, null, null, httpProxies.err); + return rcb(); } + + for (let urlMap of urlMaps.data) { + if (!urlMap.selfLink) continue; + + let clbResource = urlMap.selfLink.split('/').slice(5).join('/'); + let found = httpProxies.data.find(proxy => proxy.urlMap && proxy.urlMap.includes(clbResource)); + + if (found) { + helpers.addResult(results, 2, 'Load Balancer is not HTTPS-Only', region, clbResource); + } else { + helpers.addResult(results, 0, 'Load Balancer is HTTPS-Only', region, clbResource); + } + } + rcb(); }, function(){ // Global checking goes here callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/clb/clbHttpsOnly.spec.js b/plugins/google/clb/clbHttpsOnly.spec.js new file mode 100644 index 000000000..88017b381 --- /dev/null +++ b/plugins/google/clb/clbHttpsOnly.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var plugin = require('./clbHttpsOnly'); + +const createCache = (urlMapsData, urlMapsErr, targetHttpProxiesData, targetHttpProxiesErr) => { + return { + urlMaps: { + list: { + 'global': { + err: urlMapsErr, + data: urlMapsData + } + } + }, + targetHttpProxies: { + list: { + 'global': { + err: targetHttpProxiesErr, + data: targetHttpProxiesData + } + } + } + } +}; + +describe('clbHttpsOnly', function () { + describe('run', function () { + it('should give unknown result if a unable to query Load Balancers', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Load Balancer'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no Load Balancers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Load Balancers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Load Balancer is HTTPS-Only', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Load Balancer is HTTPS-Only'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + "id": "3272419801760278187", + "creationTimestamp": "2021-09-24T02:20:04.204-07:00", + "name": "test-clb", + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/urlMaps/test-clb", + "defaultService": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/backendServices/test-bes", + "fingerprint": "dt7lg_mdH8s=", + "kind": "compute#urlMap" + } + ], + null, + [], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Load Balancer is not HTTPS-Only', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Load Balancer is not HTTPS-Only'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + "id": "3272419801760278187", + "creationTimestamp": "2021-09-24T02:20:04.204-07:00", + "name": "test-clb", + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/urlMaps/test-clb", + "defaultService": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/backendServices/test-bes", + "fingerprint": "dt7lg_mdH8s=", + "kind": "compute#urlMap" + } + ], + null, + [ + { + "id": "8080803103162504571", + "creationTimestamp": "2021-09-24T03:42:28.691-07:00", + "name": "lb-target-2", + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/targetHttpProxies/lb-target-2", + "urlMap": "https://www.googleapis.com/compute/v1/projects/khulnasoft-test/global/urlMaps/test-clb", + "fingerprint": "Mk7MijcwFNc=", + "kind": "compute#targetHttpProxy" + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/clb/clbLoggingEnabled.js b/plugins/google/clb/clbLoggingEnabled.js new file mode 100644 index 000000000..5349d8730 --- /dev/null +++ b/plugins/google/clb/clbLoggingEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'CLB Logging Enabled', + category: 'CLB', + domain: 'Management and Governance', + severity: 'High', + description: 'Ensures that logging is enabled for all HTTP(s) load balancers', + more_info: 'Enabling logging on a HTTP(s) Load Balancer will show all network traffic and its destination which can be used to assess its performance, usage, configuration and in troubleshooting any problems.', + link: 'https://cloud.google.com/load-balancing/docs/https/https-logging-monitoring', + recommended_action: 'Enable logging for all HTTP(s) load balancers from the network services console.', + apis: ['backendServices:list'], + realtime_triggers: ['compute.backendServices.patch','compute.backendServices.insert','compute.backendServices.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.backendServices, function(region, rcb){ + let backendServices = helpers.addSource(cache, source, + ['backendServices', 'list', region]); + + if (!backendServices) return rcb(); + + if (backendServices.err || !backendServices.data) { + helpers.addResult(results, 3, + 'Unable to query backend services', region, null, null, backendServices.err); + return rcb(); + } + + if (!backendServices.data.length) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + + let found = false; + backendServices.data.forEach(backend => { + if (!backend.name) return; + + found = true; + let resource = helpers.createResourceName('backendServices', backend.name, project, 'global'); + if (backend.logConfig && backend.logConfig.enable) { + helpers.addResult(results, 0, + 'Logging is enabled for the backend service', region, resource); + } else { + helpers.addResult(results, 2, + 'Logging is disabled for the backend service', region, resource); + } + }); + + if (!found) { + helpers.addResult(results, 0, 'No HTTP(s) load balancers found', region); + return rcb(); + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/clb/clbLoggingEnabled.spec.js b/plugins/google/clb/clbLoggingEnabled.spec.js new file mode 100644 index 000000000..3c4a45ef4 --- /dev/null +++ b/plugins/google/clb/clbLoggingEnabled.spec.js @@ -0,0 +1,134 @@ +var expect = require('chai').expect; +var plugin = require('./clbLoggingEnabled'); + +const createCache = (err, data) => { + return { + backendServices: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + } + } +}; + +describe('clbLoggingEnabled', function () { + describe('run', function () { + it('should give unknown result if a backend service error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query backend services'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no backend services are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No load balancers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if logging is enabled for the backend service', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Logging is enabled for the backend service'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "4157315546279923160", + "creationTimestamp": "2019-10-02T11:57:59.879-07:00", + "name": "giotestlb1", + "description": "", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/backendServices/giotestlb1", + "healthChecks": [ + "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/healthChecks/giohealthcheck1" + ], + "timeoutSec": 30, + "port": 80, + "protocol": "HTTPS", + "fingerprint": "MWDwvqlcr5k=", + "portName": "https", + "logConfig": { + "enable": true + }, + "kind": "compute#backendService" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if logging is disabled for the backend service', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Logging is disabled for the backend service'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "4157315546279923160", + "creationTimestamp": "2019-10-02T11:57:59.879-07:00", + "name": "giotestlb1", + "description": "", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/backendServices/giotestlb1", + "healthChecks": [ + "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/healthChecks/giohealthcheck1" + ], + "timeoutSec": 30, + "port": 80, + "protocol": "HTTPS", + "fingerprint": "MWDwvqlcr5k=", + "portName": "https", + "logConfig": { + "enable": false + }, + "kind": "compute#backendService" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/clb/clbNoInstances.js b/plugins/google/clb/clbNoInstances.js index b3f40ee9a..fcbd32c51 100644 --- a/plugins/google/clb/clbNoInstances.js +++ b/plugins/google/clb/clbNoInstances.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'CLB No Instances', category: 'CLB', + domain: 'Availability', + severity: 'Medium', description: 'Detects CLBs that have no backend instances attached', more_info: 'GCP does not allow for Load Balancers to be configured without backend instances attached.', link: 'https://cloud.google.com/load-balancing/docs/load-balancing-overview', recommended_action: 'This security misconfiguration is covered by GCP. No action is necessary.', apis: ['backendServices:list'], + realtime_triggers: ['compute.backendServices.insert','compute.backendServices.delete'], run: function(cache, settings, callback) { var results = []; @@ -23,7 +26,7 @@ module.exports = { if (backendServices.err || !backendServices.data) { helpers.addResult(results, 3, - 'Unable to query backend services: ' + helpers.addError(backendServices), region); + 'Unable to query backend services', region, null, null, backendServices.err); return rcb(); } @@ -40,4 +43,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/clb/clbNoInstances.spec.js b/plugins/google/clb/clbNoInstances.spec.js new file mode 100644 index 000000000..877f43eca --- /dev/null +++ b/plugins/google/clb/clbNoInstances.spec.js @@ -0,0 +1,77 @@ +var expect = require('chai').expect; +var plugin = require('./clbNoInstances'); + +const createCache = (backendServicesData, backendServicesErr) => { + return { + backendServices: { + list: { + 'global': { + err: backendServicesErr, + data: backendServicesData + } + } + } + } +}; + +describe('clbNoInstances', function () { + describe('run', function () { + it('should give unknown result if a unable to query backend services', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query backend services'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no Load Balancers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No load balancers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give passing result if Load Balancers have backend services', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('All load balancers have backend services'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: 'projects/my-test-project/global/backendServices', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-test-project/global/backendServices', + kind: 'compute#backendServiceList' + } + ], + null + ); + + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/clb/clbSecurityPolicyEnabled.js b/plugins/google/clb/clbSecurityPolicyEnabled.js index 59ece2a7f..1045eefa9 100644 --- a/plugins/google/clb/clbSecurityPolicyEnabled.js +++ b/plugins/google/clb/clbSecurityPolicyEnabled.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Security Policy Enabled', category: 'CLB', + domain: 'Availability', + severity: 'Medium', description: 'Ensures all backend services have an attached security policy', more_info: 'Security policies on backend services control the traffic on the load balancer. This creates edge security and can deny or allow specified IP addresses.', link: 'https://cloud.google.com/armor/docs/security-policy-concepts', recommended_action: 'Ensure all load balancers have an attached Cloud Armor security policy.', apis: ['backendServices:list'], + realtime_triggers: ['compute.backendServices.patch','compute.backendServices.insert','compute.backendServices.delete'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.backendServices, function(region, rcb){ let backendServices = helpers.addSource(cache, source, ['backendServices', 'list', region]); @@ -23,7 +37,7 @@ module.exports = { if (backendServices.err || !backendServices.data) { helpers.addResult(results, 3, - 'Unable to query backend services: ' + helpers.addError(backendServices), region); + 'Unable to query backend services', region, null, null, backendServices.err); return rcb(); } @@ -32,16 +46,27 @@ module.exports = { return rcb(); } + let found = false; backendServices.data.forEach(backend => { + if (!backend.name) return; + + found = true; + let resource = helpers.createResourceName('backendServices', backend.name, project, 'global'); + if (backend.securityPolicy) { helpers.addResult(results, 0, - 'The backend service has an attached security policy', region, backend.id); + 'The backend service has an attached security policy', region, resource); } else { helpers.addResult(results, 2, - 'The backend service does not have an attached security policy', region, backend.id); + 'The backend service does not have an attached security policy', region, resource); } }); + if (!found) { + helpers.addResult(results, 0, 'No load balancers found', region); + return rcb(); + } + rcb(); }, function(){ // Global checking goes here diff --git a/plugins/google/clb/clbSecurityPolicyEnabled.spec.js b/plugins/google/clb/clbSecurityPolicyEnabled.spec.js index fb08b494f..576135766 100644 --- a/plugins/google/clb/clbSecurityPolicyEnabled.spec.js +++ b/plugins/google/clb/clbSecurityPolicyEnabled.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } } } }; diff --git a/plugins/google/cloudbuild/commentControlEnabled.js b/plugins/google/cloudbuild/commentControlEnabled.js new file mode 100644 index 000000000..165791e82 --- /dev/null +++ b/plugins/google/cloudbuild/commentControlEnabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Comment Control Enabled', + category: 'CloudBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure Comment Control is enabled for all cloud build triggers.', + more_info: 'Comment control is a configuration which determines if the build will be automatically executed by Github Pull requests trigger. As a security best practice, enable the comment control to ensure that builds are not executed automatically by pull request created by any contributor, and only gets executed when the owner or collaborator comments /gcbrun on the pull request.', + link: 'https://cloud.google.com/build/docs/automating-builds/create-manage-triggers', + recommended_action: 'Ensure all cloudbuild triggers have comment control enabled.', + apis: ['cloudbuild:triggers'], + realtime_triggers: ['devtools.cloudbuild.CloudBuild.CreateBuildTrigger','devtools.cloudbuild.CloudBuild.UpdateBuildTrigger','devtools.cloudbuild.CloudBuild.DeleteBuildTrigger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.cloudbuild, function(region, rcb){ + let triggers = helpers.addSource(cache, source, + ['cloudbuild' ,'triggers', region]); + + if (!triggers) return rcb(); + + if (triggers.err || !triggers.data) { + helpers.addResult(results, 3, 'Unable to query Cloud Build triggers', region, null, null, triggers.err); + return rcb(); + } + + if (!triggers.data.length) { + helpers.addResult(results, 0, 'No Cloud Build triggers found', region); + return rcb(); + } + + triggers.data.forEach(trigger => { + let resource = helpers.createResourceName('triggers', trigger.name, project, 'location', region); + + if (trigger.github && trigger.github['pullRequest']) { + if (trigger.github['pullRequest'] && trigger.github['pullRequest'].commentControl) { + helpers.addResult(results, 0, + 'Cloud Build trigger has comment control enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Cloud Build trigger does not have comment control enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'Cloud Build trigger is not a pull request trigger', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudbuild/commentControlEnabled.spec.js b/plugins/google/cloudbuild/commentControlEnabled.spec.js new file mode 100644 index 000000000..674ba845b --- /dev/null +++ b/plugins/google/cloudbuild/commentControlEnabled.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var plugin = require('./commentControlEnabled'); + +const triggers = [ + { + id: '11111', + createTime: '2023-02-28T06:52:35.542711825Z', + github: { + push: { branch: '^master$' }, + owner: 'test-user', + name: 'my-repo' + }, + autodetect: true, + name: 'trigger-us', + resourceName: 'projects/test-proj/locations/us-central1/triggers/11111' + }, + { + id: '22222', + createTime: '2023-02-28T06:27:53.476869805Z', + filename: 'cloudbuild.yaml', + github: { + pullRequest: { branch: '^master$', commentControl: 'COMMENTS_ENABLED' }, + owner: 'test-user', + name: 'my-repo' + }, + name: 'trigger-1', + approvalConfig: { approvalRequired: true }, + resourceName: 'projects/test-proj/locations/global/triggers/22222' + }, + { + id: '3333', + createTime: '2023-02-28T06:28:25.992664063Z', + github: { + pullRequest: { branch: '^master$' }, + owner: 'test-user', + name: 'my-repo' + }, + autodetect: true, + name: 'trigger-2', + resourceName: 'projects/test-proj/locations/global/triggers/3333' + } +]; + +const createCache = (err, data) => { + return { + cloudbuild: { + triggers: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('commentControlEnabled', function () { + describe('run', function () { + it('should give unknown result if a trigger error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Cloud Build triggers'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no triggers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cloud Build triggers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if cloud build trigger has comment control enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has comment control enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[1]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cloud build trigger does not have comment control enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have comment control enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if cloud build trigger is not a pull request trigger', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is not a pull request trigger'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[0]] + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/cloudbuild/specificSourceBranch.js b/plugins/google/cloudbuild/specificSourceBranch.js new file mode 100644 index 000000000..b4c7ff437 --- /dev/null +++ b/plugins/google/cloudbuild/specificSourceBranch.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Specific Source Branch', + category: 'CloudBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure cloud build triggers are configured with specific source branch.', + more_info: 'When creating cloud build triggers with Push or Pull repository events, ensure you specify the specific source branch within the repository. The regular expression .* will trigger this build for changes on any branch.', + link: 'https://cloud.google.com/build/docs/automating-builds/create-manage-triggers', + recommended_action: 'Ensure all cloud build triggers with Push or Pull events have a source branch specified.', + apis: ['cloudbuild:triggers'], + realtime_triggers: ['devtools.cloudbuild.CloudBuild.CreateBuildTrigger','devtools.cloudbuild.CloudBuild.UpdateBuildTrigger','devtools.cloudbuild.CloudBuild.DeleteBuildTrigger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.cloudbuild, function(region, rcb){ + let triggers = helpers.addSource(cache, source, + ['cloudbuild' ,'triggers', region]); + + if (!triggers) return rcb(); + + if (triggers.err || !triggers.data) { + helpers.addResult(results, 3, 'Unable to query Cloud Build triggers', region, null, null, triggers.err); + return rcb(); + } + + if (!triggers.data.length) { + helpers.addResult(results, 0, 'No Cloud Build triggers found', region); + return rcb(); + } + + + triggers.data.forEach(trigger => { + let resource = helpers.createResourceName('triggers', trigger.name, project, 'location', region); + if (trigger.github && (trigger.github['pullRequest'] || trigger.github['push'])) { + let triggerType = trigger.github['pullRequest'] ? 'pullRequest' : 'push'; + let triggerRegex = trigger.github[triggerType].branch || trigger.github[triggerType].tag; + if (triggerRegex && triggerRegex !== '.*') { + helpers.addResult(results, 0, + 'Cloud Build trigger has specific source branch or tag', region, resource); + } else { + helpers.addResult(results, 2, + 'Cloud Build trigger does not have specific source branch or tag', region, resource); + } + } else { + helpers.addResult(results, 0, + 'Cloud Build trigger is not a push or pull trigger', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudbuild/specificSourceBranch.spec.js b/plugins/google/cloudbuild/specificSourceBranch.spec.js new file mode 100644 index 000000000..24fe2e092 --- /dev/null +++ b/plugins/google/cloudbuild/specificSourceBranch.spec.js @@ -0,0 +1,161 @@ +var expect = require('chai').expect; +var plugin = require('./specificSourceBranch'); + +const triggers = [ + { + id: '11111', + createTime: '2023-02-28T06:52:35.542711825Z', + gitFileSource: { + path: 'cloudbuild.yaml', + uri: 'https://github.com/tes-user/repo-1', + repoType: 'GITHUB', + revision: 'refs/heads/main' + }, + sourceToBuild: { + uri: 'https://github.com/tes-user/repo-1', + ref: 'refs/heads/main', + repoType: 'GITHUB' + }, + pubsubConfig: { + subscription: 'projects/11111/subscriptions/gcb-trigger-us', + topic: 'projects/testproj/topics/topic-1' + }, + autodetect: true, + name: 'trigger-us', + resourceName: 'projects/test-proj/locations/us-central1/triggers/11111' + }, + { + id: '22222', + createTime: '2023-02-28T06:27:53.476869805Z', + filename: 'cloudbuild.yaml', + github: { + pullRequest: { branch: '^master$', commentControl: 'COMMENTS_ENABLED' }, + owner: 'test-user', + name: 'my-repo' + }, + name: 'trigger-1', + approvalConfig: { approvalRequired: true }, + resourceName: 'projects/test-proj/locations/global/triggers/22222' + }, + { + id: '3333', + createTime: '2023-02-28T06:28:25.992664063Z', + github: { + pullRequest: { branch: '.*' }, + owner: 'test-user', + name: 'my-repo' + }, + autodetect: true, + name: 'trigger-2', + resourceName: 'projects/test-proj/locations/global/triggers/3333' + } +]; + +const createCache = (err, data) => { + return { + cloudbuild: { + triggers: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('specificSourceBranch', function () { + describe('run', function () { + it('should give unknown result if a trigger error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Cloud Build triggers'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no triggers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cloud Build triggers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if cloud build trigger has specific source branch or tag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has specific source branch or tag'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[1]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cloud build trigger does not have specific source branch or tag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have specific source branch or tag'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if cloud build trigger is not a push or pull trigger', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is not a push or pull trigger'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[0]] + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/cloudbuild/triggerHasTags.js b/plugins/google/cloudbuild/triggerHasTags.js new file mode 100644 index 000000000..d50621c0d --- /dev/null +++ b/plugins/google/cloudbuild/triggerHasTags.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Trigger Has Tags', + category: 'CloudBuild', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure cloud build triggers have tags.', + more_info: 'Tags are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/build/docs/automating-builds/create-manage-triggers', + recommended_action: 'Ensure all cloudbuild triggers have tags added.', + apis: ['cloudbuild:triggers'], + realtime_triggers: ['devtools.cloudbuild.CloudBuild.CreateBuildTrigger','devtools.cloudbuild.CloudBuild.UpdateBuildTrigger','devtools.cloudbuild.CloudBuild.DeleteBuildTrigger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.cloudbuild, function(region, rcb){ + let triggers = helpers.addSource(cache, source, + ['cloudbuild' ,'triggers', region]); + + if (!triggers) return rcb(); + + if (triggers.err || !triggers.data) { + helpers.addResult(results, 3, 'Unable to query Cloud Build triggers', region, null, null, triggers.err); + return rcb(); + } + + if (!triggers.data.length) { + helpers.addResult(results, 0, 'No Cloud Build triggers found', region); + return rcb(); + } + + + triggers.data.forEach(trigger => { + let resource = helpers.createResourceName('triggers', trigger.name, project, 'location', region); + + if (trigger.tags && trigger.tags.length) { + helpers.addResult(results, 0, + `${trigger.tags.length} tags found for Cloud Build trigger`, region, resource); + } else { + helpers.addResult(results, 2, + 'Cloud Build trigger does not have any tags', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudbuild/triggerHasTags.spec.js b/plugins/google/cloudbuild/triggerHasTags.spec.js new file mode 100644 index 000000000..6c98564f4 --- /dev/null +++ b/plugins/google/cloudbuild/triggerHasTags.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var plugin = require('./triggerHasTags'); + +const triggers = [ + { + id: '11111', + createTime: '2023-02-28T06:52:35.542711825Z', + github: { + push: { branch: '^master$' }, + owner: 'test-user', + name: 'my-repo' + }, + autodetect: true, + name: 'trigger-us', + resourceName: 'projects/test-proj/locations/us-central1/triggers/11111', + tags: ['test'] + }, + { + id: '22222', + createTime: '2023-02-28T06:27:53.476869805Z', + filename: 'cloudbuild.yaml', + github: { + pullRequest: { branch: '^master$', commentControl: 'COMMENTS_ENABLED' }, + owner: 'test-user', + name: 'my-repo' + }, + name: 'trigger-1', + approvalConfig: { approvalRequired: true }, + resourceName: 'projects/test-proj/locations/global/triggers/22222' + } +]; + +const createCache = (err, data) => { + return { + cloudbuild: { + triggers: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('triggerHasTags', function () { + describe('run', function () { + it('should give unknown result if a trigger error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Cloud Build triggers'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no triggers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cloud Build triggers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if cloud build trigger has tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('tags found for Cloud Build trigger'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cloud build trigger does not have any tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any tags'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[1]] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/cloudbuild/userApprovalEnabled.js b/plugins/google/cloudbuild/userApprovalEnabled.js new file mode 100644 index 000000000..08b633ec4 --- /dev/null +++ b/plugins/google/cloudbuild/userApprovalEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'User Approval Enabled', + category: 'CloudBuild', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure User Approval is enabled for all cloud build triggers.', + more_info: 'The Approval setting ensures that build gets executed only after being approved by an user who has ‘Cloud Build Approver’ role for the project. As a security best practice, ensure user approval is enabled for all build triggers.', + link: 'https://cloud.google.com/build/docs/securing-builds/gate-builds-on-approval', + recommended_action: 'Ensure all cloud build triggers have user approval enabled.', + apis: ['cloudbuild:triggers'], + realtime_triggers: ['devtools.cloudbuild.CloudBuild.CreateBuildTrigger','devtools.cloudbuild.CloudBuild.UpdateBuildTrigger','devtools.cloudbuild.CloudBuild.DeleteBuildTrigger'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.cloudbuild, function(region, rcb){ + let triggers = helpers.addSource(cache, source, + ['cloudbuild' ,'triggers', region]); + + if (!triggers) return rcb(); + + if (triggers.err || !triggers.data) { + helpers.addResult(results, 3, 'Unable to query Cloud Build triggers', region, null, null, triggers.err); + return rcb(); + } + + if (!triggers.data.length) { + helpers.addResult(results, 0, 'No Cloud Build triggers found', region); + return rcb(); + } + + + triggers.data.forEach(trigger => { + let resource = helpers.createResourceName('triggers', trigger.name, project, 'location', region); + + if (trigger.approvalConfig && trigger.approvalConfig.approvalRequired) { + helpers.addResult(results, 0, + 'Cloud Build trigger has user approval enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Cloud Build trigger does not have user approval enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/cloudbuild/userApprovalEnabled.spec.js b/plugins/google/cloudbuild/userApprovalEnabled.spec.js new file mode 100644 index 000000000..60a33929a --- /dev/null +++ b/plugins/google/cloudbuild/userApprovalEnabled.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var plugin = require('./userApprovalEnabled'); + +const triggers = [ + { + id: '22222', + createTime: '2023-02-28T06:27:53.476869805Z', + filename: 'cloudbuild.yaml', + github: { + pullRequest: { branch: '^master$', commentControl: 'COMMENTS_ENABLED' }, + owner: 'test-user', + name: 'my-repo' + }, + name: 'trigger-1', + approvalConfig: { approvalRequired: true }, + resourceName: 'projects/test-proj/locations/global/triggers/22222' + }, + { + id: '3333', + createTime: '2023-02-28T06:28:25.992664063Z', + github: { + pullRequest: { branch: '^master$' }, + owner: 'test-user', + name: 'my-repo' + }, + autodetect: true, + name: 'trigger-2', + resourceName: 'projects/test-proj/locations/global/triggers/3333' + } +]; + +const createCache = (err, data) => { + return { + cloudbuild: { + triggers: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('userApprovalEnabled', function () { + describe('run', function () { + it('should give unknown result if a trigger error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Cloud Build triggers'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no triggers are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Cloud Build triggers found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if cloud build trigger has user approval enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has user approval enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cloud build trigger does not have user approval enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have user approval enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [triggers[1]] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.js b/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.js new file mode 100644 index 000000000..c8d199d81 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function Labels Added', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Low', + description: 'Ensure that all Cloud Functions have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/functions/docs/configuring', + recommended_action: 'Ensure labels are added to all Cloud Functions.', + apis: ['functions:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (func.labels && + Object.keys(func.labels).length) { + helpers.addResult(results, 0, `${Object.keys(func.labels).length} labels found for Cloud Function`, region, func.name); + } else { + helpers.addResult(results, 2, 'Cloud Function does not have any labels', region, func.name); + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.spec.js b/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.spec.js new file mode 100644 index 000000000..54bd67691 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionLabelsAdded'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "labels": { 'deployment-tool': 'console-cloud' } + + } +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionLabelsAdded', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Cloud Function'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js b/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js new file mode 100644 index 000000000..f05a4b4d4 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js @@ -0,0 +1,159 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Internet Exposure', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Info', + description: 'Ensures Cloud Functions are not publicly exposed to all inbound traffic.', + more_info: 'Cloud Functions should be properly secured using ingress settings and load balancer configurations to control which sources can invoke the function.', + link: 'https://cloud.google.com/functions/docs/networking/network-settings', + recommended_action: 'Modify the Cloud Function to restrict ingress settings and ensure load balancer and api gateway configurations are properly secured.', + apis: ['functions:list', 'urlMaps:list', 'targetHttpProxies:list', 'targetHttpsProxies:list', + 'forwardingRules:list', 'backendServices:list', 'apiGateways:list', 'api:list', 'apiConfigs:list', 'apiGateways:getIamPolicy'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction', + 'compute.backendServices.insert', 'compute.backendServices.delete', 'compute.backendServices.patch', 'compute.instanceGroups.removeInstances', 'compute.urlMaps.insert', 'compute.urlMaps.delete', 'compute.urlMaps.update', 'compute.urlMaps.patch', + 'compute.targetHttpProxies.insert', 'compute.targetHttpProxies.delete', 'compute.targetHttpProxies.patch', 'compute.targetHttpsProxies.insert', 'compute.targetHttpsProxies.delete', 'compute.targetHttpsProxies.patch', + 'compute.forwardingRules.insert', 'compute.forwardingRules.delete', 'compute.forwardingRules.patch', 'apigateway.gateways.create', 'apigateway.gateways.update', 'apigateway.gateways.delete' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + let apiGateways = [], apis = [], apiConfigs = []; + for (let region of regions.apiGateways) { + var gateways = helpers.addSource(cache, source, + ['apiGateways', 'list', region]); + + if (gateways && !gateways.err && gateways.data && gateways.data.length) { + apiGateways = apiGateways.concat(gateways.data); + } + + + var apiList = helpers.addSource(cache, source, + ['api', 'list', region]); + + if (apiList && !apiList.err && apiList.data && apiList.data.length) { + apis = apis.concat(apiList.data); + } + + var configs = helpers.addSource(cache, source, + ['apiConfigs', 'list', region]); + + if (configs && !configs.err && configs.data && configs.data.length) { + apiConfigs = apiConfigs.concat(configs.data); + } + } + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + let internetExposed = ''; + if (func.ingressSettings && func.ingressSettings.toUpperCase() == 'ALLOW_ALL') { + internetExposed = 'public access'; + } else if (func.ingressSettings && func.ingressSettings.toUpperCase() == 'ALLOW_INTERNAL_AND_GCLB') { + // only check load balancer flow if it allows traffic from LBs + let forwardingRules = []; + let networks = []; + let firewallRules = []; + forwardingRules = helpers.getForwardingRules(cache, source, region, func); + internetExposed = helpers.checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules); + + if (!internetExposed || !internetExposed.length) { + const gatewayPolicies = helpers.addSource(cache, source, + ['apiGateways', 'getIamPolicy', region]); + + if (apiGateways && apiGateways.length && apiConfigs && apiConfigs.length) { + apiGateways.forEach(gateway => { + let isGatewayExposed = false; + if (!gateway.apiConfig || !gateway.defaultHostname) return; + + const apiConfig = apiConfigs.find(config => + gateway.apiConfig.includes(config.name)); + + if (!apiConfig) return; + + if (apiConfig.openapiDocuments) { + const specs = apiConfig.openapiDocuments.map(doc => + typeof doc === 'string' ? JSON.parse(doc) : doc); + + const hasFunctionReference = specs.some(spec => + JSON.stringify(spec).includes(func.httpsTrigger.url) || + JSON.stringify(spec).includes(func.name) + ); + + if (!hasFunctionReference) return; + + const gatewayPolicy = gatewayPolicies.data.find(policy => + policy.parent && policy.parent.name === gateway.name); + + if (gatewayPolicy && gatewayPolicy.bindings) { + const publicAccess = gatewayPolicy.bindings.some(binding => + binding.members.includes('allUsers') || + binding.members.includes('allAuthenticatedUsers')); + if (publicAccess) { + isGatewayExposed = true; + } + } + + if (!apiConfig.securityDefinitions || !Object.keys(apiConfig.securityDefinitions).length || + !apiConfig.security || !apiConfig.security.length) { + isGatewayExposed = true; + } + + + if (isGatewayExposed) { + internetExposed += internetExposed.length ? `, ag ${gateway.displayName}` : `ag ${gateway.displayName}`; + } + } + }); + } + } + + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Cloud function is exposed to the internet through ${internetExposed}`, region, func.name); + } else { + helpers.addResult(results, 0, 'Cloud function is not exposed to the internet', region, func.name); + } + + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + + } +}; + diff --git a/plugins/google/cloudfunctions/cloudFunctionOldRuntime.js b/plugins/google/cloudfunctions/cloudFunctionOldRuntime.js new file mode 100644 index 000000000..c7a6ee2c7 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionOldRuntime.js @@ -0,0 +1,118 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function Old Runtimes', + category: 'Cloud Functions', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure Cloud Functions are not using deprecated runtime versions.', + more_info: 'Cloud Functions runtimes should be kept current with recent versions of the underlying codebase. It is recommended to update to the latest supported versions to avoid potential security risks and ensure compatibility.', + link: 'https://cloud.google.com/functions/docs/concepts/execution-environment', + recommended_action: 'Modify Cloud Functions to use latest versions.', + apis: ['functions:list'], + settings: { + function_runtime_fail: { + name: 'Cloud Function Runtime Fail', + description: 'Return a failing result for cloud function runtime before this number of days for their end of life date.', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 0 + } + }, + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + function_runtime_fail: parseInt(settings.function_runtime_fail || this.settings.function_runtime_fail.default) + }; + + var deprecatedRuntimes = [ + { 'id':'nodejs10', 'name': 'Node.js 10.x', 'endOfLifeDate': '2021-07-30' }, + { 'id':'nodejs12', 'name': 'Node.js 12', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs14', 'name': 'Node.js 14', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs16', 'name': 'Node.js 16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs18', 'name': 'Node.js 18', 'endOfLifeDate': '2025-04-30' }, + { 'id':'nodejs20', 'name': 'Node.js 20', 'endOfLifeDate': '2026-04-30' }, + { 'id':'dotnet6', 'name': '.Net 6', 'endOfLifeDate': '2024-11-12' }, + { 'id':'dotnet7', 'name': '.Net 7', 'endOfLifeDate': '2024-05-14' }, + { 'id':'dotnet3', 'name': '.Net Core 3', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python27', 'name': 'Python 2.7', 'endOfLifeDate': '2021-07-15' }, + { 'id':'python36', 'name': 'Python 3.6', 'endOfLifeDate': '2022-07-18' }, + { 'id':'python37', 'name': 'Python 3.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python38', 'name': 'Python 3.8', 'endOfLifeDate': '2024-10-14' }, + { 'id':'python39', 'name': 'Python 3.9', 'endOfLifeDate': '2025-10-05' }, + { 'id':'python310', 'name': 'Python 3.10', 'endOfLifeDate': '2026-10-04' }, + { 'id':'python311', 'name': 'Python 3.11', 'endOfLifeDate': '2027-10-24' }, + { 'id':'python312', 'name': 'Python 3.12', 'endOfLifeDate': '2028-10-02' }, + { 'id':'ruby25', 'name': 'Ruby 2.5', 'endOfLifeDate': '2021-07-30' }, + { 'id':'ruby27', 'name': 'Ruby 2.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'ruby30', 'name': 'Ruby 3.0', 'endOfLifeDate': '2024-03-31' }, + { 'id':'ruby32', 'name': 'Ruby 3.2', 'endOfLifeDate': '2026-03-31' }, + { 'id':'go121', 'name': 'Go 1.21', 'endOfLifeDate': '2024-05-01' }, + { 'id':'go119', 'name': 'Go 1.19', 'endOfLifeDate': '2024-04-30' }, + { 'id':'go118', 'name': 'Go 1.18', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go116', 'name': 'Go 1.16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go113', 'name': 'Go 1.13', 'endOfLifeDate': '2024-01-30' }, + { 'id':'java8', 'name': 'Java 8', 'endOfLifeDate': '2024-01-08' }, + { 'id':'java11', 'name': 'Java 11', 'endOfLifeDate': '2024-10-01' }, + { 'id':'java17', 'name': 'Java 17', 'endOfLifeDate': '2027-10-01' }, + { 'id':'php74', 'name': 'PHP 7.4', 'endOfLifeDate': '2024-01-30' }, + { 'id':'php81', 'name': 'PHP 8.1', 'endOfLifeDate': '2024-11-25' }, + { 'id':'php82', 'name': 'PHP 8.2', 'endOfLifeDate': '2025-12-08' }, + ]; + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name || !func.runtime) return; + + var deprecatedRuntime = deprecatedRuntimes.filter((d) => { + return d.id == func.runtime; + }); + + var version = func.runtime; + var runtimeDeprecationDate = (deprecatedRuntime && deprecatedRuntime.length && deprecatedRuntime[0].endOfLifeDate) ? Date.parse(deprecatedRuntime[0].endOfLifeDate) : null; + let today = new Date(); + today = Date.parse(`${today.getFullYear()}-${today.getMonth()+1}-${today.getDate()}`); + var difference = runtimeDeprecationDate? Math.round((runtimeDeprecationDate - today)/(1000 * 3600 * 24)): null; + if (runtimeDeprecationDate && today > runtimeDeprecationDate) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + deprecatedRuntime[0].name + ' which was deprecated on: ' + deprecatedRuntime[0].endOfLifeDate, + region, func.name); + } else if (difference && config.function_runtime_fail >= difference) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + version + ' which is deprecating in ' + Math.abs(difference) + ' days', + region, func.name); + } else { + helpers.addResult(results, 0, + 'Cloud Function is running the current version: ' + version, + region, func.name); + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/cloudFunctionOldRuntime.spec.js b/plugins/google/cloudfunctions/cloudFunctionOldRuntime.spec.js new file mode 100644 index 000000000..7f00884a4 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionOldRuntime.spec.js @@ -0,0 +1,115 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionOldRuntime'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "python311", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "labels": { 'deployment-tool': 'console-cloud' } + + } +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionOldRuntime', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function is using lasted runtime version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is running the current version: '); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is using deprecated runtimeversion', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which was deprecated on'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js b/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js new file mode 100644 index 000000000..53f5138e1 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js @@ -0,0 +1,22 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Cloud Functions', + domain: 'Cloud Functions', + severity: 'Info', + description: 'Ensures that no Cloud Functions in your cloud environment have excessive permissions.', + more_info: 'Cloud Functions that use service accounts with excessive IAM permissions may pose security risks. It is a best practice to assign only the necessary permissions to the service accounts attached to functions.', + link: 'https://cloud.google.com/functions/docs/securing/authenticating', + recommended_action: 'Review and restrict the IAM roles associated with service accounts used by Cloud Functions to follow the principle of least privilege.', + realtime_triggers: [ + 'functions.CloudFunctionsService.UpdateFunction', + 'functions.CloudFunctionsService.CreateFunction', + 'functions.CloudFunctionsService.DeleteFunction' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +}; diff --git a/plugins/google/cloudfunctions/functionAllUsersPolicy.js b/plugins/google/cloudfunctions/functionAllUsersPolicy.js new file mode 100644 index 000000000..6786c6c79 --- /dev/null +++ b/plugins/google/cloudfunctions/functionAllUsersPolicy.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function All Users Policy', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'High', + description: 'Ensure cloud functions are not anonymously or publicly accessible.', + more_info: 'Using Cloud Identity and Access Management (IAM), you can control access to the cloud functions. As a security best practice, ensure the access is not allowed to "allUsers" or "allAuthentictaedUsers" to avoid data leaks and other security risks.', + link: 'https://cloud.google.com/functions/docs/concepts/iam', + recommended_action: 'Ensure that each cloud function is configured so that no member is set to allUsers or allAuthenticatedUsers.', + apis: ['functions:list', 'functions:getIamPolicy'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + let functionPolicies = helpers.addSource(cache, source, + ['functions', 'getIamPolicy', region]); + + if (!functionPolicies) return rcb(); + + if (functionPolicies.err || !functionPolicies.data) { + helpers.addResult(results, 3, 'Unable to query cloud function policies: ' + helpers.addError(functionPolicies), region, null, null, functionPolicies.err); + return rcb(); + } + + if (!functionPolicies.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + + functions.data.forEach(func => { + if (!func.name) return; + + let funcPolicy = functionPolicies.data.find(policy => policy.parent && policy.parent.name && policy.parent.name == func.name); + let hasAllUsers = false; + + if (funcPolicy.bindings && + funcPolicy.bindings.length) { + funcPolicy.bindings.forEach(binding => { + if (binding.members && + binding.members.length) { + binding.members.forEach(member => { + if (member === 'allUsers' || + member === 'allAuthenticatedUsers') { + hasAllUsers = true; + } + }); + } + }); + } + if (hasAllUsers) { + helpers.addResult(results, 2, + 'Cloud Function has anonymous or public access', region, func.name); + } else { + helpers.addResult(results, 0, + 'Cloud Function does not have anonymous or public access', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/functionAllUsersPolicy.spec.js b/plugins/google/cloudfunctions/functionAllUsersPolicy.spec.js new file mode 100644 index 000000000..e708fd205 --- /dev/null +++ b/plugins/google/cloudfunctions/functionAllUsersPolicy.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var plugin = require('./functionAllUsersPolicy'); + +const createCache = (err, data, functionErr, functionData) => { + return { + functions: { + list: { + 'us-central1': { + err: functionErr, + data: functionData + } + }, + getIamPolicy: { + 'us-central1': { + err: err, + data: data + } + } + } + } +}; + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "labels": { 'deployment-tool': 'console-cloud' } + + } +]; + +describe('functionAllUsersPolicy', function () { + describe('run', function () { + it('should give unknown result if a function error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + null, + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no topics are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + null, + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cloud function has anonymous or public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function has anonymous or public access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [ + { + "bindings": [ + { "role": 'roles/editor', "members": ['allUsers'] }, + { + "role": 'roles/viewer', + "members": [ + 'allAuthenticatedUsers', + 'allUsers', + ] + } + ], + "parent": { + "name": "projects/my-test-project/locations/us-central1/functions/function-1" + }, + "etag": "CAE=", + "version": 1 + } + ], + null, + [functions[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if cloud function has anonymous or public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function does not have anonymous or public access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [ + { + "parent": { + "name": "projects/my-test-project/locations/us-central1/functions/function-2" + }, + "etag": "CAE=", + "version": 1 + } + ], + null, + [functions[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); diff --git a/plugins/google/cloudfunctions/functionDefaultServiceAccount.js b/plugins/google/cloudfunctions/functionDefaultServiceAccount.js new file mode 100644 index 000000000..2952ab99f --- /dev/null +++ b/plugins/google/cloudfunctions/functionDefaultServiceAccount.js @@ -0,0 +1,55 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function Default Service Account', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensures that Cloud Functions are not using the default service account.', + more_info: 'Using the default service account for Cloud Functions can lead to privilege escalation and overly permissive access. It is recommended to use a user-managed service account for each function in a project instead of the default service account. A managed service account allows more precise access control by granting only the necessary permissions through Identity and Access Management (IAM).', + link: 'https://cloud.google.com/functions/docs/securing/function-identity', + recommended_action: 'Ensure that no Cloud Functions are using the default service account.', + apis: ['functions:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (func.serviceAccountEmail && func.serviceAccountEmail.endsWith('@appspot.gserviceaccount.com')) { + helpers.addResult(results, 2, + 'Cloud Function is using default service account', region, func.name); + } else { + helpers.addResult(results, 0, + 'Cloud Function is not using default service account', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/functionDefaultServiceAccount.spec.js b/plugins/google/cloudfunctions/functionDefaultServiceAccount.spec.js new file mode 100644 index 000000000..ce36afe64 --- /dev/null +++ b/plugins/google/cloudfunctions/functionDefaultServiceAccount.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +var plugin = require('./functionDefaultServiceAccount'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL", + "serviceAccountEmail": "khulnasoft@appspot.gserviceaccount.com" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "labels": { 'deployment-tool': 'console-cloud' }, + "serviceAccountEmail": "khulnasoft-982@akhtar-dev-khulnasoft.iam.gserviceaccount.com" + + } +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('functionDefaultServiceAccount', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function is not using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is not using default service account'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is using service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is using default service account'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctions/httpTriggerRequireHttps.js b/plugins/google/cloudfunctions/httpTriggerRequireHttps.js new file mode 100644 index 000000000..94acdd31e --- /dev/null +++ b/plugins/google/cloudfunctions/httpTriggerRequireHttps.js @@ -0,0 +1,98 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'HTTP Trigger require HTTPS', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions are configured to require HTTPS for HTTP invocations.', + more_info: 'You can make your google cloud functions call secure by making sure that they require HTTPS.', + link: 'https://cloud.google.com/functions/docs/writing/http', + recommended_action: 'Ensure that your Google Cloud functions always require HTTPS.', + apis: ['functions:list'], + remediation_min_version: '202207282132', + remediation_description: 'All Google Cloud Functions will be configured to require HTTPS for HTTP invocations.', + apis_remediate: ['functions:list', 'projects:get'], + actions: {remediate:['CloudFunctionsService.UpdateFunction'], rollback:['CloudFunctionsService.UpdateFunction']}, + permissions: {remediate: ['cloudfunctions.functions.update'], rollback: ['cloudfunctions.functions.create ']}, + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction','functions.CloudFunctionsService.DeleteFunction', 'functions.CloudFunctionsService.CreateFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(funct => { + if (!funct.name) return; + + if (funct.httpsTrigger) { + if (funct.httpsTrigger.securityLevel && funct.httpsTrigger.securityLevel == 'SECURE_ALWAYS') { + helpers.addResult(results, 0, 'Cloud Function is configured to require HTTPS for HTTP invocations', + region, funct.name); + } else { + helpers.addResult(results, 2, 'Cloud Function is not configured to require HTTPS for HTTP invocations', region, funct.name); + } + } else { + helpers.addResult(results, 0, + 'Cloud Function trigger type is not HTTP', region, funct.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'httpTriggerRequireHttps'; + var baseUrl = 'https://cloudfunctions.googleapis.com/v1/{resource}?updateMask=httpsTrigger.securityLevel'; + var method = 'PATCH'; + var putCall = this.actions.remediate; + + // create the params necessary for the remediation + var body = { + httpsTrigger: { + securityLevel: 'SECURE_ALWAYS' + } + }; + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'httpTriggerRequireHttps': 'Disabled' + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } + +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/httpTriggerRequireHttps.spec.js b/plugins/google/cloudfunctions/httpTriggerRequireHttps.spec.js new file mode 100644 index 000000000..08cd026cb --- /dev/null +++ b/plugins/google/cloudfunctions/httpTriggerRequireHttps.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var plugin = require('./httpTriggerRequireHttps'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_OPTIONAL" + }, + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_ALWAYS" + }, + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + + } +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('httpTriggerRequireHttps', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function is configured to require https', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is configured to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is not configured to require https', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + }) +}); + diff --git a/plugins/google/cloudfunctions/ingressAllTrafficDisabled.js b/plugins/google/cloudfunctions/ingressAllTrafficDisabled.js new file mode 100644 index 000000000..1929c677f --- /dev/null +++ b/plugins/google/cloudfunctions/ingressAllTrafficDisabled.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Ingress All Traffic Disabled', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions are configured to allow only internal traffic or traffic from Cloud Load Balancer.', + more_info: 'You can secure your google cloud functions by implementing network based access control.', + link: 'https://cloud.google.com/functions/docs/securing/authenticating', + recommended_action: 'Ensure that your Google Cloud functions do not allow external traffic from the internet.', + apis: ['functions:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (func.ingressSettings && func.ingressSettings.toUpperCase() == 'ALLOW_ALL') { + helpers.addResult(results, 2, 'Cloud Function is configured to allow all traffic', region, func.name); + } else { + helpers.addResult(results, 0, 'Cloud Function is configured to allow only internal and CLB traffic', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/ingressAllTrafficDisabled.spec.js b/plugins/google/cloudfunctions/ingressAllTrafficDisabled.spec.js new file mode 100644 index 000000000..56b9a0b67 --- /dev/null +++ b/plugins/google/cloudfunctions/ingressAllTrafficDisabled.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var plugin = require('./ingressAllTrafficDisabled'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_OPTIONAL" + }, + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_OPTIONAL" + }, + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + + } +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('ingressAllTrafficDisabled', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function is configured to allow internal and GCLB traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is configured to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is configured to allow all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is configured to allow all traffic'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + }) +}); + diff --git a/plugins/google/cloudfunctions/serverlessVPCAccess.js b/plugins/google/cloudfunctions/serverlessVPCAccess.js new file mode 100644 index 000000000..1989311db --- /dev/null +++ b/plugins/google/cloudfunctions/serverlessVPCAccess.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function Serverless VPC Access', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'High', + description: 'Ensure CloudFunctions are allowed to access only VPC resources.', + more_info: 'Cloud Functions may require to connect directly to Compute Engine VM instances, Memorystore instances, Cloud SQL instances, and any other resources. It is a best practice to send requests to these resources using an internal IP address by connecting to VPC network using "Serverless VPC Access" configuration.', + link: 'https://cloud.google.com/functions/docs/networking/connecting-vpc#create-connector', + recommended_action: 'Ensure all cloud functions are using serverless VPC connectors.', + apis: ['functions:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (func.vpcConnector) { + if (func.vpcConnectorEgressSettings && func.vpcConnectorEgressSettings.toUpperCase() === 'ALL_TRAFFIC') { + helpers.addResult(results, 0, 'Cloud Function is using a VPC Connector to route all traffic', region, func.name); + } else { + helpers.addResult(results, 2, 'Cloud Function is using a VPC Connector for requests to private IPs only', region, func.name); + } + } else { + helpers.addResult(results, 2, 'Cloud Function is not configured with Serverless VPC Access', region, func.name); + } + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudfunctions/serverlessVPCAccess.spec.js b/plugins/google/cloudfunctions/serverlessVPCAccess.spec.js new file mode 100644 index 000000000..3dd9c873f --- /dev/null +++ b/plugins/google/cloudfunctions/serverlessVPCAccess.spec.js @@ -0,0 +1,145 @@ +var expect = require('chai').expect; +var plugin = require('./serverlessVPCAccess'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "versionId": "1", + "vpcConnector": 'projects/my-test-project/locations/us-central1/connectors/cloud-funct-connector', + "vpcConnectorEgressSettings": 'ALL_TRAFFIC', + "runtime": "nodejs14", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "labels": { 'deployment-tool': 'console-cloud' } + + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "status": "ACTIVE", + "entryPoint": "helloWorld", + "timeout": "60s", + "vpcConnector": 'projects/my-test-project/locations/us-central1/connectors/cloud-funct-connector', + "vpcConnectorEgressSettings": 'PRIVATE_RANGES_ONLY', + "availableMemoryMb": 256, + "updateTime": "2021-09-24T06:18:15.265Z", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + }, +]; + +const createCache = (list, err) => { + return { + functions: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('serverlessVPCAccess', function () { + describe('run', function () { + it('should give passing result if no cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud Functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if google cloud function is using vpc connector to route all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is using a VPC Connector to route all traffic'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is not using a vpc connector to route all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('requests to private IPs only'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if google cloud function is not configured with serverless vpc access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not configured with Serverless VPC Access'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js new file mode 100644 index 000000000..00cecfef9 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'HTTP Trigger Require HTTPS V2', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are configured to require HTTPS for HTTP invocations.', + more_info: 'You can make your Google Cloud Functions V2 calls secure by making sure that they require HTTPS.', + link: 'https://cloud.google.com/functions/docs/writing/http', + recommended_action: 'Ensure that your Google Cloud Functions V2 always require HTTPS.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction','functions.CloudFunctionsService.DeleteFunction', 'functions.CloudFunctionsService.CreateFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(funct => { + if (!funct.name) return; + + if (!funct.environment || funct.environment !== 'GEN_2') return; + + let serviceConfig = funct.serviceConfig || {}; + + if (serviceConfig.uri) { + if (serviceConfig.securityLevel && serviceConfig.securityLevel == 'SECURE_ALWAYS') { + helpers.addResult(results, 0, + 'Cloud Function is configured to require HTTPS for HTTP invocations', region, funct.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function is not configured to require HTTPS for HTTP invocations', region, funct.name); + } + } else { + helpers.addResult(results, 0, + 'Cloud Function trigger type is not HTTP', region, funct.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } + +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js new file mode 100644 index 000000000..0d7d2b42f --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js @@ -0,0 +1,177 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2HttpsOnly'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "uri": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_OPTIONAL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "uri": "https://us-central1-my-test-project.cloudfunctions.net/function-2", + "securityLevel": "SECURE_ALWAYS" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "handleEvent" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-4", + "securityLevel": "SECURE_OPTIONAL" + } + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('httpTriggerRequireHttps', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is configured to require HTTPS for HTTP invocations', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is configured to require HTTPS for HTTP invocations'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is not configured to require HTTPS for HTTP invocations', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is not configured to require HTTPS for HTTP invocations'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function trigger type is not HTTP', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function trigger type is not HTTP'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js new file mode 100644 index 000000000..78219be8b --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Ingress All Traffic Disabled V2', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are configured to allow only internal traffic or traffic from Cloud Load Balancer.', + more_info: 'You can secure your Google Cloud Functions V2 by implementing network-based access control.', + link: 'https://cloud.google.com/functions/docs/securing/authenticating', + recommended_action: 'Ensure that your Google Cloud Functions V2 do not allow external traffic from the internet.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let ingressSettings = func.serviceConfig && func.serviceConfig.ingressSettings + ? func.serviceConfig.ingressSettings + : null; + + if (ingressSettings && ingressSettings.toUpperCase() == 'ALLOW_ALL') { + helpers.addResult(results, 2, + 'Cloud Function is configured to allow all traffic', region, func.name); + } else if (ingressSettings) { + helpers.addResult(results, 0, + 'Cloud Function is configured to allow only internal and CLB traffic', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have ingress settings configured', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js new file mode 100644 index 000000000..13168f81e --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2IngressSettings'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('ingressAllTrafficDisabled', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is configured to allow only internal and CLB traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is configured to allow only internal and CLB traffic'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is configured to allow all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is configured to allow all traffic'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have ingress settings configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have ingress settings configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js new file mode 100644 index 000000000..3d7bdd644 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Labels Added', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Low', + description: 'Ensure that all Cloud Functions V2 have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/functions/docs/configuring', + recommended_action: 'Ensure labels are added to all Cloud Functions V2.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + if (func.labels && Object.keys(func.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(func.labels).length} labels found for Cloud Function`, region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have any labels', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js new file mode 100644 index 000000000..2bf55b1c3 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2LabelsAdded'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud', 'env': 'production' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionLabelsAdded', function () { + describe('run', function () { + it('should give passing result if no Google Cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Cloud Function'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js new file mode 100644 index 000000000..424fd12b6 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js @@ -0,0 +1,129 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Old Runtimes', + category: 'Cloud Functions', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are not using deprecated runtime versions.', + more_info: 'Cloud Functions V2 runtimes should be kept current with recent versions of the underlying codebase. It is recommended to update to the latest supported versions to avoid potential security risks and ensure compatibility.', + link: 'https://cloud.google.com/functions/docs/concepts/execution-environment', + recommended_action: 'Modify Cloud Functions V2 to use latest versions.', + apis: ['functionsv2:list'], + settings: { + function_runtime_fail: { + name: 'Cloud Function V2 Runtime Fail', + description: 'Return a failing result for Cloud Function V2 runtime before this number of days for their end of life date.', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 0 + } + }, + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + function_runtime_fail: parseInt(settings.function_runtime_fail || this.settings.function_runtime_fail.default) + }; + + var deprecatedRuntimes = [ + { 'id':'nodejs10', 'name': 'Node.js 10.x', 'endOfLifeDate': '2021-07-30' }, + { 'id':'nodejs12', 'name': 'Node.js 12', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs14', 'name': 'Node.js 14', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs16', 'name': 'Node.js 16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs18', 'name': 'Node.js 18', 'endOfLifeDate': '2025-04-30' }, + { 'id':'nodejs20', 'name': 'Node.js 20', 'endOfLifeDate': '2026-04-30' }, + { 'id':'dotnet6', 'name': '.Net 6', 'endOfLifeDate': '2024-11-12' }, + { 'id':'dotnet7', 'name': '.Net 7', 'endOfLifeDate': '2024-05-14' }, + { 'id':'dotnet3', 'name': '.Net Core 3', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python27', 'name': 'Python 2.7', 'endOfLifeDate': '2021-07-15' }, + { 'id':'python36', 'name': 'Python 3.6', 'endOfLifeDate': '2022-07-18' }, + { 'id':'python37', 'name': 'Python 3.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python38', 'name': 'Python 3.8', 'endOfLifeDate': '2024-10-14' }, + { 'id':'python39', 'name': 'Python 3.9', 'endOfLifeDate': '2025-10-05' }, + { 'id':'python310', 'name': 'Python 3.10', 'endOfLifeDate': '2026-10-04' }, + { 'id':'python311', 'name': 'Python 3.11', 'endOfLifeDate': '2027-10-24' }, + { 'id':'python312', 'name': 'Python 3.12', 'endOfLifeDate': '2028-10-02' }, + { 'id':'ruby25', 'name': 'Ruby 2.5', 'endOfLifeDate': '2021-07-30' }, + { 'id':'ruby27', 'name': 'Ruby 2.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'ruby30', 'name': 'Ruby 3.0', 'endOfLifeDate': '2024-03-31' }, + { 'id':'ruby32', 'name': 'Ruby 3.2', 'endOfLifeDate': '2026-03-31' }, + { 'id':'go121', 'name': 'Go 1.21', 'endOfLifeDate': '2024-05-01' }, + { 'id':'go119', 'name': 'Go 1.19', 'endOfLifeDate': '2024-04-30' }, + { 'id':'go118', 'name': 'Go 1.18', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go116', 'name': 'Go 1.16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go113', 'name': 'Go 1.13', 'endOfLifeDate': '2024-01-30' }, + { 'id':'java8', 'name': 'Java 8', 'endOfLifeDate': '2024-01-08' }, + { 'id':'java11', 'name': 'Java 11', 'endOfLifeDate': '2024-10-01' }, + { 'id':'java17', 'name': 'Java 17', 'endOfLifeDate': '2027-10-01' }, + { 'id':'php74', 'name': 'PHP 7.4', 'endOfLifeDate': '2024-01-30' }, + { 'id':'php81', 'name': 'PHP 8.1', 'endOfLifeDate': '2024-11-25' }, + { 'id':'php82', 'name': 'PHP 8.2', 'endOfLifeDate': '2025-12-08' }, + ]; + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let buildConfig = func.buildConfig || {}; + let runtime = buildConfig.runtime; + + if (!runtime) { + helpers.addResult(results, 2, + 'Cloud Function does not have a runtime configured', region, func.name); + return; + } + + var deprecatedRuntime = deprecatedRuntimes.filter((d) => { + return d.id == runtime; + }); + + var version = runtime; + var runtimeDeprecationDate = (deprecatedRuntime && deprecatedRuntime.length && deprecatedRuntime[0].endOfLifeDate) ? Date.parse(deprecatedRuntime[0].endOfLifeDate) : null; + let today = new Date(); + today = Date.parse(`${today.getFullYear()}-${today.getMonth()+1}-${today.getDate()}`); + var difference = runtimeDeprecationDate? Math.round((runtimeDeprecationDate - today)/(1000 * 3600 * 24)): null; + if (runtimeDeprecationDate && today > runtimeDeprecationDate) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + deprecatedRuntime[0].name + ' which was deprecated on: ' + deprecatedRuntime[0].endOfLifeDate, + region, func.name); + } else if (difference && config.function_runtime_fail >= difference) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + version + ' which is deprecating in ' + Math.abs(difference) + ' days', + region, func.name); + } else { + helpers.addResult(results, 0, + 'Cloud Function is running the current version: ' + version, + region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js new file mode 100644 index 000000000..8aa995313 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2OldRuntime'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs14", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "python312", + "entryPoint": "main" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionOldRuntime', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is using latest runtime version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is running the current version: '); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using deprecated runtime version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which was deprecated on'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have a runtime configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have a runtime configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js new file mode 100644 index 000000000..075a94fb4 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Serverless VPC Access', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'High', + description: 'Ensure that Cloud Functions V2 are allowed to access only VPC resources.', + more_info: 'Cloud Functions V2 may require to connect directly to Compute Engine VM instances, Memorystore instances, Cloud SQL instances, and any other resources. It is a best practice to send requests to these resources using an internal IP address by connecting to VPC network using "Serverless VPC Access" configuration.', + link: 'https://cloud.google.com/functions/docs/networking/connecting-vpc#create-connector', + recommended_action: 'Ensure all Cloud Functions V2 are using serverless VPC connectors.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let serviceConfig = func.serviceConfig || {}; + let vpcConnector = serviceConfig.vpcConnector; + let vpcConnectorEgressSettings = serviceConfig.vpcConnectorEgressSettings; + + if (vpcConnector) { + if (vpcConnectorEgressSettings && vpcConnectorEgressSettings.toUpperCase() === 'ALL_TRAFFIC') { + helpers.addResult(results, 0, + 'Cloud Function is using a VPC Connector to route all traffic', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function is using a VPC Connector for requests to private IPs only', region, func.name); + } + } else { + helpers.addResult(results, 2, + 'Cloud Function is not configured with Serverless VPC Access', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js new file mode 100644 index 000000000..13ed92c6a --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2VPCConnector'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector", + "vpcConnectorEgressSettings": "ALL_TRAFFIC" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector", + "vpcConnectorEgressSettings": "PRIVATE_RANGES_ONLY" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('serverlessVPCAccess', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is using a VPC Connector to route all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is using a VPC Connector to route all traffic'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using a VPC Connector for requests to private IPs only', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is using a VPC Connector for requests to private IPs only'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is not configured with Serverless VPC Access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is not configured with Serverless VPC Access'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js new file mode 100644 index 000000000..cceeadedb --- /dev/null +++ b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Default Service Account', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are not using the default service account.', + more_info: 'Using the default service account for Cloud Functions V2 can lead to privilege escalation and overly permissive access. It is recommended to use a user-managed service account for each function in a project instead of the default service account. A managed service account allows more precise access control by granting only the necessary permissions through Identity and Access Management (IAM).', + link: 'https://cloud.google.com/functions/docs/securing/function-identity', + recommended_action: 'Ensure that no Cloud Functions V2 are using the default service account.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let serviceAccountEmail = func.serviceConfig && func.serviceConfig.serviceAccountEmail + ? func.serviceConfig.serviceAccountEmail + : null; + + if (serviceAccountEmail && serviceAccountEmail.endsWith('@appspot.gserviceaccount.com')) { + helpers.addResult(results, 2, + 'Cloud Function is using default service account', region, func.name); + } else if (serviceAccountEmail) { + helpers.addResult(results, 0, + 'Cloud Function is not using default service account', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have a service account configured', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js new file mode 100644 index 000000000..d27f40ab9 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +var plugin = require('./functionV2DefaultServiceAccount'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "khulnasoft@appspot.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "custom-sa@my-test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "ingressSettings": "ALLOW_INTERNAL_ONLY" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "serviceAccountEmail": "khulnasoft@appspot.gserviceaccount.com" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('functionDefaultServiceAccount', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is not using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is not using default service account'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is using default service account'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have a service account configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have a service account configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js b/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js new file mode 100644 index 000000000..6c995d259 --- /dev/null +++ b/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js @@ -0,0 +1,49 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Compute Allowed External IPs', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Define Allowed External IPs for VM Instances" constraint policy is enabled at the GCP organization level.', + more_info: 'To reduce exposure to the internet, make sure that not all VM instances are allowed to use external IP addresses.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Define Allowed External IPs for VM Instances" constraint is enforced to allow you to define the VM instances that are allowed to use external IP addresses.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.vmExternalIpAccess', 'listPolicy', true, false, 'Define Allowed External IPs for VM Instances', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.spec.js b/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.spec.js new file mode 100644 index 000000000..8229e3a76 --- /dev/null +++ b/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./computeAllowedExternalIPs'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('computeAllowedExternalIPs', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if external IPs are not allowed for all VM instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.vmExternalIpAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-test-project/zones/us-central1-a/instances/instance-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if external IPs are allowed for all VM instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.vmExternalIpAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.js b/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.js new file mode 100644 index 000000000..3b128874e --- /dev/null +++ b/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Detailed Audit Logging Mode', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Detailed Audit Logging Mode" policy is configured at the GCP organization level.', + more_info: 'Detailed Audit Logging Mode is highly encouraged in coordination with Bucket Lock when seeking compliances such as SEC Rule 17a-4(f), CFTC Rule 1.31(c)-(d), and FINRA Rule 4511(c).', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Detailed Audit Logging Mode" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Detailed Audit Logging Mode" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'gcp.detailedAuditLoggingMode', 'booleanPolicy', true, false, 'Detailed Audit Logging Mode', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'detailedAuditLoggingMode'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/gcp.detailedAuditLoggingMode', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.spec.js b/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.spec.js new file mode 100644 index 000000000..628b90426 --- /dev/null +++ b/plugins/google/cloudresourcemanager/detailedAuditLoggingMode.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./detailedAuditLoggingMode'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('detailedAuditLoggingMode', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if detailed audit logging mode is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/gcp.detailedAuditLoggingMode", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if detailed audit logging mode is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/gcp.detailedAuditLoggingMode", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js b/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js new file mode 100644 index 000000000..868f5b750 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js @@ -0,0 +1,77 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Automatic IAM Grants', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Automatic IAM Grants for Default Service Accounts" policy is enforced at the organization level.', + more_info: 'By default, service accounts get the editor role when created. To improve access security, disable the automatic IAM role grant.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Automatic IAM Grants for Default Service Accounts" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable Automatic IAM Grants for Default Service Accounts" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'iam.automaticIamGrantsForDefaultServiceAccounts', 'booleanPolicy', true, false, 'Disable Automatic IAM Grants for Default Service Accounts', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableAutomaticIAMGrants'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/iam.automaticIamGrantsForDefaultServiceAccounts', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.spec.js b/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.spec.js new file mode 100644 index 000000000..1de54b521 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var plugin = require('./disableAutomaticIAMGrants'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableAutomaticIAMGrants', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if automatic IAM Grant is disabled for service accounts', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.automaticIamGrantsForDefaultServiceAccounts", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if automatic IAM Grant is not disabled for service accounts', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.automaticIamGrantsForDefaultServiceAccounts", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableDefaultEncryption.js b/plugins/google/cloudresourcemanager/disableDefaultEncryption.js new file mode 100644 index 000000000..51bbb53e7 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableDefaultEncryption.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Default Encryption Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict Default Google-Managed Encryption for Cloud SQL Instances" is enforced on the GCP organization level.', + more_info: 'Google-managed encryption keys for Cloud SQL database instances to enforce the use of Customer-Managed Keys (CMKs) in order to have complete control over database encryption/decryption process.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict Default Google-Managed Encryption for Cloud SQL Instances" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Restrict Default Google-Managed Encryption for Cloud SQL Instances" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'sql.disableDefaultEncryptionCreation', 'booleanPolicy', true, false, 'Restrict Default Google-Managed Encryption for Cloud SQL Instances', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableDefaultEncryption'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/sql.disableDefaultEncryptionCreation', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; diff --git a/plugins/google/cloudresourcemanager/disableDefaultEncryption.spec.js b/plugins/google/cloudresourcemanager/disableDefaultEncryption.spec.js new file mode 100644 index 000000000..a1dd4e40f --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableDefaultEncryption.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableDefaultEncryption'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableDefaultEncryption', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if default encryption is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/sql.disableDefaultEncryptionCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if default encryption is not disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/sql.disableDefaultEncryptionCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableGuestAttributes.js b/plugins/google/cloudresourcemanager/disableGuestAttributes.js new file mode 100644 index 000000000..7cf214e52 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableGuestAttributes.js @@ -0,0 +1,77 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Guest Attributes', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Guest Attributes of Compute Engine Metadata" constraint policy is enabled at the GCP organization level.', + more_info: 'Guest attributes are used for VM instance configuration. For security reasons, ensure that users cannot configure guest attributes for your VM instances.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Guest Attributes of Compute Engine Metadata" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable Guest Attributes of Compute Engine Metadata" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.disableGuestAttributesAccess', 'booleanPolicy', true, false, 'Disable Guest Attributes of Compute Engine Metadata', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableGuestAttributes'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/compute.disableGuestAttributesAccess', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableGuestAttributes.spec.js b/plugins/google/cloudresourcemanager/disableGuestAttributes.spec.js new file mode 100644 index 000000000..f1b54606d --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableGuestAttributes.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableGuestAttributes'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableGuestAttributes', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if guest Attributes are not accessible by users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.disableGuestAttributesAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if guest Attributes are accessible by users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.disableGuestAttributesAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.js b/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.js new file mode 100644 index 000000000..d88c46836 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Workload Identity Cluster Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Workload Identity Cluster Creation" policy is enforced at the GCP organization level.', + more_info: 'To have a better control over service account access, make sure that GKE clusters have Workload Identity feature disabled at the time of creation.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Workload Identity Cluster Creation" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable Workload Identity Cluster Creation" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'iam.disableWorkloadIdentityClusterCreation', 'booleanPolicy', true, false, 'Disable Workload Identity Cluster Creation', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableIdentityClusterCreation'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/iam.disableWorkloadIdentityClusterCreation', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.spec.js b/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.spec.js new file mode 100644 index 000000000..7150b3e58 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableIdentityClusterCreation.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableIdentityClusterCreation'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableIdentityClusterCreation', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if workload identity cluster creation is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableWorkloadIdentityClusterCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if workload identity cluster creation is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableWorkloadIdentityClusterCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableKeyCreation.js b/plugins/google/cloudresourcemanager/disableKeyCreation.js new file mode 100644 index 000000000..d58ddc3dc --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableKeyCreation.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Service Account Key Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Service Account Key Creation" policy is enforced at the GCP organization level.', + more_info: 'User-managed keys can impose a security risk if they are not handled correctly. To minimize the risk, enable user-managed keys in only specific locations.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Service Account Key Creation" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable Service Account Key Creation" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'iam.disableServiceAccountKeyCreation', 'booleanPolicy', true, false, 'Disable Service Account Key Creation', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableKeyCreation'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/iam.disableServiceAccountKeyCreation', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableKeyCreation.spec.js b/plugins/google/cloudresourcemanager/disableKeyCreation.spec.js new file mode 100644 index 000000000..62e9b9b7e --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableKeyCreation.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableKeyCreation'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableKeyCreation', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if service account key creation is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountKeyCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if service account key creation is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountKeyCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableKeyUpload.js b/plugins/google/cloudresourcemanager/disableKeyUpload.js new file mode 100644 index 000000000..fef97124a --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableKeyUpload.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Service Account Key Upload', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Service Account Key Upload" policy is enforced at the GCP organization level.', + more_info: 'User-managed keys can impose a security risk if they are not handled correctly. To minimize the risk, enable user-managed keys in only specific locations.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Service Account Key Upload" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable Service Account Key Upload" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'iam.disableServiceAccountKeyUpload', 'booleanPolicy', true, false, 'Disable Service Account Key Upload', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableKeyUpload'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/iam.disableServiceAccountKeyUpload', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableKeyUpload.spec.js b/plugins/google/cloudresourcemanager/disableKeyUpload.spec.js new file mode 100644 index 000000000..baebe1580 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableKeyUpload.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableKeyUpload'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableKeyUpload', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if service account key upload is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountKeyUpload", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if service account key upload is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountKeyUpload", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableSerialPortAccess.js b/plugins/google/cloudresourcemanager/disableSerialPortAccess.js new file mode 100644 index 000000000..cbb31902d --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableSerialPortAccess.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Serial Port Access', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable VM serial port access" policy is enforced at the GCP organization level.', + more_info: 'For security purposes, ensure that serial port access to your VM instances is disabled.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable VM serial port access" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Disable VM serial port access" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.disableSerialPortAccess', 'booleanPolicy', true, false, 'Disable VM serial port access', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'disableSerialPortAccess'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/compute.disableSerialPortAccess', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableSerialPortAccess.spec.js b/plugins/google/cloudresourcemanager/disableSerialPortAccess.spec.js new file mode 100644 index 000000000..4b16a6663 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableSerialPortAccess.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./disableSerialPortAccess'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableSerialPortAccess', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if serial port access is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.disableSerialPortAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if serial port access is not disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.disableSerialPortAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableServiceAccountCreation.js b/plugins/google/cloudresourcemanager/disableServiceAccountCreation.js new file mode 100644 index 000000000..a0fcf77c7 --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableServiceAccountCreation.js @@ -0,0 +1,39 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable Service Account Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Disable Service Account Creation" policy is enforced at the GCP organization level.', + more_info: 'Enforcing the "Disable Service Account Creation" policy allows you to centrally manage your service accounts and reduces the chances of compromised service accounts being used to access your GCP resources.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Disable Service Account Creation" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'iam.disableServiceAccountCreation', 'booleanPolicy', true, false, 'Disable Service Account Creation', results); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableServiceAccountCreation.spec.js b/plugins/google/cloudresourcemanager/disableServiceAccountCreation.spec.js new file mode 100644 index 000000000..554e627ff --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableServiceAccountCreation.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var plugin = require('./disableServiceAccountCreation'); + +const createCache = (err, data) => { + return { + organizations: { + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableServiceAccountCreation', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if service account creation is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if service account creation is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/iam.disableServiceAccountCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableVMIPForwarding.js b/plugins/google/cloudresourcemanager/disableVMIPForwarding.js new file mode 100644 index 000000000..3e14a8bee --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableVMIPForwarding.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disable VM IP Forwarding', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict VM IP Forwarding" constraint policy is enforced at the GCP organization level.', + more_info: 'Enforcing the "Restrict VM IP Forwarding" constraint allows you to define the VM instances that can ensble IP forwarding.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict VM IP Forwarding" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.vmCanIpForward', 'listPolicy', true, false, 'Restrict VM IP Forwarding', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/disableVMIPForwarding.spec.js b/plugins/google/cloudresourcemanager/disableVMIPForwarding.spec.js new file mode 100644 index 000000000..08339594d --- /dev/null +++ b/plugins/google/cloudresourcemanager/disableVMIPForwarding.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./disableVMIPForwarding'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('disableVMIPForwarding', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict VM IP forwarding is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.vmCanIpForward", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-test-project/zones/us-central1-a/instances/instance-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict VM IP forwarding is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.vmCanIpForward", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/essentialContactsConfigured.js b/plugins/google/cloudresourcemanager/essentialContactsConfigured.js new file mode 100644 index 000000000..1ed512350 --- /dev/null +++ b/plugins/google/cloudresourcemanager/essentialContactsConfigured.js @@ -0,0 +1,49 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Essential Contacts Configured', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure Essential Contacts is configured to designate email addresses for Google Cloud services to notify of important technical or security information.', + more_info: 'Many Google Cloud services, such as Cloud Billing, send out notifications to share important information with Google Cloud users. By default, these notifications are sent to members with certain Identity and Access Management (IAM) roles. With Essential Contacts, you can customize who receives notifications by providing your own list of contacts.', + link: 'https://cloud.google.com/resource-manager/docs/managing-notification-contacts', + recommended_action: 'Ensure Essential Contacts is configured for organization.', + apis: ['organizations:list', 'organizations:essentialContacts'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let essentialContacts = helpers.addSource(cache, source, + ['organizations', 'essentialContacts', 'global']); + + if (!essentialContacts) return callback(null, results, source); + + if (essentialContacts.err || !essentialContacts.data) { + helpers.addResult(results, 3, 'Unable to query essential contacts for organization', 'global', null, null, essentialContacts.err); + return callback(null, results, source); + } + + if (essentialContacts.data && essentialContacts.data.length && essentialContacts.data[0] + && essentialContacts.data[0].contacts && essentialContacts.data[0].contacts.length) { + helpers.addResult(results, 0, 'Essential Contacts is configured for organization', 'global', organization); + } else { + helpers.addResult(results, 2, 'Essential Contacts is not configured for organization', 'global', organization); + } + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/essentialContactsConfigured.spec.js b/plugins/google/cloudresourcemanager/essentialContactsConfigured.spec.js new file mode 100644 index 000000000..f0fb2acb5 --- /dev/null +++ b/plugins/google/cloudresourcemanager/essentialContactsConfigured.spec.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var plugin = require('./essentialContactsConfigured'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + essentialContacts: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('essentialContactsConfigured', function () { + describe('run', function () { + + it('should give unknow if an error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query essential contacts for organization'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if essential contacts is configured for organization', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is configured'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "contacts": [ + { + "name": "organizations/123456/contacts/0", + "email": "mycontact@gmail.com", + "notificationCategorySubscriptions": [ + "SECURITY" + ], + "languageTag": "en-US", + "validationState": "VALID", + "validateTime": "2022-12-10T10:46:33.694820Z" + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if essential contacts is not configured for organization', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not configured'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + {} + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/locationBasedRestriction.js b/plugins/google/cloudresourcemanager/locationBasedRestriction.js new file mode 100644 index 000000000..19132a315 --- /dev/null +++ b/plugins/google/cloudresourcemanager/locationBasedRestriction.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Location-Based Service Restriction', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Resource Location Restriction" is enforced on the GCP organization level.', + more_info: 'Enforcing the "Resource Location Restriction" constraint allows you to define the locations where your cloud resources can be created.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Resource Location Restriction" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'gcp.resourceLocations', 'listPolicy', true, false, 'Resource Location Restriction', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/locationBasedRestriction.spec.js b/plugins/google/cloudresourcemanager/locationBasedRestriction.spec.js new file mode 100644 index 000000000..e86a96a54 --- /dev/null +++ b/plugins/google/cloudresourcemanager/locationBasedRestriction.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./locationBasedRestriction'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('locationBasedRestriction', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if location based restriction is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/gcp.resourceLocations", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "us-east1-locations" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if location based restriction is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/gcp.resourceLocations", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/requireOsLogin.js b/plugins/google/cloudresourcemanager/requireOsLogin.js new file mode 100644 index 000000000..54f84f44d --- /dev/null +++ b/plugins/google/cloudresourcemanager/requireOsLogin.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Enforce Require OS Login', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Require OS Login" policy is enforced at the GCP organization level.', + more_info: 'Enabling OS Login at project level will ensure that the SSH keys being used to access your VM instances are mapped with Cloud IAM users.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Require OS Login" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Require OS Login" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.requireOsLogin', 'booleanPolicy', true, false, 'Require OS Login', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'requireOsLogin'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/compute.requireOsLogin', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/requireOsLogin.spec.js b/plugins/google/cloudresourcemanager/requireOsLogin.spec.js new file mode 100644 index 000000000..e5c464b91 --- /dev/null +++ b/plugins/google/cloudresourcemanager/requireOsLogin.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./requireOsLogin'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('requireOSLogin', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if require os login is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.requireOsLogin", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if require os login is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.requireOsLogin", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.js b/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.js new file mode 100644 index 000000000..45bdc9de8 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Enforce Restrict Authorized Networks', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict Authorized Networks on Cloud SQL instances" policy is enforced at the GCP organization level.', + more_info: 'Enforcing "Restrict Authorized Networks on Cloud SQL instances" organization policy, restricts adding authorized networks for unproxied database access to Cloud SQL instances.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict Authorized Networks on Cloud SQL instances" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Restrict Authorized Networks on Cloud SQL instances" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'sql.restrictAuthorizedNetworks', 'booleanPolicy', true, false, 'Restrict Authorized Networks on Cloud SQL instances', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'restrictAuthorizedNetworks'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/sql.restrictAuthorizedNetworks', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.spec.js b/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.spec.js new file mode 100644 index 000000000..830ce56b2 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictAuthorizedNetworks.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./restrictAuthorizedNetworks'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('restrictAuthorizedNetworks', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict authorized networks constraint is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/sql.restrictAuthorizedNetworks", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict authorized networks constraint is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/sql.restrictAuthorizedNetworks", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.js b/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.js new file mode 100644 index 000000000..57c126d7a --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Restrict Load Balancer Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict Load Balancer Creation for Types" is enforced on the GCP organization level.', + more_info: 'Enforcing the "Restrict Load Balancer Creation for Types" constraint allows you to control which type of load balancers can be created within your organization.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict Load Balancer Creation for Types" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.restrictLoadBalancerCreationForTypes', 'listPolicy', true, false, 'Restrict Load Balancer Creation for Types', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.spec.js b/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.spec.js new file mode 100644 index 000000000..8b6d94afa --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictLoadBalancerCreation.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./restrictLoadBalancerCreation'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('restrictLoadBalancerCreation', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict load balancer types is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictLoadBalancerCreationForTypes", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "INTERNAL_TCP_UDP", "INTERNAL_HTTP_HTTPS" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict load balancer types is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictLoadBalancerCreationForTypes", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.js b/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.js new file mode 100644 index 000000000..3cdd37761 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Restrict Shared VPC Subnetworks', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict Shared VPC Subnetworks" is enforced on the GCP organization level.', + more_info: 'Enforcing the "Restrict Shared VPC Subnetworks" constraint allows you to define which VPC Shared Subnetworks your resources can use within your GCP organization.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict Shared VPC Subnetworks" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.restrictSharedVpcSubnetworks', 'listPolicy', true, false, 'Restrict Shared VPC Subnetworks', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.spec.js b/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.spec.js new file mode 100644 index 000000000..0e4a46f6d --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictSharedVPCSubnetworks.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./restrictSharedVPCSubnetworks'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('restrictSharedVPCSubnetworks', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict shared vpc subnetworks is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictSharedVpcSubnetworks", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-project/regions/us-central1/subnetworks/subnetwork-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict shared vpc subnetworks is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictSharedVpcSubnetworks", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictVPCPeering.js b/plugins/google/cloudresourcemanager/restrictVPCPeering.js new file mode 100644 index 000000000..8ed82e360 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictVPCPeering.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Restrict VPC Peering', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict VPC Peering" is enforced on the GCP organization level.', + more_info: 'Enforcing the "Restrict VPC Peering" constraint allows you to define which VPC Networks are allowed to be peered with other networks.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict VPC Peering" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.restrictVpcPeering', 'listPolicy', true, false, 'Restrict VPC Peering', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictVPCPeering.spec.js b/plugins/google/cloudresourcemanager/restrictVPCPeering.spec.js new file mode 100644 index 000000000..58ecfc032 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictVPCPeering.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./restrictVPCPeering'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('restrictVPCPeering', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict vpc peering is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictVpcPeering", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-project/regions/us-central1/vpcnetworks/network-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict vpc peering is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictVpcPeering", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.js b/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.js new file mode 100644 index 000000000..8c6a3b6db --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Restrict VPN Peer IPs', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Restrict VPN Peer IPs" is enforced on the GCP organization level.', + more_info: 'Enforcing the "Restrict VPN Peer IPs" constraint allows you to control the IP addresses which can be configured as VPN Peers.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Restrict VPN Peer IPs" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.restrictVpnPeerIPs', 'listPolicy', true, false, 'Restrict VPN Peer IPs', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.spec.js b/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.spec.js new file mode 100644 index 000000000..e8eba5828 --- /dev/null +++ b/plugins/google/cloudresourcemanager/restrictVPNPeerIPs.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./restrictVPNPeerIPs'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('restrictVPNPeerIPs', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if restrict vpn peer IPs is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictVpnPeerIPs", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-project/regions/us-central1/vpcnetworks/network-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if restrict vpn peer IPs is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.restrictVpnPeerIPs", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.js b/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.js new file mode 100644 index 000000000..801ca844c --- /dev/null +++ b/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.js @@ -0,0 +1,77 @@ + +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Skip Default Network Creation', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Skip Default Network Creation" constraint policy is enforces at the GCP organization level.', + more_info: 'Enforcing the "Skip Default Network Creation" disables the creation of default VPC network on project creation which is recommended if you want to keep some parts of your network private.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Skip Default Network Creation" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Skip Default Network Creation" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.skipDefaultNetworkCreation', 'booleanPolicy', true, false, 'Skip Default Network Creation', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'skipDefaultNetworkCreation'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/compute.skipDefaultNetworkCreation', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.spec.js b/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.spec.js new file mode 100644 index 000000000..fa5c38b65 --- /dev/null +++ b/plugins/google/cloudresourcemanager/skipDefaultNetworkCreation.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./skipDefaultNetworkCreation'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('skipDefaultNetworkCreation', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if skip default network creation is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.skipDefaultNetworkCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if skip default network creation is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.skipDefaultNetworkCreation", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/trustedImageProjects.js b/plugins/google/cloudresourcemanager/trustedImageProjects.js new file mode 100644 index 000000000..8fc0074cd --- /dev/null +++ b/plugins/google/cloudresourcemanager/trustedImageProjects.js @@ -0,0 +1,51 @@ + +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Trusted Image Projects', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Define Trusted Image Projects" constraint policy is enforces at the GCP organization level.', + more_info: 'Enforcing the "Define Trusted Image Projects" allows you to restrict disk image access and ensure that your project members can only create boot disks from trusted images.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Define Trusted Image Projects" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'compute.trustedImageProjects', 'listPolicy', true, false, 'Define Trusted Image Projects', results, organization); + + return callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/trustedImageProjects.spec.js b/plugins/google/cloudresourcemanager/trustedImageProjects.spec.js new file mode 100644 index 000000000..073ec524c --- /dev/null +++ b/plugins/google/cloudresourcemanager/trustedImageProjects.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./trustedImageProjects'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('trustedImageProjects', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if trusted image projects is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.trustedImageProjects", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allowedValues": [ + "projects/my-project/regions/us-central1/images/image-1" + ] + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if trusted image projects is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/compute.trustedImageProjects", + "updateTime": "2021-10-19T20:42:37.813762Z", + "listPolicy": { + "allValues": "ALLOW" + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.js b/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.js new file mode 100644 index 000000000..5bc9083d8 --- /dev/null +++ b/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Enforce Uniform Bucket-Level Access', + category: 'Resource Manager', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Determine if "Enforce uniform bucket-level access" policy is enabled at the GCP organization level.', + more_info: 'Enforcing Uniform Bucket Level Access ensures that access is granted exclusively through Cloud IAM service which is more efficient and secure.', + link: 'https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints', + recommended_action: 'Ensure that "Enforce uniform bucket-level access" constraint is enforced at the organization level.', + apis: ['organizations:list', 'organizations:listOrgPolicies'], + remediation_min_version: '202207280432', + remediation_description: 'The "Enforce uniform bucket-level access" constraint will be enforced at the organization level.', + apis_remediate: ['organizations:list', 'organizations:listOrgPolicies'], + actions: {remediate:['SetOrgPolicy'], rollback:['SetOrgPolicy']}, + permissions: {remediate: ['orgpolicy.policy.set'], rollback: ['orgpolicy.policy.set']}, + realtime_triggers: ['SetOrgPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let organizations = helpers.addSource(cache, source, + ['organizations','list', 'global']); + + if (!organizations || organizations.err || !organizations.data || !organizations.data.length) { + helpers.addResult(results, 3, + 'Unable to query for organizations: ' + helpers.addError(organizations), 'global', null, null, (organizations) ? organizations.err : null); + return callback(null, results, source); + } + + var organization = organizations.data[0].name; + + let listOrgPolicies = helpers.addSource(cache, source, + ['organizations', 'listOrgPolicies', 'global']); + + if (!listOrgPolicies) return callback(null, results, source); + + if (listOrgPolicies.err || !listOrgPolicies.data) { + helpers.addResult(results, 3, 'Unable to query organization policies', 'global', null, null, listOrgPolicies.err); + return callback(null, results, source); + } + + if (!listOrgPolicies.data.length) { + helpers.addResult(results, 0, 'No organization policies found', 'global'); + return callback(null, results, source); + } + let orgPolicies = listOrgPolicies.data[0]; + + helpers.checkOrgPolicy(orgPolicies, 'storage.uniformBucketLevelAccess', 'booleanPolicy', true, false, 'Enforce uniform bucket-level access', results, organization); + + return callback(null, results, source); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'uniformBucketLevelAccess'; + + var putCall = this.actions.remediate; + + helpers.remediateOrgPolicy(config, 'constraints/storage.uniformBucketLevelAccess', 'booleanPolicy', true, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.spec.js b/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.spec.js new file mode 100644 index 000000000..8ba1389c0 --- /dev/null +++ b/plugins/google/cloudresourcemanager/uniformBucketLevelAccess.spec.js @@ -0,0 +1,127 @@ +var expect = require('chai').expect; +var plugin = require('./uniformBucketLevelAccess'); + +const createCache = (err, data) => { + return { + organizations: { + list: { + 'global': { + data: [ + { + "organizationId": "123456", + "displayName": "myorg", + "creationTime": "2018-03-03T17:56:10.122Z", + "lifecycleState": "ACTIVE", + "name": "organizations/123456" + } + ], + err: null + } + }, + listOrgPolicies: { + 'global': { + err: err, + data: data + } + }, + }, + } +}; + +describe('uniformBucketLevelAccess', function () { + describe('run', function () { + + it('should give unknow if an organization policies error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query organization policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + 'error', + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no organization policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No organization policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if uniform bucket level access is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/storage.uniformBucketLevelAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": { + "enforced": true + } + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if uniform bucket level access is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enforced'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "policies": [ + { + "constraint": "constraints/storage.uniformBucketLevelAccess", + "updateTime": "2021-10-19T20:42:37.813762Z", + "booleanPolicy": {} + }, + ] + } + + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/composer/envDefaultServiceAccount.js b/plugins/google/composer/envDefaultServiceAccount.js new file mode 100644 index 000000000..f1657c689 --- /dev/null +++ b/plugins/google/composer/envDefaultServiceAccount.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Environment Default Service Account', + category: 'Cloud Composer', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure Composer environment is not using the default compute engine service account', + more_info: 'The Composer environment node VMs uses a service account to deploy different pods like Airflow workers and schedulers.By default it uses the compute engine service account which has the editor role on the project. This allows the VM node to have read and write permissions on most of the GCP services. To prevent privilege escalation, it is recommended to create a new service account with limited permissions for your VM instead of using the default one.', + link: 'https://cloud.google.com/compute/docs/access/service-accounts', + recommended_action: 'Make sure that composer environments are not using default service account', + apis: ['composer:environments', 'projects:get'], + realtime_triggers: ['orchestration.airflow.service.Environments.CreateEnviroments', 'orchestration.airflow.service.Environments.DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects) return callback(null, results, source); + + if (projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global'); + return callback(null, results, source); + } + + if (!projects.data.length) { + helpers.addResult(results, 0, 'No projects found', 'global'); + return callback(null, results, source); + } + + var defaultServiceAccount = projects.data[0].defaultServiceAccount; + + if (!defaultServiceAccount) return callback(null, results, source); + + async.each(regions.composer, function(region, rcb){ + let environments = helpers.addSource(cache, source, + ['composer', 'environments', region]); + + if (!environments) return rcb(); + + if (environments.err || !environments.data) { + helpers.addResult(results, 3, 'Unable to query Composer environments', region, null, null, environments.err); + return rcb(); + } + + if (!environments.data.length) { + helpers.addResult(results, 0, 'No Composer environments found', region); + return rcb(); + } + + environments.data.forEach(environment => { + if (environment.config && environment.config.nodeConfig && + environment.config.nodeConfig.serviceAccount && environment.config.nodeConfig.serviceAccount === defaultServiceAccount) { + helpers.addResult(results, 2, + 'Composer environment is using default service account', region, environment.name); + } else { + helpers.addResult(results, 0, + 'Composer environment is not using default service account', region, environment.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/composer/envDefaultServiceAccount.spec.js b/plugins/google/composer/envDefaultServiceAccount.spec.js new file mode 100644 index 000000000..a4791a40a --- /dev/null +++ b/plugins/google/composer/envDefaultServiceAccount.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +var plugin = require('./envDefaultServiceAccount'); + +const createCache = (data, projectData, error) => { + return { + composer: { + environments: { + 'us-central1': { + data: data, + err: error + } + } + }, + projects : { + get: { + 'global': { + data: projectData + } + } + } + } +}; + +const project = { + kind: "compute#project", + id: "00000111112222233333", + defaultServiceAccount: "00000111112222233333-compute@developer.gserviceaccount.com", +} + +const environments = [ + { + name: 'projects/test-proj/locations/us-central1/environments/test-1', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-1-gke', + dagGcsPrefix: 'gs://us-central1-test-1-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + serviceAccount: '00000111112222233333-compute@developer.gserviceaccount.com', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + }, + { + name: 'projects/test-proj/locations/us-central1/environments/test-2', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-2-gke', + dagGcsPrefix: 'gs://us-central1-test-2-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {}, + serviceAccount: '00000111112222233333-compute@dev2.gserviceaccount.com' + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + } +] + +describe('envDefaultServiceAccount', function () { + describe('run', function () { + + it('should give unknown if an environment error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Composer environments'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + [project], + { message: 'error'} + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no composer environments', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Composer environments found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + [project] + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if any composer environment is using the default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Composer environment is using default service account'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [environments[0]], + [project] + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if the composer environment is not using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Composer environment is not using default service account'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [environments[1]], + [project], + null + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/composer/environmentEncryption.js b/plugins/google/composer/environmentEncryption.js new file mode 100644 index 000000000..0e05678a9 --- /dev/null +++ b/plugins/google/composer/environmentEncryption.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Environment Encryption', + category: 'Cloud Composer', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure Composer environments have encryption enabled using desired protection level.', + more_info: 'Within a Composer environment, data is encrypted by default using Google-managed encryption keys. To adhere to security compliance standards and have more control over the keys and encryption process, ensure the environment is encrypted with desired encryption level.', + link: 'https://cloud.google.com/composer/docs/cmek', + recommended_action: 'Ensure that all composer environments have desired encryption level.', + apis: ['composer:environments', 'keyRings:list', 'cryptoKeys:list'], + settings: { + environment_encryption_level: { + name: 'Environment Encryption Level', + description: 'Desired protection level for composer environments. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['orchestration.airflow.service.Environments.CreateEnviroments', 'orchestration.airflow.service.Environments.DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.environment_encryption_level || this.settings.environment_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.composer, function(region, rcb) { + + let environments = helpers.addSource( + cache, source, ['composer', 'environments', region]); + + if (!environments) return rcb(); + + if (environments.err || !environments.data) { + helpers.addResult(results, 3, 'Unable to query Composer environments: ' + helpers.addError(environments), region, null, null, environments.err); + return rcb(); + } + + if (!environments.data.length) { + helpers.addResult(results, 0, 'No Composer environments found', region); + return rcb(); + } + + if (environments && environments.data) { + environments.data.forEach(environment => { + let currentEncryptionLevel; + + if (environment && environment.config && environment.config.encryptionConfig && environment.config.encryptionConfig.kmsKeyName + && keysObj[environment.config.encryptionConfig.kmsKeyName]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[environment.config.encryptionConfig.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Composer environment has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, environment.name); + } else { + helpers.addResult(results, 2, + `Composer environment has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, environment.name); + } + }); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/composer/environmentEncryption.spec.js b/plugins/google/composer/environmentEncryption.spec.js new file mode 100644 index 000000000..7aafdd7f7 --- /dev/null +++ b/plugins/google/composer/environmentEncryption.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var plugin = require('./environmentEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const environments = [ + { + name: 'projects/test-proj/locations/us-central1/environments/test-1', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-1-gke', + dagGcsPrefix: 'gs://us-central1-test-1-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + encryptionConfig: { + kmsKeyName: 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z' + }, + { + name: 'projects/test-proj/locations/us-central1/environments/test-2', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-2-gke', + dagGcsPrefix: 'gs://us-central1-test-2-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + } +] + +const createCache = (err, data, keysList, keysErr) => { + return { + composer: { + environments: { + 'us-central1': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('environmentEncryption', function () { + describe('run', function () { + it('should give unknown result if an environment error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Composer environments'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no composer environments found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Composer environments found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if environmentshas desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [environments[0]], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if environmentsdoes not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [environments[1]], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/google/composer/environmentLabelsAdded.js b/plugins/google/composer/environmentLabelsAdded.js new file mode 100644 index 000000000..4cf498d46 --- /dev/null +++ b/plugins/google/composer/environmentLabelsAdded.js @@ -0,0 +1,63 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Environment Labels Added', + category: 'Cloud Composer', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensures all Composer environments have labels added', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/composer/docs/manage-environment-labels', + recommended_action: 'Ensure labels are added to all cloud composer environments', + apis: ['composer:environments'], + realtime_triggers: ['orchestration.airflow.service.Environments.CreateEnviroments', 'orchestration.airflow.service.Environments.UpdateEnvironment', 'orchestration.airflow.service.Environments.DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.each(regions.composer, function(region, rcb){ + let environments = helpers.addSource(cache, source, + ['composer', 'environments', region]); + + if (!environments) return rcb(); + + if (environments.err || !environments.data) { + helpers.addResult(results, 3, 'Unable to query Composer environments', region, null, null, environments.err); + return rcb(); + } + + if (!environments.data.length) { + helpers.addResult(results, 0, 'No Composer environments found', region); + return rcb(); + } + + environments.data.forEach(environment => { + if (environment.labels && + Object.keys(environment.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(environment.labels).length} labels found for composer environment`, region, environment.name); + } else { + helpers.addResult(results, 2, + 'Composer environment does not have any labels added', region, environment.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/composer/environmentLabelsAdded.spec.js b/plugins/google/composer/environmentLabelsAdded.spec.js new file mode 100644 index 000000000..b81ed930e --- /dev/null +++ b/plugins/google/composer/environmentLabelsAdded.spec.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var plugin = require('./environmentLabelsAdded'); + +const createCache = (err, data) => { + return { + composer: { + environments: { + 'us-central1': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +const environments = [ + { + name: 'projects/test-proj/locations/us-central1/environments/test-1', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-1-gke', + dagGcsPrefix: 'gs://us-central1-test-1-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + labels: { 'test-label': 'test' } + }, + { + name: 'projects/test-proj/locations/us-central1/environments/test-2', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-2-gke', + dagGcsPrefix: 'gs://us-central1-test-2-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + } +] + +describe('environmentLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if an environment error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Composer environments'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no environments are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Composer environments found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the environment', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for composer environment'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [environments[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the environmenr', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Composer environment does not have any labels added'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [environments[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/composer/webserverPublicAccess.js b/plugins/google/composer/webserverPublicAccess.js new file mode 100644 index 000000000..147d2a009 --- /dev/null +++ b/plugins/google/composer/webserverPublicAccess.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Airflow Web Server Public Access', + category: 'Cloud Composer', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure Composer Airflow web server is not open to the world', + more_info: 'Allowing access from all IP addresses on the Internet to Composer Environments is risky as it can lead to Brute Force or DoS attacks. As a security best practice, only allow access from required IP ranges.', + link: 'https://cloud.google.com/composer/docs/concepts/private-ip', + recommended_action: 'Ensure that all composer environments have private airflow web servers', + apis: ['composer:environments'], + realtime_triggers: ['orchestration.airflow.service.Environments.CreateEnviroments', 'orchestration.airflow.service.Environments.UpdateEnvironment', 'orchestration.airflow.service.Environments.DeleteEnvironment'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.each(regions.composer, function(region, rcb){ + let environments = helpers.addSource(cache, source, + ['composer', 'environments', region]); + + if (!environments) return rcb(); + + if (environments.err || !environments.data) { + helpers.addResult(results, 3, 'Unable to query Composer environments', region, null, null, environments.err); + return rcb(); + } + + if (!environments.data.length) { + helpers.addResult(results, 0, 'No Composer environments found', region); + return rcb(); + } + + environments.data.forEach(environment => { + if (environment.config && environment.config.webServerNetworkAccessControl + && environment.config.webServerNetworkAccessControl.allowedIpRanges + && !(environment.config.webServerNetworkAccessControl.allowedIpRanges.find( + range => ['0.0.0.0/0', '::0/0'].includes(range.value) + ))) { + helpers.addResult(results, 0, + 'Composer Airflow Web Server does not allow public access', region, environment.name); + } else { + helpers.addResult(results, 2, + 'Composer Airflow Web Server allows public access', region, environment.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/composer/webserverPublicAccess.spec.js b/plugins/google/composer/webserverPublicAccess.spec.js new file mode 100644 index 000000000..f9c80eb76 --- /dev/null +++ b/plugins/google/composer/webserverPublicAccess.spec.js @@ -0,0 +1,162 @@ +var expect = require('chai').expect; +var plugin = require('./webserverPublicAccess'); + +const createCache = (err, data) => { + return { + composer: { + environments: { + 'us-central1': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +const environments = [ + { + name: 'projects/test-proj/locations/us-central1/environments/test-1', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-1-gke', + dagGcsPrefix: 'gs://us-central1-test-1-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + webServerNetworkAccessControl: { + allowedIpRanges: [ + { value: '10.0.0.0/16' } + ] + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + }, + { + name: 'projects/test-proj/locations/us-central1/environments/test-2', + config: { + gkeCluster: 'projects/test-proj/locations/us-central1/clusters/us-central1-test-2-gke', + dagGcsPrefix: 'gs://us-central1-test-2-bucket/dags', + softwareConfig: { imageVersion: 'composer-2.1.10-airflow-2.4.3' }, + nodeConfig: { + network: 'projects/test-proj/global/networks/default', + subnetwork: 'projects/test-proj/regions/us-central1/subnetworks/default', + ipAllocationPolicy: {} + }, + privateEnvironmentConfig: { + privateClusterConfig: {}, + cloudSqlIpv4CidrBlock: '10.0.0.0/12', + cloudComposerNetworkIpv4CidrBlock: '172.31.245.0/24' + }, + webServerNetworkAccessControl: { + allowedIpRanges: [ + { + value: '0.0.0.0/0', + description: 'Allows access from all IPv4 addresses (default value)' + }, + { + value: '::0/0', + description: 'Allows access from all IPv6 addresses (default value)' + } + ] + }, + environmentSize: 'ENVIRONMENT_SIZE_SMALL', + recoveryConfig: { scheduledSnapshotsConfig: {} } + }, + uuid: '1111111111', + state: 'RUNNING', + createTime: '2023-03-22T19:48:55.635485Z', + updateTime: '2023-03-22T20:28:21.177734Z', + } +] + +describe('webserverPublicAccess', function () { + describe('run', function () { + it('should give unknown result if an environment error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Composer environments'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no environments are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Composer environments found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if airflow webserver does not allow public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Composer Airflow Web Server does not allow public access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [environments[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if airflow webserver allows public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Composer Airflow Web Server allows public access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [environments[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/InstanceTemplateMachineTypes.spec.js b/plugins/google/compute/InstanceTemplateMachineTypes.spec.js new file mode 100644 index 000000000..17de317fd --- /dev/null +++ b/plugins/google/compute/InstanceTemplateMachineTypes.spec.js @@ -0,0 +1,115 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instanceTemplateMachineTypes'); + +const createCache = (instanceData, error) => { + return { + instanceTemplates: { + list: { + 'global': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instanceTemplateMachineTypes', function () { + describe('run', function () { + const settings = { instance_template_machine_types: 'e2-micro' }; + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instance templates'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, settings, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instance templates found'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, settings, callback); + }); + + it('should FAIL if VM instance template does not have desired machine type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine instance template does not have desired machine type'); + done() + }; + + const cache = createCache( + [ + { + "id": "864700679362969633", + "creationTimestamp": "2021-06-25T04:32:30.481-07:00", + "name": "instance-template-1", + "description": "", + "properties": { + "machineType": "e2-small", + "canIpForward": false + }, + } + ], + null + ); + + plugin.run(cache, settings, callback); + }) + + it('should PASS if VM instance template has desired machine type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Virtual Machine instance template has desired machine type'); + done() + }; + + const cache = createCache( + [ + { + "id": "864700679362969633", + "creationTimestamp": "2021-06-25T04:32:30.481-07:00", + "name": "instance-template-1", + "description": "", + "properties": { + "machineType": "e2-micro", + "canIpForward": false, + }, + } + ], + null + ); + plugin.run(cache, settings, callback); + }) + + }) +}); diff --git a/plugins/google/compute/VMDisksCMKEncrypted.js b/plugins/google/compute/VMDisksCMKEncrypted.js new file mode 100644 index 000000000..f4c063127 --- /dev/null +++ b/plugins/google/compute/VMDisksCMKEncrypted.js @@ -0,0 +1,122 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'VM Disks CMK Encryption', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Virtual Machine instances are encrypted using customer-managed keys.', + more_info: 'Google encrypts all disks at rest by default. By using CMKs you can have better control over your disk encryption.', + link: 'https://cloud.google.com/compute/docs/disks/customer-supplied-encryption', + recommended_action: 'Ensure that your VM instances have CMK encryption enabled.', + apis: ['disks:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + disk_encryption_level: { + name: 'Disk Encryption Protection Level', + description: 'Desired protection level for Virtual Machine Disk. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + }, + }, + realtime_triggers: ['compute.disks.insert','compute.disks.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.disk_encryption_level || this.settings.disk_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + var keysArr = []; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) keysArr = cryptoKeys.data; + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.disks, (region, rcb) => { + var noDisks = []; + var zones = regions.zones; + + async.each(zones[region], function(zone, zcb) { + var disks = helpers.addSource(cache, source, + ['disks', 'list', zone]); + + if (!disks) return zcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, + 'Unable to query compute disks', region, null, null, disks.err); + return zcb(); + } + + if (!disks.data.length) { + noDisks.push(zone); + return zcb(); + } + + var disksFound = false; + + disks.data.forEach(disk => { + if (!disk.id || !disk.selfLink || !disk.creationTimestamp) return; + + disksFound = true; + + let currentEncryptionLevel; + + if (disk.diskEncryptionKey && disk.diskEncryptionKey.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel((keysArr.find(key => key.name && disk.diskEncryptionKey.kmsKeyName.includes(key.name))), helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + let resource = helpers.createResourceName('disks', disk.name, project, 'zone', zone); + + if (currentEncryptionLevel < desiredEncryptionLevel) { + helpers.addResult(results, 2, + `Disk encryption level ${currentEncryptionLevelStr} is less than desired encryption level ${desiredEncryptionLevelStr}`, region, resource); + } else { + helpers.addResult(results, 0, + `Disk encryption level ${currentEncryptionLevelStr} is greater than or equal to desired encryption level ${desiredEncryptionLevelStr}`, region, resource); + } + }); + + if (!disksFound) noDisks.push(zone); + + zcb(); + }, function() { + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + rcb(); + }); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/VMDisksCMKEncrypted.spec.js b/plugins/google/compute/VMDisksCMKEncrypted.spec.js new file mode 100644 index 000000000..bae5655f0 --- /dev/null +++ b/plugins/google/compute/VMDisksCMKEncrypted.spec.js @@ -0,0 +1,155 @@ +var expect = require('chai').expect; +var plugin = require('./VMDisksCMKEncrypted'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const disks = [ + { + "kind": "compute#disk", + "id": "5472446060006107254", + "creationTimestamp": "2019-09-25T14:05:30.090-07:00", + "name": "instance-3", + "sizeGb": "10", + "diskEncryptionKey": { "kmsKeyName": 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1' }, + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3" + }, + { + "kind": "compute#disk", + "id": "5472446060006107254", + "creationTimestamp": "2019-09-25T14:05:30.090-07:00", + "name": "instance-3", + "sizeGb": "10", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3" + }, + +]; + + +const createCache = (disks, disksError, keysList, keysErr) => { + return { + disks: { + list: { + 'us-central1-a': { + err: disksError, + data: disks + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + + +describe('VMDisksCMKEncrypted', function () { + describe('run', function () { + it('should give unknown result if a disk error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + ['error'], + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no disks are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found '); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache([], null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }); + + it('should pass if disk encryption level is equal to or greater than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('greater than or equal to desired encryption level'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache([disks[0]], null, cryptoKeys, null); + plugin.run(cache, {}, callback); + }); + + it('should fail if disk encryption level is less than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('less than desired encryption level'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache([disks[1]], null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }) + it('should fail if disk encryption level key is not found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('less than desired encryption level'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache([disks[0]], null, [], null); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/applicationConsistentSnapshots.js b/plugins/google/compute/applicationConsistentSnapshots.js new file mode 100644 index 000000000..642fe6ade --- /dev/null +++ b/plugins/google/compute/applicationConsistentSnapshots.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Application Consistent Snapshots', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that application consistent snapshots feature is enabled for snapshot schedules.', + more_info: 'Application consistent snapshots are more reliable because they are created after making sure that current operations are temporarily ceased and any data in memory is flushed to disk.', + link: 'https://cloud.google.com/compute/docs/disks/snapshot-best-practices#prepare_for_consistency', + recommended_action: 'Ensure that all disk snapshot schedules are application consistent.', + apis: ['resourcePolicies:list'], + realtime_triggers: ['compute.snapshots.insert', 'compute.snapshots.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.resourcePolicies, (region, rcb) => { + + var resourcePolicies = helpers.addSource(cache, source, + ['resourcePolicies', 'list', region]); + + if (!resourcePolicies) return rcb(); + + if (resourcePolicies.err || !resourcePolicies.data) { + helpers.addResult(results, 3, + 'Unable to query for snapshot schedules: ' + helpers.addError(resourcePolicies), region, null, null, resourcePolicies.err); + return rcb(); + } + + if (!resourcePolicies.data.length) { + helpers.addResult(results, 0, 'No snapshot schedules found', region); + return rcb(); + } + + resourcePolicies.data.forEach(policy => { + if (!policy.name) return; + + if (policy.snapshotSchedulePolicy && policy.snapshotSchedulePolicy.snapshotProperties && policy.snapshotSchedulePolicy.snapshotProperties.guestFlush) { + helpers.addResult(results, 0, 'Snapshot schedule is configured to take application-consistent snapshots', + region, policy.name); + } else { + helpers.addResult(results, 2, 'Snapshot schedule is not configured to take application-consistent snapshots', + region, policy.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/applicationConsistentSnapshots.spec.js b/plugins/google/compute/applicationConsistentSnapshots.spec.js new file mode 100644 index 000000000..1d05099ca --- /dev/null +++ b/plugins/google/compute/applicationConsistentSnapshots.spec.js @@ -0,0 +1,163 @@ +var expect = require('chai').expect; +var plugin = require('./applicationConsistentSnapshots'); + + +const schedules = [ + { + "id": "11111", + "creationTimestamp": "2021-10-09T10:17:01.727-07:00", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-project/regions/us-central1/resourcePolicies/schedule-1", + "region": "https://www.googleapis.com/compute/v1/projects/my-project/regions/us-central1", + "name": "schedule-1", + "snapshotSchedulePolicy": { + "schedule": { + "weeklySchedule": { + "dayOfWeeks": [ + { + "day": "TUESDAY", + "startTime": "03:00", + "duration": "PT14400S" + } + ] + } + }, + "retentionPolicy": { + "maxRetentionDays": 14, + "onSourceDiskDelete": "KEEP_AUTO_SNAPSHOTS" + }, + "snapshotProperties": { + "storageLocations": [ + "us-central1" + ], + "guestFlush": true + } + }, + "status": "READY", + "kind": "compute#resourcePolicy" + }, + { + "id": "111111", + "creationTimestamp": "2021-10-09T10:20:00.717-07:00", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-project/regions/us-central1/resourcePolicies/schedule-2", + "region": "https://www.googleapis.com/compute/v1/projects/my-project/regions/us-central1", + "name": "schedule-2", + "snapshotSchedulePolicy": { + "schedule": { + "dailySchedule": { + "daysInCycle": 1, + "startTime": "03:00", + "duration": "PT14400S" + } + }, + "retentionPolicy": { + "maxRetentionDays": 14, + "onSourceDiskDelete": "KEEP_AUTO_SNAPSHOTS" + }, + "snapshotProperties": { + "storageLocations": [ + "us-central1" + ], + "guestFlush": false + } + }, + "status": "READY", + "kind": "compute#resourcePolicy" + + } +]; + +const createCache = (list, err) => { + + return { + resourcePolicies: { + list: { + 'us-central1': { + err: err, + data: list + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('applicationConsistentSnapshots', function () { + describe('run', function () { + + it('should give unknown if unable to query schedules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for snapshot schedules'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give passing result if no snapshot schedules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No snapshot schedules found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if snapshot schedule is configured to take application-consistent snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is configured to take application-consistent snapshots'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [schedules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if snapshot schedule is not configured to take application-consistent snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not configured to take application-consistent snapshots'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [schedules[1]], + null ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/compute/automaticRestartEnabled.js b/plugins/google/compute/automaticRestartEnabled.js new file mode 100644 index 000000000..531575b55 --- /dev/null +++ b/plugins/google/compute/automaticRestartEnabled.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Automatic Restart Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Virtual Machine instances have automatic restart feature enabled.', + more_info: 'Automatic Restart sets the virtual machine restart behavior when an instance is crashed or stopped by the system. If it is enabled, Google Cloud Compute Engine restarts the instance if it crashes or is stopped.', + link: 'https://cloud.google.com/compute/docs/instances/setting-instance-scheduling-options#autorestart', + recommended_action: 'Ensure automatic restart is enabled for all virtual machine instances.', + apis: ['compute:list'], + remediation_min_version: '202202080432', + remediation_description: 'Automatic Restart will be enabled for all virtual machine instances.', + apis_remediate: ['compute:list', 'projects:get'], + actions: {remediate:['compute.instances.setScheduling'], rollback:['compute.instances.setScheduling']}, + permissions: {remediate: ['compute.instances.setScheduling'], rollback: ['compute.instances.setScheduling']}, + realtime_triggers: ['compute.instances.setScheduling', 'compute.instances.insert', 'compute.instances.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.compute, (region, rcb) => { + var noInstances = []; + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (instance.scheduling && instance.scheduling.automaticRestart) { + helpers.addResult(results, 0, + 'Automatic Restart is enabled for the instance', region, resource); + } else { + helpers.addResult(results, 2, + 'Automatic Restart is disabled for the instance', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'automaticRestartEnabled'; + var baseUrl = 'https://compute.googleapis.com/compute/v1/{resource}/setScheduling'; + var method = 'POST'; + var putCall = this.actions.remediate; + + // create the params necessary for the remediation + var body = { + automaticRestart: true + }; + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'automaticRestart': 'Disabled' + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/automaticRestartEnabled.spec.js b/plugins/google/compute/automaticRestartEnabled.spec.js new file mode 100644 index 000000000..a6e9439f2 --- /dev/null +++ b/plugins/google/compute/automaticRestartEnabled.spec.js @@ -0,0 +1,278 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./automaticRestartEnabled'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('automaticRestartEnabled', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if Automatic Restart is disabled for the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automatic Restart is disabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "3086667528957202900", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "networkInterfaces": [ + { + "kind": "compute#networkInterface", + "network": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/networks/default", + "subnetwork": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/regions/us-central1/subnetworks/default", + "networkIP": "10.128.0.5", + "name": "nic0", + "accessConfigs": [ + { + "kind": "compute#accessConfig", + "type": "ONE_TO_ONE_NAT", + "name": "External NAT", + "natIP": "35.193.110.217", + "networkTier": "PREMIUM" + } + ], + "fingerprint": "Wq0vYR9v5BQ=" + } + ], + "disks": [ + { + "kind": "compute#attachedDisk", + "type": "PERSISTENT", + "mode": "READ_WRITE", + "source": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3", + "deviceName": "instance-3", + "index": 0, + "boot": true, + "autoDelete": true, + "licenses": [ + "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" + ], + "interface": "SCSI", + "guestOsFeatures": [ + { + "type": "VIRTIO_SCSI_MULTIQUEUE" + } + ] + } + ], + "metadata": { + "kind": "compute#metadata", + "fingerprint": "InJCT-yXAGE=", + "items": [ + { + "key": "serial-port-enable", + "value": "true" + } + ] + }, + "serviceAccounts": [ + { + "email": "293348421062-compute@developer.gserviceaccount.com", + "scopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring.write", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ] + } + ], + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": false, + "preemptible": false + }, + "cpuPlatform": "Intel Haswell", + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "reservationAffinity": { + "consumeReservationType": "ANY_RESERVATION" + }, + "displayDevice": { + "enableDisplay": false + } + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if Automatic Restart is enabled for the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Automatic Restart is enabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": false, + "networkInterfaces": [ + { + "kind": "compute#networkInterface", + "network": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/global/networks/default", + "subnetwork": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/regions/us-central1/subnetworks/default", + "networkIP": "10.128.0.3", + "name": "nic0", + "accessConfigs": [ + { + "kind": "compute#accessConfig", + "type": "ONE_TO_ONE_NAT", + "name": "External NAT", + "natIP": "34.68.162.149", + "networkTier": "PREMIUM" + } + ], + "fingerprint": "zZcIeLJlyfk=" + } + ], + "disks": [ + { + "kind": "compute#attachedDisk", + "type": "PERSISTENT", + "mode": "READ_WRITE", + "source": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-2", + "deviceName": "instance-2", + "index": 0, + "boot": true, + "autoDelete": true, + "licenses": [ + "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" + ], + "interface": "SCSI", + "guestOsFeatures": [ + { + "type": "VIRTIO_SCSI_MULTIQUEUE" + } + ] + } + ], + "metadata": { + "kind": "compute#metadata", + "fingerprint": "XusztY_f8i4=" + }, + "serviceAccounts": [ + { + "email": "293348421062-compute@developer.gserviceaccount.com", + "scopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring.write", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ] + } + ], + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": true, + "preemptible": false + }, + "cpuPlatform": "Intel Haswell", + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "reservationAffinity": { + "consumeReservationType": "ANY_RESERVATION" + }, + "displayDevice": { + "enableDisplay": false + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/autoscaleEnabled.js b/plugins/google/compute/autoscaleEnabled.js index b9ba6aec8..177f69f56 100644 --- a/plugins/google/compute/autoscaleEnabled.js +++ b/plugins/google/compute/autoscaleEnabled.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Autoscale Enabled', category: 'Compute', + domain: 'Compute', + severity: 'High', description: 'Ensures instance groups have autoscale enabled for high availability', more_info: 'Enabling autoscale increases efficiency and improves cost management for resources.', link: 'https://cloud.google.com/compute/docs/autoscaler/', recommended_action: 'Ensure autoscaling is enabled for all instance groups.', - apis: ['instanceGroups:aggregatedList', 'autoscalers:aggregatedList','clusters:list'], + apis: ['instanceGroups:aggregatedList', 'autoscalers:aggregatedList','kubernetes:list'], + realtime_triggers: ['compute.instancegroups.insert', 'compute.instancegroups.delete'], run: function(cache, settings, callback) { var results = []; @@ -18,40 +21,59 @@ module.exports = { let instanceGroupsObj = helpers.addSource(cache, source, ['instanceGroups', 'aggregatedList', ['global']]); + if (!instanceGroupsObj) return callback(null, results, source); + if (instanceGroupsObj.err || !instanceGroupsObj.data) { - helpers.addResult(results, 3, 'Unable to query instance groups: ' + helpers.addError(instanceGroupsObj), 'global'); + helpers.addResult(results, 3, 'Unable to query instance groups', 'global', null, null, instanceGroupsObj.err); return callback(null, results, source); } - var instanceGroups = Object.values(instanceGroupsObj.data).filter(instanceGroup =>{ - return !instanceGroup.warning; - }); + let instanceGroups = []; + + if (instanceGroupsObj.data.length) { + instanceGroupsObj.data.forEach(instanceGroup => { + instanceGroups = instanceGroups.concat(Object.values(instanceGroup).filter(instanceGroup =>{ + return !instanceGroup.warning; + })); + }); + } if (!instanceGroups.length) { helpers.addResult(results, 0, 'No instance groups found', 'global'); return callback(null, results, source); } + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(instanceGroups, function(instanceGroupsInLocation, rcb) { instanceGroupsInLocation.instanceGroups.forEach(instanceGroup => { if (instanceGroup.name) { - instanceGroupURLObj[instanceGroup.name] = instanceGroup + instanceGroupURLObj[instanceGroup.name] = instanceGroup; } }); return rcb(); }, function() { let clusters = helpers.addSource(cache, source, - ['clusters', 'list', ['global']]); + ['kubernetes', 'list', ['global']]); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query autoscalers: ' + helpers.addError(clusters), 'global'); + helpers.addResult(results, 3, 'Unable to query clusters', 'global', null, null, clusters.err); } else if (!clusters.data.length) { - helpers.addResult(results, 0, 'No instance groups found', 'global'); + helpers.addResult(results, 0, 'No clusters found', 'global'); } else { clusters.data.forEach(cluster => { if (cluster.nodePools && - cluster.nodePools.length) { + cluster.nodePools.length) { cluster.nodePools.forEach(nodePool => { if (nodePool.autoscaling && nodePool.autoscaling.enabled && @@ -59,8 +81,8 @@ module.exports = { nodePool.instanceGroupUrls.length) { nodePool.instanceGroupUrls.forEach(instanceGroupUrl => { var instanceGroupUrlName = instanceGroupUrl.split('/')[10]; - if (instanceGroupURLObj.hasOwnProperty(instanceGroupUrlName)) { - delete instanceGroupURLObj[instanceGroupUrlName] + if (instanceGroupURLObj[instanceGroupUrlName]) { + delete instanceGroupURLObj[instanceGroupUrlName]; } }); } @@ -71,31 +93,45 @@ module.exports = { let autoscalersObj = helpers.addSource(cache, source, ['autoscalers', 'aggregatedList', ['global']]); - + if (autoscalersObj.err || !autoscalersObj.data) { - helpers.addResult(results, 3, 'Unable to query autoscalers: ' + helpers.addError(autoscalersObj), 'global'); + helpers.addResult(results, 3, 'Unable to query autoscalers', 'global', null, null, autoscalersObj.err); } else { - var autoscalers = Object.values(autoscalersObj.data).filter(autoscaler =>{ - return !autoscaler.warning; - }); + var autoscalers = []; + if (autoscalersObj.data.length) { + autoscalersObj.data.forEach(autoscaler => { + autoscalers = autoscalers.concat(Object.values(autoscaler).filter(autoscaler =>{ + return !autoscaler.warning; + })); + }); + } } if (autoscalers.length) { async.each(autoscalers, function(autoscalersInLocation, lcb) { autoscalersInLocation.autoscalers.forEach(autoscaler => { if (autoscaler.name) { - if (instanceGroupURLObj.hasOwnProperty(autoscaler.name)) { - delete instanceGroupURLObj[autoscaler.name] + if (instanceGroupURLObj[autoscaler.name]) { + delete instanceGroupURLObj[autoscaler.name]; } } }); - + lcb(); }, function() { if (Object.keys(instanceGroupURLObj).length) { - let instanceGroupStr = Object.values(instanceGroupURLObj).map(a => a.id).join(', '); - helpers.addResult(results, 2, - `The following instance groups do not have autoscale enabled: ${instanceGroupStr}`, 'global'); + for (let group in instanceGroupURLObj) { + let groupLocArr = instanceGroupURLObj[group].zone ? instanceGroupURLObj[group].zone.split('/') : + instanceGroupURLObj[group].region ? instanceGroupURLObj[group].region.split('/') : ['global']; + let groupLoc = groupLocArr[groupLocArr.length-1]; + let resourceType = instanceGroupURLObj[group].zone ? 'zone' : + instanceGroupURLObj[group].region ? 'region' : 'global'; + let resource = helpers.createResourceName('instanceGroups', instanceGroupURLObj[group].name, project, resourceType, groupLoc); + let region = (resourceType == 'zone') ? groupLoc.substr(0, groupLoc.length - 2) : groupLoc; + + helpers.addResult(results, 2, + 'Instance group does not have autoscale enabled', region, resource); + } } else { helpers.addResult(results, 0, 'All instance groups have autoscale enabled', 'global'); @@ -104,9 +140,18 @@ module.exports = { }); } else { if (Object.keys(instanceGroupURLObj).length) { - let instanceGroupStr = Object.values(instanceGroupURLObj).map(a => a.id).join(', '); - helpers.addResult(results, 2, - `The following instance groups do not have autoscale enabled: ${instanceGroupStr}`, 'global'); + for (let group in instanceGroupURLObj) { + let groupLocArr = instanceGroupURLObj[group].zone ? instanceGroupURLObj[group].zone.split('/') : + instanceGroupURLObj[group].region ? instanceGroupURLObj[group].region.split('/') : ['global']; + let groupLoc = groupLocArr[groupLocArr.length-1]; + let resourceType = instanceGroupURLObj[group].zone ? 'zone' : + instanceGroupURLObj[group].region ? 'region' : 'global'; + let resource = helpers.createResourceName('instanceGroups', instanceGroupURLObj[group].name, project, resourceType, groupLoc); + let region = (resourceType == 'zone') ? groupLoc.substr(0, groupLoc.length - 2) : groupLoc; + + helpers.addResult(results, 2, + 'Instance group does not have autoscale enabled', region, resource); + } } else { helpers.addResult(results, 0, 'All instance groups have autoscale enabled', 'global'); diff --git a/plugins/google/compute/autoscaleEnabled.spec.js b/plugins/google/compute/autoscaleEnabled.spec.js new file mode 100644 index 000000000..03417b256 --- /dev/null +++ b/plugins/google/compute/autoscaleEnabled.spec.js @@ -0,0 +1,187 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./autoscaleEnabled'); + +const createCache = (instanceData, autoscalers, instanceGroupsError, autoScalersError) => { + return { + instanceGroups: { + aggregatedList: { + 'global': { + data: instanceData, + err: instanceGroupsError + } + } + }, + autoscalers: { + aggregatedList: { + 'global': { + data: autoscalers, + err: autoScalersError + } + } + }, + kubernetes: { + list: { + 'global': { + data: clusters, + err: null + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +const instanceGroupData = { + "zones/us-central1-a": { + "instanceGroups": [ + { + "id": "111111", + "creationTimestamp": "2019-12-17T11:52:28.215-08:00", + "name": "instance-group-1", + "description": "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + "network": "https://www.googleapis.com/compute/v1/projects/frosty-forest-647198/global/networks/default", + "fingerprint": "42WmSpB8rSM=", + "zone": "https://www.googleapis.com/compute/v1/projects/frosty-forest-647198/zones/us-central1-a", + "selfLink": "https://www.googleapis.com/compute/v1/projects/frosty-forest-647198/zones/us-central1-a/instanceGroups/instance-group-1", + "size": 1, + "subnetwork": "https://www.googleapis.com/compute/v1/projects/frosty-forest-647198/regions/us-central1/subnetworks/default", + "kind": "compute#instanceGroup" + } + ] + }, +}; +const clusters = [ + + { + "name": "cluster-1", + "subnetwork": "default", + "nodePools": [ + { + "name": "default-pool", + "initialNodeCount": 3, + "autoscaling": {}, + "locations": [ + "us-central1-c" + ], + "selfLink": "https://container.googleapis.com/v1beta1/projects/akhtar-dev-khulnasoft/zones/us-central1-c/clusters/cluster-1/nodePools/default-pool", + "version": "1.20.10-gke.1600", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frosty-forest-647198/regions/us-central1/instanceGroups/instance-group-1" + ], + "status": "RUNNING", + "upgradeSettings": { + "maxSurge": 1 + } + } + ], + "locations": [ + "us-central1-c" + ], + } +] + +describe('autoscaleEnabled', function () { + describe('run', function () { + + it('should give unknown if an instance group error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instance groups'); + done() + }; + + const cache = createCache( + [], [], ['error'], null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + + it('should pass no instance groups found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instance groups found'); + done() + }; + + const cache = createCache( + [], [], null, null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + + + + it('should fail if instance instance group does not have autoscaling enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance group does not have autoscale enabled'); + done() + }; + + const cache = createCache( + [instanceGroupData], + [{ + "zones/us-central1-a": { + "autoscalers": + [ + { + creationTimestamp: '2021-08-16T01:24:57.502-07:00', + name: 'instance-group-2', + autoscalingPolicy: { cpuUtilization: { utilizationTarget: 0.001, predictiveMethod: 'NONE' } }, + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + status: 'ACTIVE', + kind: 'compute#autoscaler' + } + ] + } + }], + null, null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if instance group has autoscaling enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('All instance groups have autoscale enabled'); + done() + }; + + const cache = createCache( + [instanceGroupData], + [{ + "zones/us-central1-a": { + "autoscalers": + [ + { + creationTimestamp: '2021-08-16T01:24:57.502-07:00', + name: 'instance-group-1', + autoscalingPolicy: { cpuUtilization: { utilizationTarget: 0.001, predictiveMethod: 'NONE' } }, + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + status: 'ACTIVE', + kind: 'compute#autoscaler' + } + ] + } + }], + null, null + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/autoscaleMinCpuUtilization.js b/plugins/google/compute/autoscaleMinCpuUtilization.js new file mode 100644 index 000000000..4f4b6be5c --- /dev/null +++ b/plugins/google/compute/autoscaleMinCpuUtilization.js @@ -0,0 +1,124 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Autoscale Minimum CPU Utilization Target', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure that minimum CPU utilization target is greater or equal than set percentage.', + more_info: 'The autoscaler treats the target CPU utilization level as a fraction of the average use of all vCPUs over time in the instance group. If the average utilization of your total vCPUs exceeds the target utilization, the autoscaler adds more VM instances. If the average utilization of your total vCPUs is less than the target utilization, the autoscaler removes instances.', + link: 'https://cloud.google.com/compute/docs/autoscaler/scaling-cpu', + recommended_action: 'Ensure all instance groups have Minimum CPU Utilization greater than or equal to target value.', + apis: ['instanceGroups:aggregatedList', 'autoscalers:aggregatedList'], + settings: { + minimum_cpu_utilization_target: { + name: 'Autoscale Minimum CPU Utilization Target', + description: 'Value between 1-100 for the Autoscale Minimum CPU Utilization Target', + regex: '^(100|[1-9][0-9]?)$', + default: 'false', + } + }, + realtime_triggers: ['compute.instancegroups.insert', 'compute.instancegroups.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var minimum_cpu_utilization_target = settings.minimum_cpu_utilization_target || this.settings.minimum_cpu_utilization_target.default; + + if (minimum_cpu_utilization_target == 'false') return callback(null, results, source); + + minimum_cpu_utilization_target = parseInt(minimum_cpu_utilization_target); + + let instanceGroupsObj = helpers.addSource(cache, source, + ['instanceGroups', 'aggregatedList', ['global']]); + + if (!instanceGroupsObj) return callback(null, results, source); + + if (instanceGroupsObj.err || !instanceGroupsObj.data) { + helpers.addResult(results, 3, 'Unable to query instance groups', 'global', null, null, instanceGroupsObj.err); + return callback(null, results, source); + } + + var instanceGroups = Object.values(instanceGroupsObj.data).filter(instanceGroup => { + return !instanceGroup.warning; + }); + + if (!instanceGroups.length) { + helpers.addResult(results, 0, 'No instance groups found', 'global'); + return callback(null, results, source); + } + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + let autoscalersObj = helpers.addSource(cache, source, + ['autoscalers', 'aggregatedList', ['global']]); + + if (autoscalersObj.err || !autoscalersObj.data) { + helpers.addResult(results, 3, 'Unable to query autoscalers', 'global', null, null, autoscalersObj.err); + } else { + var autoscalers = Object.values(autoscalersObj.data).filter(autoscaler => { + return !autoscaler.warning; + }); + + if (!autoscalers.length) { + helpers.addResult(results, 0, 'No autoscalers found', 'global'); + } + + async.each(instanceGroups, function(instanceGroupsInLocation, rcb) { + instanceGroupsInLocation.instanceGroups.forEach(instanceGroup => { + let groupLocArr = instanceGroup.zone ? instanceGroup.zone.split('/') : + instanceGroup.region ? instanceGroup.region.split('/') : ['global']; + let groupLoc = groupLocArr[groupLocArr.length - 1]; + let resourceType = instanceGroup.zone ? 'zone' : + instanceGroup.region ? 'region' : 'global'; + let resource = helpers.createResourceName('instanceGroups', instanceGroup.name, project, resourceType, groupLoc); + let region = (resourceType == 'zone') ? groupLoc.substr(0, groupLoc.length - 2) : groupLoc; + + if (autoscalers.length) { + autoscalers.forEach(scaler => { + + if (scaler && scaler.autoscalers && scaler.autoscalers.length) { + + if (instanceGroup && instanceGroup.name) { + let autoScalingData = scaler.autoscalers.find(scalerObj => scalerObj.name == instanceGroup.name); + + if (autoScalingData && autoScalingData.autoscalingPolicy && + autoScalingData.autoscalingPolicy.cpuUtilization && + autoScalingData.autoscalingPolicy.cpuUtilization.utilizationTarget) { + + if ((autoScalingData.autoscalingPolicy.cpuUtilization.utilizationTarget * 100) >= minimum_cpu_utilization_target) { + helpers.addResult(results, 0, + 'Instance group has desired minimum cpu utilization target', region, resource); + } else { + helpers.addResult(results, 2, + 'Instance group does not have desired minimum cpu utilization target', region, resource); + } + + } else { + helpers.addResult(results, 0, + 'No auto scaling policies found for this instance group', region, resource); + } + + } + } + }); + } + + }); + return rcb(); + }); + } + callback(null, results, source); + + } +}; \ No newline at end of file diff --git a/plugins/google/compute/autoscaleMinCpuUtilization.spec.js b/plugins/google/compute/autoscaleMinCpuUtilization.spec.js new file mode 100644 index 000000000..ed5061812 --- /dev/null +++ b/plugins/google/compute/autoscaleMinCpuUtilization.spec.js @@ -0,0 +1,267 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./autoscaleMinCpuUtilization'); + +const createCache = (instanceData, instanceGroupsError, autoScalersError) => { + return { + instanceGroups: { + aggregatedList: { + 'global': { + data: instanceData.instanceGroups, + err: instanceGroupsError + } + } + }, + autoscalers: { + aggregatedList: { + 'global': { + data: instanceData.autoscalers, + err: autoScalersError + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('autoscaleMinCpuUtilization', function () { + describe('run', function () { + + it('should give unknown if an instance group error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instance groups'); + done() + }; + + const cache = createCache( + { + instanceGroups: [], + autoscalers: [] + }, + ['error'], null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + + it('should pass no instance groups found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instance groups found'); + done() + }; + + const cache = createCache( + { + instanceGroups: [], + autoscalers: [] + }, + null, null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + it('should give unknown if an autoscaler error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query autoscalers'); + done() + }; + + const cache = createCache( + { + instanceGroups: [ + { + instanceGroups: [ + { + creationTimestamp: '2021-08-16T01:24:42.747-07:00', + name: 'instance-group-1', + description: "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + kind: 'compute#instanceGroup' + } + ] + } + ] + }, null, ['error'] + ); + + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + + it('should pass no autoscalers found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No autoscalers found'); + done() + }; + + const cache = createCache( + { + instanceGroups: [ + { + instanceGroups: [ + { + creationTimestamp: '2021-08-16T01:24:42.747-07:00', + name: 'instance-group-1', + description: "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + kind: 'compute#instanceGroup' + } + ] + } + ], + autoscalers: [] + }, + null, null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }); + + it('should fail if instance group does not have desired minimum cpu utilization target', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance group does not have desired minimum cpu utilization target'); + done() + }; + + const cache = createCache( + { + instanceGroups: [ + { + instanceGroups: [ + { + creationTimestamp: '2021-08-16T01:24:42.747-07:00', + name: 'instance-group-1', + description: "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + kind: 'compute#instanceGroup' + } + ] + } + ], + autoscalers: { + 'global': { + autoscalers: [ + { + creationTimestamp: '2021-08-16T01:24:57.502-07:00', + name: 'instance-group-1', + autoscalingPolicy: { cpuUtilization: { utilizationTarget: 0.001, predictiveMethod: 'NONE' } }, + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + status: 'ACTIVE', + kind: 'compute#autoscaler' + } + ] + } + } + }, + null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }) + + it('should pass if instance group has desired minimum cpu utilization target', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Instance group has desired minimum cpu utilization target'); + done() + }; + + const cache = createCache( + { + instanceGroups: [ + { + instanceGroups: [ + { + creationTimestamp: '2021-08-16T01:24:42.747-07:00', + name: 'instance-group-1', + description: "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + kind: 'compute#instanceGroup' + } + ] + } + ], + autoscalers: { + 'global': { + autoscalers: [ + { + creationTimestamp: '2021-08-16T01:24:57.502-07:00', + name: 'instance-group-1', + autoscalingPolicy: { cpuUtilization: { utilizationTarget: 0.6, predictiveMethod: 'NONE' } }, + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + status: 'ACTIVE', + kind: 'compute#autoscaler' + } + ] + } + } + }, + null + ); + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }) + it('should pass if no autoscaling policies found for instance group', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('No auto scaling policies found for this instance group'); + done() + }; + + const cache = createCache( + { + instanceGroups: [ + { + instanceGroups: [ + { + creationTimestamp: '2021-08-16T01:24:42.747-07:00', + name: 'instance-group-1', + description: "This instance group is controlled by Instance Group Manager 'instance-group-1'. To modify instances in this group, use the Instance Group Manager API: https://cloud.google.com/compute/docs/reference/latest/instanceGroupManagers", + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + kind: 'compute#instanceGroup' + } + ] + } + ], + autoscalers: { + 'global': { + autoscalers: [ + { + creationTimestamp: '2021-08-16T01:24:57.502-07:00', + name: 'instance-group-2', + autoscalingPolicy: { cpuUtilization: { utilizationTarget: 0.6, predictiveMethod: 'NONE' } }, + zone: 'https://www.googleapis.com/compute/v1/projects/project/zones/us-central1-a', + status: 'ACTIVE', + kind: 'compute#autoscaler' + } + ] + } + } + }, + null + ); + + + plugin.run(cache, { minimum_cpu_utilization_target: '60' }, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/computePrivilegeAnalysis.js b/plugins/google/compute/computePrivilegeAnalysis.js new file mode 100644 index 000000000..42e3fce9c --- /dev/null +++ b/plugins/google/compute/computePrivilegeAnalysis.js @@ -0,0 +1,19 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Compute', + domain: 'Compute', + severity: 'Info', + description: 'Ensures that no compute instances in your cloud has excessive permissions.', + more_info: 'Compute instances having service account attached with excessive permissions can lead to security risks. Compute instances should have restrictive permissions assigned through service accounts for security best practices.', + link: 'https://cloud.google.com/compute/docs/access/iam', + recommended_action: 'Make sure that compute instances are using service account with only required permissions.', + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + + } +}; diff --git a/plugins/google/compute/confidentialComputingEnabled.js b/plugins/google/compute/confidentialComputingEnabled.js new file mode 100644 index 000000000..0538d9c88 --- /dev/null +++ b/plugins/google/compute/confidentialComputingEnabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Confidential Computing Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Virtual Machine instances have confidential computing enabled.', + more_info: 'Confidential computing allows your sensitive data to be encrypted in memory while it is being processesd and does not allow Google to have access to the encryption keys. Enabling confidential computing can help alleviate risks about Google insiders access to your confidential data.', + link: 'https://cloud.google.com/compute/confidential-vm/docs/about-cvm', + recommended_action: 'Ensure that all VM instances have confidential computing enabled.', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.compute, (region, rcb) => { + var noInstances = []; + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (instance.confidentialInstanceConfig && instance.confidentialInstanceConfig.enableConfidentialCompute) { + helpers.addResult(results, 0, + 'Confidential Computing is enabled for the instance', region, resource); + } else { + helpers.addResult(results, 2, + 'Confidential Computing is disabled for the instance', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/confidentialComputingEnabled.spec.js b/plugins/google/compute/confidentialComputingEnabled.spec.js new file mode 100644 index 000000000..de52ce287 --- /dev/null +++ b/plugins/google/compute/confidentialComputingEnabled.spec.js @@ -0,0 +1,162 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./confidentialComputingEnabled'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('confidentialComputingEnabled', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if Confidential Computing is disabled for the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Confidential Computing is disabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "3086667528957202900", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": false, + "preemptible": false + }, + "cpuPlatform": "Intel Haswell", + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "reservationAffinity": { + "consumeReservationType": "ANY_RESERVATION" + }, + "displayDevice": { + "enableDisplay": false + } + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if Confidential Computing is enabled for the instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Confidential Computing is enabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": false, + + "confidentialInstanceConfig": { + "enableConfidentialCompute": true + }, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": true, + "preemptible": false + }, + "cpuPlatform": "Intel Haswell", + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "reservationAffinity": { + "consumeReservationType": "ANY_RESERVATION" + }, + "displayDevice": { + "enableDisplay": false + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/connectSerialPortsDisabled.js b/plugins/google/compute/connectSerialPortsDisabled.js index 334a43176..196bb5abf 100644 --- a/plugins/google/compute/connectSerialPortsDisabled.js +++ b/plugins/google/compute/connectSerialPortsDisabled.js @@ -4,79 +4,74 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Connect Serial Ports Disabled', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures connecting to serial ports is not enabled for VM instances', more_info: 'The serial console does not allow restricting IP Addresses, which allows any IP address to connect to instance and should therefore be disabled.', link: 'https://cloud.google.com/compute/docs/instances/interacting-with-serial-console', recommended_action: 'Ensure the Enable Connecting to Serial Ports option is disabled for all compute instances.', - apis: ['instances:compute:list'], + apis: ['compute:list'], + realtime_triggers: [ 'compute.instances.insert', 'compute.instances.delete', 'compute.instances.setMetadata'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.compute, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.compute, (region, rcb) => { + var noInstances = []; var zones = regions.zones; - var myError = {}; - var noInstances = {}; - var badInstances = []; async.each(zones[region], function(zone, zcb) { var instances = helpers.addSource(cache, source, - ['instances', 'compute','list', zone ]); + ['compute','list', zone ]); if (!instances) return zcb(); if (instances.err || !instances.data) { - if (!myError[region]) { - myError[region] = []; - } - myError[region].push(zone); + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); return zcb(); } if (!instances.data.length) { - if (!noInstances[region]) { - noInstances[region] = []; - } - noInstances[region].push(zone); + noInstances.push(zone); return zcb(); } instances.data.forEach(instance => { + let found = false; if (instance.metadata && instance.metadata.items && instance.metadata.items.length) { + found = instance.metadata.items.find(item => item.key && item.key.toLowerCase() == 'serial-port-enable' && + item.value && item.value.toLowerCase() === 'true'); + } - instance.metadata.items.forEach(item => { - if (item && - item.key === 'serial-port-enable' && - item.value === 'true') { - badInstances.push(instance.id) - } - }) - + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (found) { + helpers.addResult(results, 2, + 'Connecting to Serial Ports is enabled for the instance', region, resource); + } else { + helpers.addResult(results, 0, + 'Connecting to Serial Ports is disabled for the instance', region, resource); } }); - + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); }); - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query instances' , region); - } else if (noInstances[region] && - zones[region] && - (noInstances[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 0, 'No instances found in the region' , region); - } else if (badInstances.length) { - var myInstanceStr = badInstances.join(", "); - helpers.addResult(results, 2, - `Connecting to Serial Ports is enabled for the following instances: ${myInstanceStr}`, region); - } else if (!badInstances.length) { - helpers.addResult(results, 0, - 'Connecting to Serial Ports is disabled for all instances in the region', region); - } - rcb(); }, function() { callback(null, results, source); }); diff --git a/plugins/google/compute/connectSerialPortsdisabled.spec.js b/plugins/google/compute/connectSerialPortsdisabled.spec.js index 6e2748010..4e7b10d94 100644 --- a/plugins/google/compute/connectSerialPortsdisabled.spec.js +++ b/plugins/google/compute/connectSerialPortsdisabled.spec.js @@ -2,28 +2,21 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./connectSerialPortsDisabled'); -const createCache = (instanceData, instanceDatab, error) => { +const createCache = (instanceData, error) => { return { - instances: { compute: { list: { 'us-central1-a': { data: instanceData, err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, - err: error } } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } } } } @@ -35,16 +28,15 @@ describe('connectSerialPortsDisabled', function () { it('should give unknown if an instance error occurs', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.include('Unable to query instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( [], - [], - ['null'] + ['error'] ); plugin.run(cache, {}, callback); @@ -53,14 +45,13 @@ describe('connectSerialPortsDisabled', function () { it('should pass no VM Instances', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No instances found'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( - [], [], null ); @@ -68,12 +59,12 @@ describe('connectSerialPortsDisabled', function () { plugin.run(cache, {}, callback); }); - it('should fail if Connecting to Serial Ports is enabled', function (done) { + it('should fail if Connecting to Serial Ports is enabled for the instance', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('Connecting to Serial Ports is enabled for the following instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Connecting to Serial Ports is enabled for the instance'); + expect(results[0].region).to.equal('us-central1'); done() }; @@ -173,19 +164,18 @@ describe('connectSerialPortsDisabled', function () { } } ], - [], null ); plugin.run(cache, {}, callback); }) - it('should pass if Connecting to Serial Ports is enabled', function (done) { + it('should pass if Connecting to Serial Ports is disabled for the instance', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.equal('Connecting to Serial Ports is disabled for all instances in the region'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Connecting to Serial Ports is disabled for the instance'); + expect(results[0].region).to.equal('us-central1'); done() }; diff --git a/plugins/google/compute/csekEncryptionEnabled.js b/plugins/google/compute/csekEncryptionEnabled.js index f79dbeff5..9ef28f695 100644 --- a/plugins/google/compute/csekEncryptionEnabled.js +++ b/plugins/google/compute/csekEncryptionEnabled.js @@ -1,14 +1,15 @@ -var async = require('async'); var helpers = require('../../../helpers/google'); module.exports = { title: 'CSEK Encryption Enabled', category: 'Compute', + domain: 'Compute', + severity: 'High', description: 'Ensures Customer Supplied Encryption Key Encryption is enabled on disks', more_info: 'Google encrypts all disks at rest by default. By using CSEK only the users with the key can access the disk. Anyone else, including Google, cannot access the disk data.', link: 'https://cloud.google.com/compute/docs/disks/customer-supplied-encryption', recommended_action: 'CSEK can only be configured when creating a disk. Delete the disk and redeploy with CSEK.', - apis: ['disks:list'], + apis: ['disks:aggregatedList'], compliance: { hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + 'Enabling encryption of disk data helps to protect this data.', @@ -16,69 +17,81 @@ module.exports = { 'Encryption should be enabled for all disks storing this ' + 'type of data.' }, + realtime_triggers: ['compute.disks.insert','compute.disks.delete'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.disks, (region, rcb) => { - var zones = regions.zones; - var myError = {}; - var noDisks = {}; - var badDisks = []; - var goodDisks = []; - async.each(zones[region], function(zone, zcb) { - var disks = helpers.addSource(cache, source, - ['disks', 'list', zone]); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); - if (!disks) return zcb(); + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } - if (disks.err || !disks.data) { - if (!myError[region]) { - myError[region] = []; - } - myError[region].push(zone); - return zcb(); - } + var project = projects.data[0].name; + let disks = helpers.addSource(cache, source, + ['disks', 'aggregatedList', ['global']]); - if (!disks.data.length) { - if (!noDisks[region]) { - noDisks[region] = []; - } - noDisks[region].push(zone); - return zcb(); - } + if (!disks) return callback(null, results, source); - disks.data.forEach(disk => { + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', 'global', null, null, disks.err); + return callback(null, results, source); + } + + disks.data.forEach(diskData => { + regions.all_regions.forEach(region => { + var noDisks = []; + var zones = regions.zones; + let disksInRegion = []; + let regionData = diskData[`regions/${region}`]; + + if (regionData && regionData['disks'] && regionData['disks'].length) { + disksInRegion = diskData[`regions/${region}`].disks.map(disk => { return {...disk, locationType: 'region', location: region};}); + } + + zones[region].forEach(zone => { + let disksInZone = []; + let zoneData = diskData[`zones/${zone}`]; + + if (zoneData && zoneData['disks'] && zoneData['disks'].length) { + disksInZone = diskData[`zones/${zone}`].disks.map(disk => { return {...disk, locationType: 'zone', location: zone};}); + } + + if (!disksInZone.length) { + noDisks.push(zone); + } + + disksInRegion = disksInRegion.concat(disksInZone); + }); + + disksInRegion.forEach(disk => { + if (!disk.id) return; + + let resource = helpers.createResourceName('disks', disk.name, project, disk.locationType, disk.location); + if (disk.creationTimestamp && disk.diskEncryptionKey && Object.keys(disk.diskEncryptionKey) && Object.keys(disk.diskEncryptionKey).length) { - goodDisks.push(disk.id) + helpers.addResult(results, 0, + 'CSEK Encryption is enabled for disk', region, resource); } else if (disk.creationTimestamp) { - badDisks.push(disk.id) + helpers.addResult(results, 2, + 'CSEK Encryption is disabled for disk', region, resource); } }); + + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } }); - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query disks' , region); - } else if (!goodDisks.length && !badDisks.length) { - helpers.addResult(results, 0, 'No disks found in the region' , region); - } else if (badDisks.length) { - var myInstanceStr = badDisks.join(", "); - helpers.addResult(results, 2, - `CSEK Encryption is disabled for the following disks: ${myInstanceStr}`, region); - } else if (goodDisks.length) { - helpers.addResult(results, 0, - 'CSEK Encryption is enabled for all disks in the region', region); - } - rcb(); - }, function() { - callback(null, results, source); }); + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/google/compute/csekEncryptionEnabled.spec.js b/plugins/google/compute/csekEncryptionEnabled.spec.js index 2af2900ab..67a4b1627 100644 --- a/plugins/google/compute/csekEncryptionEnabled.spec.js +++ b/plugins/google/compute/csekEncryptionEnabled.spec.js @@ -2,63 +2,55 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./csekEncryptionEnabled'); -const createCache = (instanceData, instanceDatab, error) => { +const createCache = (instanceData, error) => { return { disks: { - list: { - 'us-central1-a': { - data: instanceData, - err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, + aggregatedList: { + 'global': { + data: [instanceData], err: error } } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } } } }; describe('csekEncryptionEnabled', function () { describe('run', function () { - - it('should give unknown if an instance error occurs', function (done) { + it('should give unknown if unable to query compute disks', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.include('Unable to query disks'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('global'); done() }; const cache = createCache( [], - [], - ['null'] + ['error'] ); plugin.run(cache, {}, callback); }); - it('should pass no VM Instances', function (done) { + it('should pass if No compute disks found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No disks found in the region'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-east1'); done() }; const cache = createCache( - [], [], null ); @@ -66,100 +58,72 @@ describe('csekEncryptionEnabled', function () { plugin.run(cache, {}, callback); }); - it('should fail csek encryption is disabled', function (done) { + it('should fail if CSEK Encryption is disabled for disk', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('CSEK Encryption is disabled for the following disks'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('CSEK Encryption is disabled for disk'); + expect(results[0].region).to.equal('us-east1'); done() }; const cache = createCache( - [ - { - "kind": "compute#disk", - "id": "5472446060006107254", - "creationTimestamp": "2019-09-25T14:05:30.090-07:00", - "name": "instance-2", - "sizeGb": "10", - "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", - "status": "READY", - "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-2", - "sourceImage": "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-9-stretch-v20190916", - "sourceImageId": "2382294996946296915", - "type": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/diskTypes/pd-standard", - "licenses": [ - "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" - ], - "guestOsFeatures": [ + { + "regions/us-east1": { + "disks": [ { - "type": "VIRTIO_SCSI_MULTIQUEUE" + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk" } - ], - "lastAttachTimestamp": "2019-09-25T14:05:30.090-07:00", - "users": [ - "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2" - ], - "labelFingerprint": "42WmSpB8rSM=", - "licenseCodes": [ - "1000205" - ], - "physicalBlockSizeBytes": "4096" - } - ], - [], - null + ] + }, + } ); plugin.run(cache, {}, callback); }) - it('should pass with block project-wide ssh key', function (done) { + it('should pass if CSEK Encryption is enabled for disk', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.equal('CSEK Encryption is enabled for all disks in the region'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('CSEK Encryption is enabled for disk'); + expect(results[0].region).to.equal('us-east1'); done() }; const cache = createCache( - [ - { - "kind": "compute#disk", - "id": "5472446060006107254", - "creationTimestamp": "2019-09-25T14:05:30.090-07:00", - "name": "instance-2", - "sizeGb": "10", - "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", - "status": "READY", - "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-2", - "sourceImage": "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-9-stretch-v20190916", - "sourceImageId": "2382294996946296915", - "type": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/diskTypes/pd-standard", - "licenses": [ - "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" - ], - "guestOsFeatures": [ + { + "regions/us-east1": { + "disks": [ { - "type": "VIRTIO_SCSI_MULTIQUEUE" + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk", + "diskEncryptionKey": { + 'key': 'true', + } } - ], - "lastAttachTimestamp": "2019-09-25T14:05:30.090-07:00", - "users": [ - "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2" - ], - "labelFingerprint": "42WmSpB8rSM=", - "licenseCodes": [ - "1000205" - ], - "physicalBlockSizeBytes": "4096", - "diskEncryptionKey": { - 'key': 'true', - } - } - ] + ] + }, + } ); plugin.run(cache, {}, callback); diff --git a/plugins/google/compute/deprecatedImages.js b/plugins/google/compute/deprecatedImages.js new file mode 100644 index 000000000..959ff9f2d --- /dev/null +++ b/plugins/google/compute/deprecatedImages.js @@ -0,0 +1,111 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Deprecated Images', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Compute instances are not created from deprecated images.', + more_info: 'Deprecated Compute Disk Images should not be used to create VM instances.', + link: 'https://cloud.google.com/compute/docs/images/image-management-best-practices', + recommended_action: 'Ensure that no compute instances are created from deprecated images.', + apis: ['compute:list', 'disks:list', 'images:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete','compute.disks.insert','compute.disks.delete', 'compute.images.insert', 'compute.images.delete', 'compute.images.deprecate'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + var images = helpers.addSource(cache, source, + ['images', 'list', 'global']); + + if (!images) return callback(null, results, source); + + if (images.err || !images.data) { + ('Unable to query for disk images: ' + helpers.addError(images), 'global', null, null, images.err); + return callback(null, results, source); + } + + if (!images.data.length) { + helpers.addResult(results, 0, 'No disk images found', 'global'); + return callback(null, results, source); + } + + async.each(regions.compute, (region, rcb) => { + var noInstances = []; + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + var disks = helpers.addSource(cache, source, + ['disks', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + if (!disks) return zcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', region, null, null, disks.err); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + let isDeprecatedImage = false; + if (instance.disks && instance.disks.length) { + let bootDisk = instance.disks.find(disk => disk.boot == true); + if (bootDisk) { + let diskInformation = disks.data.find(disk => disk.selfLink === bootDisk.source); + if (diskInformation) { + let diskImage = images.data.find(image => image.id == diskInformation.sourceImageId); + if (diskImage) { + if (diskImage.deprecated && diskImage.deprecated.state && diskImage.deprecated.state.toUpperCase() == 'DEPRECATED') { + isDeprecatedImage = true; + } + } + } + } + if (isDeprecatedImage) { + helpers.addResult(results, 2, + 'Instance is created from a deprecated image', region, resource); + } else { + helpers.addResult(results, 0, + 'Instance is not created from a deprecated image', region, resource); + } + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/deprecatedImages.spec.js b/plugins/google/compute/deprecatedImages.spec.js new file mode 100644 index 000000000..f9145497d --- /dev/null +++ b/plugins/google/compute/deprecatedImages.spec.js @@ -0,0 +1,220 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./deprecatedImages'); + +const createCache = (instanceData, error, diskData, diskError) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + images: { + list: { + "global": { + "data": [ + { + "id": '11511', + "creationTimestamp": '2021-10-09T05:58:36.754-07:00', + "name": 'image-1', + "sourceType": 'RAW', + "deprecated": { state: 'DEPRECATED' }, + "status": 'READY', + "diskSizeGb": '10', + "sourceDisk": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/disk-1', + "sourceDiskId": '11111', + "selfLink": 'https://www.googleapis.com/compute/v1/projects/my-project/global/images/image-1', + "storageLocations": [ 'us-central1' ], + "kind": 'compute#image' + }, + { + "id": '11611', + "creationTimestamp": '2021-10-09T05:58:36.754-07:00', + "name": 'image-2', + "sourceType": 'RAW', + "status": 'READY', + "diskSizeGb": '10', + "sourceDisk": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/disk-1', + "sourceDiskId": '11111', + "selfLink": 'https://www.googleapis.com/compute/v1/projects/my-project/global/images/image-2', + "storageLocations": [ 'us-central1' ], + "kind": 'compute#image' + }, + ] + } + } + }, + disks: { + list: { + 'us-central1-a': { + data: diskData, + err: diskError + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; +const diskData = [ + { + "id": '5678', + "creationTimestamp": '2021-10-09T06:10:38.389-07:00', + "name": 'instance-2', + "sizeGb": '10', + "zone": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a', + "status": 'READY', + "selfLink": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/instance-2', + "sourceImage": 'https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-10-buster-v20210916', + "sourceImageId": '11611', + "physicalBlockSizeBytes": '4096', + "kind": 'compute#disk' + }, + { + "id": '1234', + "creationTimestamp": '2021-10-09T05:59:46.571-07:00', + "name": 'instance-1', + "sizeGb": '10', + "zone": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a', + "status": 'READY', + "selfLink": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/instance-1', + "sourceImage": 'https://www.googleapis.com/compute/v1/projects/my-project/global/images/image-2', + "sourceImageId": '11511', + "kind": 'compute#disk' + } +] +describe('deprecatedImages', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'], + null, + diskData, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null, + diskData, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if instance is created from a deprecated image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance is created from a deprecated image'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": '1111111', + "creationTimestamp": '2021-10-09T05:59:46.533-07:00', + "name": 'instance-1', + "machineType": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/machineTypes/e2-micro', + "status": 'RUNNING', + "zone": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a', + "canIpForward": false, + "disks": [ + { + "type": 'PERSISTENT', + "mode": 'READ_WRITE', + "source": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/instance-1', + "deviceName": 'instance-1', + "index": 0, + "boot": true, + "autoDelete": true, + "interface": 'SCSI', + "diskSizeGb": '10', + "kind": 'compute#attachedDisk' + } + ], + "kind": 'compute#instance' + } + ], + null, + diskData, + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if instance is not created from a deprecated image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Instance is not created from a deprecated image'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": '1111', + "creationTimestamp": '2021-10-09T06:10:38.352-07:00', + "name": 'instance-2', + "disks": [ + { + "type": 'PERSISTENT', + "mode": 'READ_WRITE', + "source": 'https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/instance-2', + "deviceName": 'instance-2', + "index": 0, + "boot": true, + "autoDelete": true, + "diskSizeGb": '10', + "kind": 'compute#attachedDisk' + } + ], + "kind": 'compute#instance' + } + ], + null, + diskData, + null + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/diskAutomaticBackupEnabled.js b/plugins/google/compute/diskAutomaticBackupEnabled.js new file mode 100644 index 000000000..e4d37f6ab --- /dev/null +++ b/plugins/google/compute/diskAutomaticBackupEnabled.js @@ -0,0 +1,88 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disk Automatic Backup Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Google Compute disks have scheduled snapshots configured.', + more_info: 'Having scheduled snapshots configured for your disks will periodically backup data from your persistent disks.', + link: 'https://cloud.google.com/compute/docs/disks/scheduled-snapshots', + recommended_action: 'Ensure that all compute disks have a snapshot schedule attached.', + apis: ['disks:aggregatedList'], + realtime_triggers: ['compute.disks.insert','compute.disks.delete', 'compute.disk.addResourcePolicies', 'compute.disk.removeResourcePolicies'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let disks = helpers.addSource(cache, source, + ['disks', 'aggregatedList', ['global']]); + + if (!disks) return callback(null, results, source); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', 'global', null, null, disks.err); + return callback(null, results, source); + } + + disks.data.forEach(diskData => { + regions.all_regions.forEach(region => { + var noDisks = []; + var zones = regions.zones; + let disksInRegion = []; + let regionData = diskData[`regions/${region}`]; + + if (regionData && regionData['disks'] && regionData['disks'].length) { + disksInRegion = diskData[`regions/${region}`].disks.map(disk => { return {...disk, locationType: 'region', location: region};}); + } + + zones[region].forEach(zone => { + let disksInZone = []; + let zoneData = diskData[`zones/${zone}`]; + + if (zoneData && zoneData['disks'] && zoneData['disks'].length) { + disksInZone = diskData[`zones/${zone}`].disks.map(disk => { return {...disk, locationType: 'zone', location: zone};}); + } + + if (!disksInZone.length) { + noDisks.push(zone); + } + + disksInRegion = disksInRegion.concat(disksInZone); + }); + + disksInRegion.forEach(disk => { + if (!disk.id || !disk.creationTimestamp) return; + + let resource = helpers.createResourceName('disks', disk.name, project, disk.locationType, disk.location); + + if (disk.resourcePolicies && disk.resourcePolicies.length) { + helpers.addResult(results, 0, + 'Snapshot schedule is configured for disk', region, resource); + } else { + helpers.addResult(results, 2, + 'Snapshot schedule is not configured for disk', region, resource); + } + }); + + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + }); + }); + callback(null, results, source); + } +}; diff --git a/plugins/google/compute/diskAutomaticBackupEnabled.spec.js b/plugins/google/compute/diskAutomaticBackupEnabled.spec.js new file mode 100644 index 000000000..bfc650fc0 --- /dev/null +++ b/plugins/google/compute/diskAutomaticBackupEnabled.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./diskAutomaticBackupEnabled'); + +const createCache = (diskData, error) => { + return { + disks: { + aggregatedList: { + 'global': { + data: [diskData], + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('diskAutomaticBackupEnabled', function () { + describe('run', function () { + it('should give unknown if unable to query compute disks', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disks found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if snapshot schedule is not configured for disk', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Snapshot schedule is not configured for'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk" + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if snapshot schedule is configured for disk', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Snapshot schedule is configured for'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk", + "resourcePolicies": [ + 'https://www.googleapis.com/compute/v1/projects/my-project/regions/us-east1/resourcePolicies/schedule-1' + ], + } + ] + }, + } + ); + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/diskInUse.js b/plugins/google/compute/diskInUse.js new file mode 100644 index 000000000..f3f35352e --- /dev/null +++ b/plugins/google/compute/diskInUse.js @@ -0,0 +1,89 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disk In Use', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that there are no unused Compute disks.', + more_info: 'Unused Compute disks should be deleted to prevent accidental exposure of data and to avoid unnecessary billing.', + link: 'https://cloud.google.com/compute/docs/disks', + recommended_action: 'Delete unused Compute disks.', + apis: ['disks:aggregatedList'], + realtime_triggers: ['compute.disks.insert','compute.disks.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let disks = helpers.addSource(cache, source, + ['disks', 'aggregatedList', ['global']]); + + if (!disks) return callback(null, results, source); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', 'global', null, null, disks.err); + return callback(null, results, source); + } + + disks.data.forEach(diskData => { + regions.all_regions.forEach(region => { + var noDisks = []; + var zones = regions.zones; + let disksInRegion = []; + let regionData = diskData[`regions/${region}`]; + + if (regionData && regionData['disks'] && regionData['disks'].length) { + disksInRegion = diskData[`regions/${region}`].disks.map(disk => { return {...disk, locationType: 'region', location: region};}); + } + + zones[region].forEach(zone => { + let disksInZone = []; + let zoneData = diskData[`zones/${zone}`]; + + if (zoneData && zoneData['disks'] && zoneData['disks'].length) { + disksInZone = diskData[`zones/${zone}`].disks.map(disk => { return {...disk, locationType: 'zone', location: zone};}); + } + + if (!disksInZone.length) { + noDisks.push(zone); + } + + disksInRegion = disksInRegion.concat(disksInZone); + }); + + disksInRegion.forEach(disk => { + if (!disk.id || !disk.creationTimestamp) return; + + let resource = helpers.createResourceName('disks', disk.name, project, disk.locationType, disk.location); + + if (disk.users && disk.users.length) { + helpers.addResult(results, 0, + 'Disk is in use', region, resource); + } else { + helpers.addResult(results, 2, + 'Disk is not in use', region, resource); + } + }); + + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + }); + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/diskInUse.spec.js b/plugins/google/compute/diskInUse.spec.js new file mode 100644 index 000000000..e4dce04b6 --- /dev/null +++ b/plugins/google/compute/diskInUse.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var plugin = require('./diskInUse'); + +const createCache = (diskData, error) => { + return { + disks: { + aggregatedList: { + 'global': { + data: [diskData], + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('diskInUse', function () { + describe('run', function () { + it('should give unknown if unable to query compute disks', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disks found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if disk is not in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('not in use'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk", + "resourcePolicies": [ + 'https://www.googleapis.com/compute/v1/projects/my-project/regions/us-east1/resourcePolicies/schedule-1' + ], + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if disk is in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('in use'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk", + "users": [ + "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-a/instances/instance-2" + ], + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); diff --git a/plugins/google/compute/diskLabelsAdded.js b/plugins/google/compute/diskLabelsAdded.js new file mode 100644 index 000000000..0aea8876d --- /dev/null +++ b/plugins/google/compute/diskLabelsAdded.js @@ -0,0 +1,91 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disk Labels Added', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that all Compute Disks have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/compute/docs/labeling-resources', + recommended_action: 'Ensure labels are added to all Compute Disks.', + apis: ['disks:aggregatedList'], + realtime_triggers: ['compute.disks.insert','compute.disks.delete', 'compute.disks.setlabels'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let disks = helpers.addSource(cache, source, + ['disks', 'aggregatedList', ['global']]); + + if (!disks) return callback(null, results, source); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', 'global', null, null, disks.err); + return callback(null, results, source); + } + + disks.data.forEach(diskData => { + regions.all_regions.forEach(region => { + var noDisks = []; + var zones = regions.zones; + let disksInRegion = []; + let regionData = diskData[`regions/${region}`]; + + if (regionData && regionData['disks'] && regionData['disks'].length) { + disksInRegion = diskData[`regions/${region}`].disks.map(disk => { return {...disk, locationType: 'region', location: region};}); + } + + zones[region].forEach(zone => { + let disksInZone = []; + let zoneData = diskData[`zones/${zone}`]; + + if (zoneData && zoneData['disks'] && zoneData['disks'].length) { + disksInZone = diskData[`zones/${zone}`].disks.map(disk => { return {...disk, locationType: 'zone', location: zone};}); + } + + if (!disksInZone.length) { + noDisks.push(zone); + } + + disksInRegion = disksInRegion.concat(disksInZone); + }); + + disksInRegion.forEach(disk => { + if (!disk.id || !disk.creationTimestamp) return; + + let resource = helpers.createResourceName('disks', disk.name, project, disk.locationType, disk.location); + + if (disk.labels && + Object.keys(disk.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(disk.labels).length} labels found for compute disk`, region, resource); + } else { + helpers.addResult(results, 2, + 'Compute disk does not have any labels', region, resource); + } + + }); + + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + }); + }); + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/diskLabelsAdded.spec.js b/plugins/google/compute/diskLabelsAdded.spec.js new file mode 100644 index 000000000..a0e38ba0b --- /dev/null +++ b/plugins/google/compute/diskLabelsAdded.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +var plugin = require('./diskLabelsAdded'); + +const createCache = (diskData, error) => { + return { + disks: { + aggregatedList: { + 'global': { + data: [diskData], + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('diskLabelsAdded', function () { + describe('run', function () { + it('should give unknown if unable to query compute disks', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disks found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if disk does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk", + "resourcePolicies": [ + 'https://www.googleapis.com/compute/v1/projects/my-project/regions/us-east1/resourcePolicies/schedule-1' + ], + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if disk has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for compute disk'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "labels": {"test": "test"}, + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk" + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); diff --git a/plugins/google/compute/diskMultiAz.js b/plugins/google/compute/diskMultiAz.js new file mode 100644 index 000000000..835e738fb --- /dev/null +++ b/plugins/google/compute/diskMultiAz.js @@ -0,0 +1,90 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disk MultiAz', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Compute disks have regional disk replication feature enabled for high availability.', + more_info: 'Enabling regional disk replication will allow you to force attach a regional persistent disk to another VM instance in a different zone in the same region in case of a zonal outage.', + link: 'https://cloud.google.com/compute/docs/disks/high-availability-regional-persistent-disk', + recommended_action: 'Ensure that all Google compute disks have replica zones configured.', + apis: ['disks:aggregatedList'], + realtime_triggers: ['compute.disks.insert','compute.disks.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let disks = helpers.addSource(cache, source, + ['disks', 'aggregatedList', ['global']]); + + if (!disks) return callback(null, results, source); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query compute disks', 'global', null, null, disks.err); + return callback(null, results, source); + } + + disks.data.forEach(diskData => { + regions.all_regions.forEach(region => { + var noDisks = []; + var zones = regions.zones; + let disksInRegion = []; + let regionData = diskData[`regions/${region}`]; + + if (regionData && regionData['disks'] && regionData['disks'].length) { + disksInRegion = diskData[`regions/${region}`].disks.map(disk => { return {...disk, locationType: 'region', location: region};}); + } + + zones[region].forEach(zone => { + let disksInZone = []; + let zoneData = diskData[`zones/${zone}`]; + + if (zoneData && zoneData['disks'] && zoneData['disks'].length) { + disksInZone = diskData[`zones/${zone}`].disks.map(disk => { return {...disk, locationType: 'zone', location: zone};}); + } + + if (!disksInZone.length) { + noDisks.push(zone); + } + + disksInRegion = disksInRegion.concat(disksInZone); + }); + + disksInRegion.forEach(disk => { + if (!disk.id || !disk.creationTimestamp) return; + + let resource = helpers.createResourceName('disks', disk.name, project, disk.locationType, disk.location); + + if (disk && disk.replicaZones && disk.replicaZones.length) { + helpers.addResult(results, 0, + 'Regional Disk Replication is enabled for disk', region, resource); + } else { + helpers.addResult(results, 2, + 'Regional Disk Replication is not enabled for disk', region, resource); + } + }); + + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + }); + }); + + callback(null, results, source); + } +}; + diff --git a/plugins/google/compute/diskMultiAz.spec.js b/plugins/google/compute/diskMultiAz.spec.js new file mode 100644 index 000000000..df1590dd0 --- /dev/null +++ b/plugins/google/compute/diskMultiAz.spec.js @@ -0,0 +1,147 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./diskMultiAz'); + +const createCache = (diskData, error) => { + return { + disks: { + aggregatedList: { + 'global': { + data: [diskData], + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('diskMultiAz', function () { + describe('run', function () { + it('should give unknown if unable to query compute disks', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disks found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "warning": { + "code": "NO_RESULTS_ON_PAGE", + "message": "There are no results for scope 'regions/us-central1' on this page.", + "data": [ + { + "key": "scope", + "value": "regions/us-central1" + } + ] + } + } + }, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if regional replication is not enabled for disk', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enabled'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk" + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if regional replication is enabled for disk', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled for'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + { + "regions/us-east1": { + "disks": [ + { + "id": "1111111", + "creationTimestamp": "2021-09-23T12:58:54.065-07:00", + "name": "disk-1", + "sizeGb": "10", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/disks/disk-1", + "type": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1/diskTypes/pd-balanced", + "labelFingerprint": "42WmSpB8rSM=", + "region": "https://www.googleapis.com/compute/v1/projects/my-test-project/regions/us-east1", + "replicaZones": [ + "https://www.googleapis.com/compute/v1/projects/my-test-project/zones/us-east1-a", + "https://www.googleapis.com/compute/v1/projects/my-test-project/zones/us-east1-c" + ], + "physicalBlockSizeBytes": "4096", + "kind": "compute#disk" + } + ] + }, + } + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/diskOldSnapshots.js b/plugins/google/compute/diskOldSnapshots.js new file mode 100644 index 000000000..d45107e7b --- /dev/null +++ b/plugins/google/compute/diskOldSnapshots.js @@ -0,0 +1,80 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Disk Old Snapshots', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Compute disk snapshots are deleted after defined time period.', + more_info: 'To optimize storage costs, make sure that there are no old disk snapshots in your GCP project.', + link: 'https://cloud.google.com/compute/docs/disks/create-snapshots', + recommended_action: 'Ensure that there are no snapshots older than specified number of days.', + apis: ['snapshots:list'], + settings: { + compute_disk_snapshot_life: { + name: 'Disk Snapshot Result Life', + description: 'Disk Snapshot will FAIL if its creation date is before this number of days in the past', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30', + }, + }, + realtime_triggers: ['compute.snapshots.insert', 'compute.snapshots.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var number_of_days = parseInt(settings.compute_disk_snapshot_life || this.settings.compute_disk_snapshot_life.default); + + var project = projects.data[0].name; + + let snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', 'global']); + + if (!snapshots) return callback(null, results, source); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for disk snapshots: ' + helpers.addError(snapshots), 'global'); + return callback(null, results, source); + } + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No disk snapshots found', 'global'); + return callback(null, results, source); + } + + var snapshotsFound = false; + + snapshots.data.forEach(snapshot => { + if (snapshot.creationTimestamp) { + + snapshotsFound = true; + const daysSinceCreation = helpers.daysBetween(new Date(), new Date(snapshot.creationTimestamp)); + let resource = helpers.createResourceName('snapshot', snapshot.name, project, 'global'); + + if (daysSinceCreation > number_of_days) { + helpers.addResult(results, 2, + `Disk snapshot is more than ${number_of_days} days old`, 'global', resource); + } else { + helpers.addResult(results, 0, + `Disk snapshot is less than ${number_of_days} days old`, 'global', resource); + } + } + }); + + if (!snapshotsFound) { + helpers.addResult(results, 0, 'No snapshots found in the project', 'global', project); + } + + callback(null, results, source); + + } +}; \ No newline at end of file diff --git a/plugins/google/compute/diskOldSnapshots.spec.js b/plugins/google/compute/diskOldSnapshots.spec.js new file mode 100644 index 000000000..d0be81b53 --- /dev/null +++ b/plugins/google/compute/diskOldSnapshots.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var plugin = require('./diskOldSnapshots'); + +const createCache = (snapshotData, error) => { + return { + snapshots: { + list: { + 'global': { + data: snapshotData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('diskOldSnapshots', function () { + describe('run', function () { + it('should give unknown if unable to query compute disk snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for disk snapshots'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disk snapshots found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No disk snapshots found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if snapshot is older than specified number of days', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('more than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: '2020-09-08T01:48:16.346-07:00', + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if snapshot is not older than specified number of days', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: new Date(), + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/enableUsageExport.js b/plugins/google/compute/enableUsageExport.js new file mode 100644 index 000000000..aeafe015b --- /dev/null +++ b/plugins/google/compute/enableUsageExport.js @@ -0,0 +1,42 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Enable Usage Export', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that setting is configured to export Compute instances usage to Cloud Storage bucket.', + link: 'https://cloud.google.com/compute/docs/logging/usage-export', + more_info: 'Compute Engine lets you export detailed reports that provide information about the lifetime and usage of your Compute Engine resources to a Google Cloud Storage bucket using the usage export feature.', + recommended_action: 'Ensure that Enable Usage Export setting is configured for your GCP project.', + apis: ['projects:get'], + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0]; + + let resource = helpers.createResourceName('projects', project.name); + + if (project.usageExportLocation && project.usageExportLocation.bucketName) { + helpers.addResult(results, 0, 'Enable Usage Export is configured for project', 'global', resource); + } else { + helpers.addResult(results, 2, 'Enable Usage Export is not configured for project', 'global', resource); + } + + return callback(null, results, source); + + + } +}; diff --git a/plugins/google/compute/enableUsageExport.spec.js b/plugins/google/compute/enableUsageExport.spec.js new file mode 100644 index 000000000..6b482f094 --- /dev/null +++ b/plugins/google/compute/enableUsageExport.spec.js @@ -0,0 +1,90 @@ +var expect = require('chai').expect; +var plugin = require('./enableUsageExport'); + +const createCache = (projectData, error) => { + return { + projects: { + get: { + 'global': { + data: projectData, + error: error + } + } + } + } +} + +describe('enableUsageExport', function () { + describe('run', function () { + it('should return unknown if a project error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for projects'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + + it('should fail if usage export is diabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Enable Usage Export is not configured for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + kind: 'compute#project', + id: '11111', + creationTimestamp: '2021-05-07T12:09:27.812-07:00', + name: 'project-1', + xpnProjectStatus: 'UNSPECIFIED_XPN_PROJECT_STATUS', + defaultNetworkTier: 'PREMIUM' + } + ], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if usage export is enabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Enable Usage Export is configured for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + kind: 'compute#project', + id: '11111', + creationTimestamp: '2021-05-07T12:09:27.812-07:00', + name: 'project-1', + usageExportLocation: { bucketName: 'project-1-my-bucket-1', reportNamePrefix: '' }, + xpnProjectStatus: 'UNSPECIFIED_XPN_PROJECT_STATUS', + defaultNetworkTier: 'PREMIUM' + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/frequentlyUsedSnapshots.js b/plugins/google/compute/frequentlyUsedSnapshots.js new file mode 100644 index 000000000..869a5191b --- /dev/null +++ b/plugins/google/compute/frequentlyUsedSnapshots.js @@ -0,0 +1,91 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Frequently Used Snapshots', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that frequently used disks are created from images instead of snapshots to save networking cost.', + more_info: 'If you are repeatedly using a snapshot in the same zone to create a persistent disk, save networking costs by using the snapshot once and creating an image of that snapshot. Store this image and use it to create your disk and start a VM instance.', + link: 'https://cloud.google.com/compute/docs/disks/snapshot-best-practices#prepare_for_consistency', + recommended_action: 'Ensure that your disk snapshots have images created from them.', + apis: ['snapshots:list', 'images:list'], + settings: { + snapshots_to_check: { + name: 'Snapshots to Check for Images', + description: 'Comma separated string of snapshot names.', + regex: '^([a-zA-Z0-9]+,)+$', + default: '' + } + }, + realtime_triggers: ['compute.snapshots.insert', 'compute.snapshots.delete','compute.images.insert', 'compute.images.delete' ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var snapshots_to_check = settings.snapshots_to_check || this.settings.snapshots_to_check.default; + + if (!snapshots_to_check.length) return callback(null, results, source); + + snapshots_to_check = snapshots_to_check.split(','); + var project = projects.data[0].name; + + let snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', 'global']); + + if (!snapshots) return callback(null, results, source); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for disk snapshots: ' + helpers.addError(snapshots), 'global'); + return callback(null, results, source); + } + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No disk snapshots found', 'global'); + return callback(null, results, source); + } + + let images = helpers.addSource(cache, source, + ['images', 'list', 'global']); + + if (!images) return callback(null, results, source); + + if (images.err || !images.data) { + helpers.addResult(results, 3, 'Unable to query for images: ' + helpers.addError(images), 'global'); + return callback(null, results, source); + } + + let snapshotsToCheck = snapshots.data.filter(snapshot => snapshot.name && snapshots_to_check.includes(snapshot.name)); + + if (!snapshotsToCheck || !snapshotsToCheck.length) { + helpers.addResult(results, 0, 'Nothing to check', 'global', project); + return callback(null, results, source); + } + + snapshotsToCheck.forEach(snapshot => { + + let resource = helpers.createResourceName('snapshot', snapshot.name, project, 'global'); + + if (snapshot.id && (images.data && images.data.length && images.data.find(image => image.sourceSnapshotId && image.sourceSnapshotId == snapshot.id))) { + helpers.addResult(results, 0, + 'Disk snapshot has an image created', 'global', resource); + } else { + helpers.addResult(results, 2, + 'Disk snapshot does not have an image created', 'global', resource); + } + + }); + + callback(null, results, source); + + } +}; \ No newline at end of file diff --git a/plugins/google/compute/frequentlyUsedSnapshots.spec.js b/plugins/google/compute/frequentlyUsedSnapshots.spec.js new file mode 100644 index 000000000..2e9c9c325 --- /dev/null +++ b/plugins/google/compute/frequentlyUsedSnapshots.spec.js @@ -0,0 +1,195 @@ +var expect = require('chai').expect; +var plugin = require('./frequentlyUsedSnapshots'); + +const createCache = (snapshotData, error, imageData, imageError) => { + return { + snapshots: { + list: { + 'global': { + data: snapshotData, + err: error + } + } + }, + images: { + list: { + 'global': { + data: imageData, + err: imageError + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('frequentlyUsedSnapshots', function () { + describe('run', function () { + it('should give unknown if unable to query compute disk snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for disk snapshots'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'], + [], + null + ); + + plugin.run(cache, {snapshots_to_check: 'snapshot-1'}, callback); + }); + + it('should pass if No compute disk snapshots found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No disk snapshots found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + [], + null + ); + + plugin.run(cache, {snapshots_to_check: 'snapshot-1'}, callback); + }); + + it('should give unknown if unable to query images', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for images'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: '2020-09-08T01:48:16.346-07:00', + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ], + null, + [], + ['error'] + ); + + plugin.run(cache, {snapshots_to_check: 'snapshot-1'}, callback); + }); + + it('should fail if snapshot does not have an image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('not have an image created'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: '2020-09-08T01:48:16.346-07:00', + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ], + null, + [], + null + ); + + plugin.run(cache, {snapshots_to_check: 'snapshot-1'}, callback); + }) + + it('should pass if snapshot has an image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('an image created'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '111151', + creationTimestamp: new Date(), + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ], + null, + [ + { + id: '7777', + creationTimestamp: '2021-10-04T13:00:27.621-07:00', + name: 'image-1', + sourceType: 'RAW', + status: 'READY', + diskSizeGb: '10', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/images/image-1', + labelFingerprint: '42WmSpB8rSM=', + sourceSnapshot: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + sourceSnapshotId: '111151', + storageLocations: ['us-central1'], + kind: 'compute#image' + } + ] + ); + + plugin.run(cache, {snapshots_to_check: 'snapshot-1'}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/imageLabelsAdded.js b/plugins/google/compute/imageLabelsAdded.js new file mode 100644 index 000000000..29d474f0a --- /dev/null +++ b/plugins/google/compute/imageLabelsAdded.js @@ -0,0 +1,59 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Image Labels Added', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that all VM disk images have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/compute/docs/labeling-resources', + recommended_action: 'Ensure labels are added to all disk images.', + apis: ['images:list'], + realtime_triggers: ['compute.images.insert', 'compute.images.delete', 'compute.images.setLabels' ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let images = helpers.addSource(cache, source, + ['images', 'list', 'global']); + + if (!images || images.err || !images.data) { + helpers.addResult(results, 3, 'Unable to query Disk Images: ' + helpers.addError(images), 'global'); + return callback(null, results, source); + } + + if (!images.data.length) { + helpers.addResult(results, 0, 'No Disk Images found', 'global'); + return callback(null, results, source); + } + + images.data.forEach(image => { + + let resource = helpers.createResourceName('images', image.name, project, 'global'); + + if (image.labels && + Object.keys(image.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(image.labels).length} labels found for disk image`, 'global', resource); + } else { + helpers.addResult(results, 2, + 'Disk image does not have any labels', 'global', resource); + } + + }); + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/imageLabelsAdded.spec.js b/plugins/google/compute/imageLabelsAdded.spec.js new file mode 100644 index 000000000..487dc18ca --- /dev/null +++ b/plugins/google/compute/imageLabelsAdded.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +var plugin = require('./imageLabelsAdded'); + +const images = [ + { + "id": "1231", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-1", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-1", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-1", + "kind": "compute#image" + }, + { + "id": 1232, + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-2", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-2", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-2", + "labels": {"label-1": "test"}, + "kind": "compute#image" + } +]; +const createCache = (imageData, error) => { + return { + images: { + list: { + 'global': { + data: imageData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('imageLabelsAdded', function () { + describe('run', function () { + + it('should give unknown if unable to query disk images', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Disk Images'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no disk images found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Disk Images found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if disk image does not have any labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if labels are added for disk image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for disk image'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/imagesCMKEncrypted.js b/plugins/google/compute/imagesCMKEncrypted.js new file mode 100644 index 000000000..26d586250 --- /dev/null +++ b/plugins/google/compute/imagesCMKEncrypted.js @@ -0,0 +1,104 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Images CMK Encrypted', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure Compute Images are encrypted using Customer Managed or Supplied Keys', + more_info: 'Compute Images are encrypted by default using the Google-managed encryption keys. However, for highly sensitive images and more control over the encryption and decryption process, use either customer-managed keys or customer-supplied keys for encryption.', + link: 'https://cloud.google.com/compute/docs/disks/customer-supplied-encryption', + recommended_action: 'Ensure that all Compute Images are encrypted using desired protection level.', + apis: ['images:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + image_encryption_level: { + name: 'Image Encryption Protection Level', + description: 'Desired protection level for Compute Image. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + }, + }, + realtime_triggers: ['compute.images.insert', 'compute.images.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.image_encryption_level || this.settings.image_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) { + helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + } + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + + let images = helpers.addSource(cache, source, + ['images', 'list', 'global']); + + if (!images || images.err || !images.data) { + helpers.addResult(results, 3, 'Unable to query Compute Images: ' + helpers.addError(images), 'global'); + return callback(null, results, source); + } + + if (!images.data.length) { + helpers.addResult(results, 0, 'No Compute Images found', 'global'); + return callback(null, results, source); + } + + images.data.forEach(image => { + + let resource = helpers.createResourceName('images', image.name, project, 'global'); + let currentEncryptionLevel = 1; // default + + if (image.imageEncryptionKey && image.imageEncryptionKey.kmsKeyName) { + let keyName = Object.keys(keysObj).find(key => image.imageEncryptionKey.kmsKeyName.includes(key)); + if (keyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[keyName], helpers.PROTECTION_LEVELS); + } + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Compute Image has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, 'global', resource); + } else { + helpers.addResult(results, 2, + `Compute Image has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, 'global', resource); + } + }); + + cb(); + } + ], function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/imagesCMKEncrypted.spec.js b/plugins/google/compute/imagesCMKEncrypted.spec.js new file mode 100644 index 000000000..257df7b63 --- /dev/null +++ b/plugins/google/compute/imagesCMKEncrypted.spec.js @@ -0,0 +1,160 @@ +var expect = require('chai').expect; +var plugin = require('./imagesCMKEncrypted'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const images = [ + { + "id": "1231", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-1", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-1", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-1", + "imageEncryptionKey": { + "kmsKeyName": "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2/cryptoKeyVersions/1" + }, + "kind": "compute#image" + }, + { + "id": 1232, + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-2", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-2", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-2", + "labels": {"label-1": "test"}, + "kind": "compute#image" + } +]; + +const createCache = (imageData, error, keysErr, keysList) => { + return { + + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + images: { + list: { + 'global': { + data: imageData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('imagesCMKEncrypted', function () { + describe('run', function () { + + it('should give unknown if unable to query compute images', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Compute Images'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'], + null, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no compute images found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Compute Images found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + null, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if compute image has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[0]], + null, + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if compute image does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[1]], + null, + null, + cryptoKeys + ); + + + plugin.run(cache, {}, callback); + }) + }) +}); diff --git a/plugins/google/compute/instanceDefaultServiceAccount.js b/plugins/google/compute/instanceDefaultServiceAccount.js new file mode 100644 index 000000000..0df11a32f --- /dev/null +++ b/plugins/google/compute/instanceDefaultServiceAccount.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Default Service Account', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that compute instances are not configured to use the default service account.', + more_info: 'Default service account has the editor role permissions. Due to security reasons it should not be used for any instance.', + link: 'https://cloud.google.com/compute/docs/access/service-accounts', + recommended_action: 'Make sure that compute instances are not using default service account', + apis: ['compute:list', 'projects:get'], + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete', 'compute.instances.insert', 'compute.instances.delete', 'compute.instances.setservicezccount'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects) return callback(null, results, source); + + if (projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global'); + return callback(null, results, source); + } + + if (!projects.data.length) { + helpers.addResult(results, 0, 'No projects found', 'global'); + return callback(null, results, source); + } + + var defaultServiceAccount = projects.data[0].defaultServiceAccount; + var project = projects.data[0].name; + + if (!defaultServiceAccount) return callback(null, results, source); + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], (zone, zcb) => { + var instances = helpers.addSource(cache, source, + ['compute','list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let found; + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (instance.serviceAccounts && + instance.serviceAccounts.length) { + found = instance.serviceAccounts.find(account => account.email == defaultServiceAccount); + } + if (found) { + helpers.addResult(results, 2, + 'Default service account is used for instance', region, resource); + } else { + helpers.addResult(results, 0, + 'Default service account is not used for instance', region, resource); + } + }); + + zcb(); + }, function(){ + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/instanceDefaultServiceAccount.spec.js b/plugins/google/compute/instanceDefaultServiceAccount.spec.js new file mode 100644 index 000000000..f18dfdfd7 --- /dev/null +++ b/plugins/google/compute/instanceDefaultServiceAccount.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var plugin = require('./instanceDefaultServiceAccount'); + +const createCache = (instanceData, projectData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects : { + get: { + 'global': { + data: projectData + } + } + } + } +}; + +describe('instanceDefaultServiceAccount', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + [ + { + kind: "compute#project", + id: "00000111112222233333", + defaultServiceAccount: "779980017373-compute@developer.gserviceaccount.com", + } + ], + { message: 'error'} + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + [ + { + kind: "compute#project", + id: "00000111112222233333", + defaultServiceAccount: "779980017373-compute@developer.gserviceaccount.com", + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if any compute instance is using the default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default service account is used for instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "1719867382827328572", + name: "testing-instance", + serviceAccounts: [ + { + email: "779980017373-compute@developer.gserviceaccount.com", + scopes: [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring.write", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append", + ], + }, + ], + } + ], + [ + { + kind: "compute#project", + id: "00000111112222233333", + defaultServiceAccount: "779980017373-compute@developer.gserviceaccount.com", + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if the compute instance is not using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default service account is not used for instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "000001111112222222", + name: "testing-instance2", + } + ], + [ + { + kind: "compute#project", + id: "00000111112222233333", + defaultServiceAccount: "779980017373-compute@developer.gserviceaccount.com", + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/instanceDeletionProtection.js b/plugins/google/compute/instanceDeletionProtection.js new file mode 100644 index 000000000..076bc9b44 --- /dev/null +++ b/plugins/google/compute/instanceDeletionProtection.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'VM Instance Deletion Protection', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Virtual Machine instances have deletion protection enabled.', + more_info: 'VM instances should have deletion protection enabled in order to prevent them for being accidentally deleted.', + link: 'https://cloud.google.com/compute/docs/instances/preventing-accidental-vm-deletion', + recommended_action: 'Modify VM instances to enable deletion protection', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setDeletionProtection'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + if (!instance.deletionProtection) { + helpers.addResult(results, 2, + 'Instance deletion protection is disabled', region, resource); + } else { + helpers.addResult(results, 0, + 'Instance deletion protection is enabled', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/instanceDeletionProtection.spec.js b/plugins/google/compute/instanceDeletionProtection.spec.js new file mode 100644 index 000000000..efbbbb32d --- /dev/null +++ b/plugins/google/compute/instanceDeletionProtection.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var plugin = require('./instanceDeletionProtection'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } + } + } +}; + +describe('instanceDeletionProtection', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if instance deletion protection is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance deletion protection is disabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "canIpForward": false, + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "kind": "compute#instance" + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if instance deletion protection is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Instance deletion protection is enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "canIpForward": false, + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": true, + "kind": "compute#instance" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/instanceDesiredMachineTypes.js b/plugins/google/compute/instanceDesiredMachineTypes.js new file mode 100644 index 000000000..6dd413229 --- /dev/null +++ b/plugins/google/compute/instanceDesiredMachineTypes.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Desired Machine Type', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensures that Virtual Machine instances are of given types.', + more_info: 'Virtual Machine instance should be of the given types to ensure the internal compliance and prevent unexpected billing charges.', + link: 'https://cloud.google.com/compute/docs/machine-types', + recommended_action: 'Stop the Virtual Machine instance, change the machine type to the desired type and restart the instance.', + apis: ['compute:list'], + settings: { + instance_desired_machine_types: { + name: 'Instance Desired Machine Types', + description: 'Desired Virtual Machine instance type', + regex: '^.*$', + default: '' + }, + }, + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setMachineType'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + var instance_desired_machine_types = settings.instance_desired_machine_types || this.settings.instance_desired_machine_types.default; + if (!instance_desired_machine_types.length) return callback(null, results, source); + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + var project = projects.data[0].name; + async.each(regions.compute, (region, rcb) => { + var noInstances = []; + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (instance.machineType && instance_desired_machine_types.includes((instance.machineType.split('machineTypes/')[1]))) { + helpers.addResult(results, 0, + 'Virtual Machine instance has desired machine type', region, resource); + } else { + helpers.addResult(results, 2, + 'Virtual Machine instance does not have desired machine type', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/instanceDesiredMachineTypes.spec.js b/plugins/google/compute/instanceDesiredMachineTypes.spec.js new file mode 100644 index 000000000..0c26e2b3b --- /dev/null +++ b/plugins/google/compute/instanceDesiredMachineTypes.spec.js @@ -0,0 +1,115 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instanceDesiredMachineTypes'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instanceDesiredMachineTypes', function () { + describe('run', function () { + const settings = { instance_desired_machine_types: 'e2-micro' }; + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, settings, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, settings, callback); + }); + + it('should FAIL if VM instance is not of the desired machine type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Virtual Machine instance does not have desired machine type'); + done() + }; + const cache = createCache( + [ + { + "kind": "compute#instance", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + } + ], + null + ); + + plugin.run(cache, settings, callback); + }) + + it('should PASS if VM instance is of the desired machine type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Virtual Machine instance has desired machine type'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/e2-micro", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": false + } + ] + ); + + plugin.run(cache, settings, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/instanceGroupAutoHealing.js b/plugins/google/compute/instanceGroupAutoHealing.js new file mode 100644 index 000000000..327f25093 --- /dev/null +++ b/plugins/google/compute/instanceGroupAutoHealing.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Group Auto Healing Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure that instance groups have auto-healing enabled for high availability.', + more_info: 'To improve the availability of your application, configure a health check to verify that the application is responding as expected.', + link: 'https://cloud.google.com/compute/docs/instance-groups/autohealing-instances-in-migs', + recommended_action: 'Ensure autohealing is enabled for all instance groups.', + apis: ['instanceGroupManagers:list'], + realtime_triggers: ['compute.instancegroups.insert', 'compute.instancegroups.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.instanceGroupManagers, (region, rcb) => { + var zones = regions.zones; + var noInstanceGroups = []; + async.each(zones[region], function(zone, zcb) { + var instanceGroupManagers = helpers.addSource(cache, source, + ['instanceGroupManagers', 'list', zone]); + + if (!instanceGroupManagers) return zcb(); + + if (instanceGroupManagers.err || !instanceGroupManagers.data) { + helpers.addResult(results, 3, 'Unable to query instance groups', region, null, null, instanceGroupManagers.err); + return zcb(); + } + + if (!instanceGroupManagers.data.length) { + noInstanceGroups.push(zone); + return zcb(); + } + + instanceGroupManagers.data.forEach(instanceGroupManager => { + if (!instanceGroupManager.id || !instanceGroupManager.creationTimestamp) return; + + let resource = helpers.createResourceName('instanceGroupManagers', instanceGroupManager.name, project, 'zone', zone); + + if (instanceGroupManager.autoHealingPolicies && instanceGroupManager.autoHealingPolicies.length) { + helpers.addResult(results, 0, + 'Instance Group has auto healing enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Instance Group does not have auto healing enabled', region, resource); + } + }); + zcb(); + }, function() { + if (noInstanceGroups.length) { + helpers.addResult(results, 0, `No instance groups found in following zones: ${noInstanceGroups.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/instanceGroupAutoHealing.spec.js b/plugins/google/compute/instanceGroupAutoHealing.spec.js new file mode 100644 index 000000000..fc11d33fa --- /dev/null +++ b/plugins/google/compute/instanceGroupAutoHealing.spec.js @@ -0,0 +1,120 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instanceGroupAutoHealing'); + +const createCache = (instanceData, error) => { + return { + instanceGroupManagers: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instanceGroupAutohealing', function () { + describe('run', function () { + const settings = { instance_desired_machine_types: 'e2-micro' }; + it('should give unknown if an instance group error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instance groups'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, settings, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instance groups found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, settings, callback); + }); + + it('should FAIL if instance group does not have auto healing enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Group does not have auto healing enabled'); + done() + }; + const cache = createCache( + [ + { + "id": '11111111', + "creationTimestamp": '2022-01-05T12:19:12.147-08:00', + "name": 'instance-group-1', + "description": '', + "zone": 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a', + "instanceGroup": 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/instanceGroups/instance-group-1', + "baseInstanceName": 'instance-group-1', + "autoHealingPolicies": [] + } + ], + null + ); + + plugin.run(cache, settings, callback); + }) + + it('should PASS if instance group has auto healing enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Instance Group has auto healing enabled'); + done() + }; + + const cache = createCache( + [ + { + "id": '11111111', + "creationTimestamp": '2022-01-05T12:19:12.147-08:00', + "name": 'instance-group-1', + "description": '', + "zone": 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a', + "instanceGroup": 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/instanceGroups/instance-group-1', + "baseInstanceName": 'instance-group-1', + "autoHealingPolicies": [ + { + "healthCheck": 'https://www.googleapis.com/compute/v1/projects/my-project/global/healthChecks/hc-1', + "initialDelaySec": 300 + } + ] + } + ] + ); + + plugin.run(cache, settings, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/instanceLabelsAdded.js b/plugins/google/compute/instanceLabelsAdded.js new file mode 100644 index 000000000..217f5f77f --- /dev/null +++ b/plugins/google/compute/instanceLabelsAdded.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Labels Added', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that all Virtual Machine instances have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/compute/docs/labeling-resources', + recommended_action: 'Ensure labels are added to all VM instances.', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setLabels'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + if (instance.labels && + Object.keys(instance.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(instance.labels).length} labels found for VM instance.`, region, resource); + } else { + helpers.addResult(results, 2, + 'VM instance does not have any labels', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/instanceLabelsAdded.spec.js b/plugins/google/compute/instanceLabelsAdded.spec.js new file mode 100644 index 000000000..3c6f65039 --- /dev/null +++ b/plugins/google/compute/instanceLabelsAdded.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var plugin = require('./instanceLabelsAdded'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } + } + } +}; + +describe('instanceLabelsAdded', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if instance does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "canIpForward": false, + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "deletionProtection": false, + "kind": "compute#instance" + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if instance has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for VM instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "canIpForward": false, + "labelFingerprint": "42WmSpB8rSM=", + "startRestricted": false, + "labels": {"test": "test"}, + "kind": "compute#instance" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/instanceLeastPrivilege.js b/plugins/google/compute/instanceLeastPrivilege.js index 3ab9140c9..654b34186 100644 --- a/plugins/google/compute/instanceLeastPrivilege.js +++ b/plugins/google/compute/instanceLeastPrivilege.js @@ -1,92 +1,138 @@ -var async = require('async'); +var async = require('async'); var helpers = require('../../../helpers/google'); module.exports = { title: 'VM Instances Least Privilege', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that instances are not configured to use the default service account with full access to all cloud APIs', more_info: 'To support the principle of least privilege and prevent potential privilege escalation, it is recommended that instances are not assigned to the default service account, Compute Engine default service account with a scope allowing full access to all cloud APIs.', link: 'https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances', recommended_action: 'For all instances, if the default service account is used, ensure full access to all cloud APIs is not configured.', - apis: ['instances:compute:list'], + apis: ['compute:list', 'projects:getIamPolicy'], compliance: { pci: 'PCI has explicit requirements around default accounts and ' + 'resources. PCI recommends removing all default accounts, ' + 'only enabling necessary services as required for the function ' + 'of the system' }, + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setServiceAccount'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - var myFullAccessScopes = {}; - async.each(regions.instances.compute, (region, rcb) => { - var zones = regions.zones; - myFullAccessScopes[region] = []; - var myError = {}; - var noInstances = {}; - - async.each(zones[region], function(zone, zcb) { - var instances = helpers.addSource(cache, source, - ['instances', 'compute','list', zone]); - - if (!instances) return zcb(); - - if (instances.err || !instances.data) { - if (!myError[region]) { - myError[region] = []; - } - myError[region].push(zone); - return zcb(); - } - if (!instances.data.length) { - if (!noInstances[region]) { - noInstances[region] = []; - } - noInstances[region].push(zone); - return zcb(); - } - - instances.data.forEach(instance => { - if (instance.serviceAccounts && instance.serviceAccounts.length) { - instance.serviceAccounts.forEach(serviceAccount => { - if (serviceAccount.scopes && - serviceAccount.scopes.indexOf('https://www.googleapis.com/auth/cloud-platform') > -1) { - myFullAccessScopes[region].push(serviceAccount.email) - } - }); - } - }); - return zcb(); - }); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query instances', region); + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } - } else if (noInstances[region] && - zones[region] && - (noInstances[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 0, 'No instances found in the region' , region); + var project = projects.data[0].name; - } else if (myFullAccessScopes[region].length) { - var myScopesStr = myFullAccessScopes[region].join(', '); - helpers.addResult(results, 2, - `The following service accounts have full access: ${myScopesStr}` , region); + var serviceAccountRoles = {}; - } else if (!myFullAccessScopes[region].length){ - helpers.addResult(results, 0, - 'All instance service accounts follow least privilege' , region); + async.each(regions.projects, function(region, rcb) { + let iamPolicy = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicy) return rcb(); + + if (iamPolicy.err || !iamPolicy.data || !iamPolicy.data.length) { + helpers.addResult(results, 3, + 'Unable to query for IAM policies: ' + helpers.addError(iamPolicy), region); + return rcb(); + } + + var iamPolicyData = iamPolicy.data[0]; + + if (iamPolicyData && iamPolicyData.bindings && iamPolicyData.bindings.length) { + iamPolicyData.bindings.forEach(roleBinding => { + if (!roleBinding.role || !roleBinding.members) return; + + var role = roleBinding.role; + + roleBinding.members.forEach(member => { + if (member.startsWith('serviceAccount:')) { + var serviceAccountEmail = member.split(':')[1]; + + if (!serviceAccountRoles[serviceAccountEmail]) { + serviceAccountRoles[serviceAccountEmail] = []; + } + serviceAccountRoles[serviceAccountEmail].push(role); + } + }); + }); } rcb(); }, function() { - callback(null, results, source); - // console.log("Results=", results); + async.each(regions.compute, (computeRegion, computeRcb) => { + var zones = regions.zones; + var noInstances = []; + + async.each(zones[computeRegion], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', computeRegion, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + let instanceServiceAccountEmail = null; + let hasBroadRole = false; + + if (instance.serviceAccounts && instance.serviceAccounts.length) { + instance.serviceAccounts.forEach(serviceAccount => { + if (serviceAccount.email) { + instanceServiceAccountEmail = serviceAccount.email; + var roles = serviceAccountRoles[serviceAccount.email] || []; + var broadRoles = roles.filter(role => + role === 'roles/owner' || + role === 'roles/editor' || + role.endsWith('.admin') + ); + if (broadRoles.length > 0) { + hasBroadRole = true; + } + } + }); + } + + if (hasBroadRole && instanceServiceAccountEmail) { + helpers.addResult(results, 2, + 'Instance Service account has full access', computeRegion, resource); + } else { + helpers.addResult(results, 0, + 'Instance service account follows least privilege', computeRegion, resource); + } + }); + return zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, computeRegion); + } + computeRcb(); + }); + }, function() { + callback(null, results, source); + }); }); } }; diff --git a/plugins/google/compute/instanceLeastPrivilege.spec.js b/plugins/google/compute/instanceLeastPrivilege.spec.js index caf59e70f..a63428618 100644 --- a/plugins/google/compute/instanceLeastPrivilege.spec.js +++ b/plugins/google/compute/instanceLeastPrivilege.spec.js @@ -2,27 +2,28 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./instanceLeastPrivilege'); -const createCache = (instanceData, instanceDatab, error) => { +const createCache = (instanceData, error, iamPolicyData, defaultServiceAccount) => { return { - instances: { - compute: { - list: { - 'us-central1-a': { - data: instanceData, - err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, - err: error - } + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: [{ + name: 'test-proj', + defaultServiceAccount: defaultServiceAccount || '123456789-compute@developer.gserviceaccount.com' + }] + } + }, + getIamPolicy: { + 'global': { + data: iamPolicyData || [] } } } @@ -34,16 +35,22 @@ describe('instanceLeastPrivilege', function () { it('should return unknown if an instance error or no data returned', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.equal('Unable to query instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.equal('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [] + }]; + const cache = createCache( [], - null, - ['hellooo'] + ['error'], + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); @@ -52,30 +59,48 @@ describe('instanceLeastPrivilege', function () { it('should pass no VM Instances', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No instances found'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [] + }]; + const cache = createCache( [], - [], - null + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }); - it('should fail with full access service account', function (done) { + it('should fail when default service account has broad IAM role (editor)', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('The following service accounts have full access'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/editor', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + const cache = createCache( [ { @@ -85,7 +110,7 @@ describe('instanceLeastPrivilege', function () { 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', serviceAccounts: [ { - email: '413092707322-compute@developer.gserviceaccount.com', + email: defaultSA, scopes: [ 'https://www.googleapis.com/auth/cloud-platform' ] @@ -93,22 +118,35 @@ describe('instanceLeastPrivilege', function () { ] } ], - [], - null + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }); - it('should pass with no full access service account', function (done) { + it('should pass when default service account has restricted IAM roles', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('All instance service accounts follow least privilege'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('follows least privilege'); + expect(results[0].region).to.equal('us-central1'); done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/storage.objectViewer', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + const cache = createCache( [ { @@ -118,19 +156,160 @@ describe('instanceLeastPrivilege', function () { 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', serviceAccounts: [ { - email: '413092707322-compute@developer.gserviceaccount.com', + email: defaultSA, scopes: [ - 'https://www.googleapis.com/auth/devstorage.read_only', - 'https://www.googleapis.com/auth/logging.write' + 'https://www.googleapis.com/auth/cloud-platform' ] } ] } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when custom service account has broad IAM role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const customSA = 'custom-sa@test-proj.iam.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/editor', + members: [ + 'serviceAccount:' + customSA + ] + } ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: customSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when default service account has owner role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/owner', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: defaultSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when default service account has admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/compute.admin', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: defaultSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }) }) -}) \ No newline at end of file +}) diff --git a/plugins/google/compute/instanceLevelSSHOnly.js b/plugins/google/compute/instanceLevelSSHOnly.js index 56bbe2bb4..8e536309f 100644 --- a/plugins/google/compute/instanceLevelSSHOnly.js +++ b/plugins/google/compute/instanceLevelSSHOnly.js @@ -4,77 +4,75 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Instance Level SSH Only', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that instances are not configured to allow project-wide SSH keys', more_info: 'To support the principle of least privilege and prevent potential privilege escalation it is recommended that instances are not give access to project-wide SSH keys through instance metadata.', link: 'https://cloud.google.com/compute/docs/instances/adding-removing-ssh-keys', recommended_action: 'Ensure project-wide SSH keys are blocked for all instances.', - apis: ['instances:compute:list'], + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setMetadata'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.compute, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { var zones = regions.zones; - var myError = {}; - var noInstances = {}; - var notBlockedProjectSSHKey = []; + var noInstances = []; async.each(zones[region], function(zone, zcb) { var instances = helpers.addSource(cache, source, - ['instances', 'compute','list', zone]); + ['compute','list', zone]); if (!instances) return zcb(); if (instances.err || !instances.data) { - if (!myError[region]) { - myError[region] = []; - } - myError[region].push(zone); + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); return zcb(); } if (!instances.data.length) { - if (!noInstances[region]) { - noInstances[region] = []; - } - noInstances[region].push(zone); + noInstances.push(zone); return zcb(); } instances.data.forEach(instance => { + var found; if (instance.metadata && instance.metadata.items && instance.metadata.items.length) { + found = instance.metadata.items.find(metaItem => metaItem.key === 'block-project-ssh-keys' && + metaItem.value && metaItem.value.toUpperCase() === 'TRUE'); + } - instance.metadata.items.forEach(metaItem => { - if (metaItem.key === 'block-project-ssh-keys' && metaItem.value === 'FALSE') { - notBlockedProjectSSHKey.push(instance.id) - } - }); + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (found) { + helpers.addResult(results, 0, + 'Block project-wide SSH keys is enabled for the instance', region, resource); + } else { + helpers.addResult(results, 2, + 'Block project-wide SSH keys is disabled for the instance', region, resource); } }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); }); - - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query instances' , region); - } else if (noInstances[region] && - zones[region] && - (noInstances[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 0, 'No instances found in the region' , region); - } else if (notBlockedProjectSSHKey.length) { - var myInstanceStr = notBlockedProjectSSHKey.join(", "); - helpers.addResult(results, 2, - `Block project-wide SSH keys is disabled for the following instances: ${myInstanceStr}`, region); - } else if (!notBlockedProjectSSHKey.length) { - helpers.addResult(results, 0, - 'Block project-wide SSH keys is enabled for all instances in the region', region); - } - - rcb(); }, function() { callback(null, results, source); }); diff --git a/plugins/google/compute/instanceLevelSSHOnly.spec.js b/plugins/google/compute/instanceLevelSSHOnly.spec.js index a8d4edb5a..df36188c3 100644 --- a/plugins/google/compute/instanceLevelSSHOnly.spec.js +++ b/plugins/google/compute/instanceLevelSSHOnly.spec.js @@ -1,29 +1,21 @@ -var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./instanceLevelSSHOnly'); -const createCache = (instanceData, instanceDatab, error) => { +const createCache = (instanceData, error) => { return { - instances: { compute: { list: { 'us-central1-a': { data: instanceData, err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, - err: error } } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } } } } @@ -35,16 +27,15 @@ describe('instanceLevelSSHOnly', function () { it('should give unknown if an instance error occurs', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.include('Unable to query instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( [], - [], - ['null'] + ['error'] ); plugin.run(cache, {}, callback); @@ -53,14 +44,13 @@ describe('instanceLevelSSHOnly', function () { it('should pass no VM Instances', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No instances found'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( - [], [], null ); @@ -70,11 +60,11 @@ describe('instanceLevelSSHOnly', function () { it('should fail with no block project-wide ssh keys', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('Block project-wide SSH keys is disabled for the following instances'); - expect(results[4].region).to.equal('us-central1'); - done() + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Block project-wide SSH keys is disabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done(); }; const cache = createCache( @@ -97,8 +87,6 @@ describe('instanceLevelSSHOnly', function () { } } ], - [], - null ); plugin.run(cache, {}, callback); @@ -106,10 +94,10 @@ describe('instanceLevelSSHOnly', function () { it('should pass with block project-wide ssh key', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.equal('Block project-wide SSH keys is enabled for all instances in the region'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Block project-wide SSH keys is enabled for the instance'); + expect(results[0].region).to.equal('us-central1'); done() }; diff --git a/plugins/google/compute/instanceMaintenanceBehavior.js b/plugins/google/compute/instanceMaintenanceBehavior.js new file mode 100644 index 000000000..733babfaa --- /dev/null +++ b/plugins/google/compute/instanceMaintenanceBehavior.js @@ -0,0 +1,74 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Maintenance Behavior', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that "On Host Maintenance" configuration is set to Migrate for VM instances.', + more_info: 'When Google Compute Engine performs regular maintenance of its infrastructure, it migrates your VM instances to other hardware if you have configured the availability policy for the instance to use live migration. This prevents your applications from experiencing disruptions during these events.', + link: 'https://cloud.google.com/compute/docs/instances/setting-instance-scheduling-options', + recommended_action: 'Ensure that your Google Compute Engine VM instances are configured to use live migration.', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setSheduling'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + if (instance.scheduling && instance.scheduling.onHostMaintenance && instance.scheduling.onHostMaintenance.toUpperCase() == 'MIGRATE') { + helpers.addResult(results, 0, + 'Instance Maintenance Behavior is set to MIGRATE', region, resource); + } else { + helpers.addResult(results, 2, + 'Instance Maintenance Behavior is not set to MIGRATE', region, resource); + } + + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/instanceMaintenanceBehavior.spec.js b/plugins/google/compute/instanceMaintenanceBehavior.spec.js new file mode 100644 index 000000000..cbbe4d67d --- /dev/null +++ b/plugins/google/compute/instanceMaintenanceBehavior.spec.js @@ -0,0 +1,138 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instanceMaintenanceBehavior'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instanceMaintenanceBehavior', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if Instance Maintenance Behavior is not set to MIGRATE', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Maintenance Behavior is not set to MIGRATE'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "74374374", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + "scheduling": { + "onHostMaintenance": "TERMINATE", + "automaticRestart": false, + "preemptible": false + } + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if Instance Maintenance Behavior is set to MIGRATE', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Instance Maintenance Behavior is set to MIGRATE'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "1074579276103575670", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": true, + "preemptible": false + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/instanceMaxCount.js b/plugins/google/compute/instanceMaxCount.js index b4b1b4e6f..f94fbeefc 100644 --- a/plugins/google/compute/instanceMaxCount.js +++ b/plugins/google/compute/instanceMaxCount.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'VM Max Instances', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures the total number of VM instances does not exceed a set threshold', more_info: 'The number of running VM instances should be carefully audited, especially in unused regions, to ensure only approved applications are consuming compute resources. Many compromised Google accounts see large numbers of VM instances launched.', link: 'https://cloud.google.com/compute/docs/instances/', recommended_action: 'Ensure that the number of running VM instances matches the expected count. If instances are launched above the threshold, investigate to ensure they are legitimate.', - apis: ['instances:compute:list'], + apis: ['compute:list'], settings: { instance_count_global_threshold: { name: 'Instance Count Global Threshold', @@ -40,6 +42,30 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_us_west3: { + name: 'Instance Count Region Threshold: us-west3', + description: 'Checks for the number of running instances in the us-west3 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_us_west4: { + name: 'Instance Count Region Threshold: us-west4', + description: 'Checks for the number of running instances in the us-west4 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_us_east5: { + name: 'Instance Count Region Threshold: us-east5', + description: 'Checks for the number of running instances in the us-east5 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_us_south1: { + name: 'Instance Count Region Threshold: us-south1', + description: 'Checks for the number of running instances in the us-south1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_us_central1: { name: 'Instance Count Region Threshold: us-central1', description: 'Checks for the number of running instances in the us-central1 region and triggers a failing result if it exceeds the specified count', @@ -52,12 +78,24 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_northamerica_northeast2: { + name: 'Instance Count Region Threshold: northamerica-northeast2', + description: 'Checks for the number of running instances in the northamerica-northeast2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_southamerica_east1: { name: 'Instance Count Region Threshold: southamerica-east1', description: 'Checks for the number of running instances in the southamerica-east1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_southamerica_west1: { + name: 'Instance Count Region Threshold: southamerica-west1', + description: 'Checks for the number of running instances in the southamerica-west1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_europe_west1: { name: 'Instance Count Region Threshold: europe-west1', description: 'Checks for the number of running instances in the europe-west1 region and triggers a failing result if it exceeds the specified count', @@ -100,18 +138,54 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_europe_central2: { + name: 'Instance Count Region Threshold: europe-central2', + description: 'Checks for the number of running instances in the europe-central2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_europe_west8: { + name: 'Instance Count Region Threshold: europe-west8', + description: 'Checks for the number of running instances in the europe-west8 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_europe_southwest1: { + name: 'Instance Count Region Threshold: europe-southwest1', + description: 'Checks for the number of running instances in the europe-southwest1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_europe_west9: { + name: 'Instance Count Region Threshold: europe-west9', + description: 'Checks for the number of running instances in the europe-west9 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_asia_south1: { name: 'Instance Count Region Threshold: asia-south1', description: 'Checks for the number of running instances in the asia-south1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_asia_south2: { + name: 'Instance Count Region Threshold: asia-south2', + description: 'Checks for the number of running instances in the asia-south2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_asia_southeast1: { name: 'Instance Count Region Threshold: asia-southeast1', description: 'Checks for the number of running instances in the asia-southeast1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_asia_southeast2: { + name: 'Instance Count Region Threshold: asia-southeast2', + description: 'Checks for the number of running instances in the asia-southeast2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_asia_east1: { name: 'Instance Count Region Threshold: asia-east1', description: 'Checks for the number of running instances in the asia-east1 region and triggers a failing result if it exceeds the specified count', @@ -136,14 +210,32 @@ module.exports = { regex: '^[0-9]{1,4}$', default: 100 }, + instance_count_region_threshold_asia_northeast3: { + name: 'Instance Count Region Threshold: asia-northeast3', + description: 'Checks for the number of running instances in the asia-northeast3 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, instance_count_region_threshold_australia_southeast1: { name: 'Instance Count Region Threshold: australia-southeast1', description: 'Checks for the number of running instances in the australia-southeast1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 100 }, - + instance_count_region_threshold_australia_southeast2: { + name: 'Instance Count Region Threshold: australia-southeast2', + description: 'Checks for the number of running instances in the australia-southeast2 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + }, + instance_count_region_threshold_me_west1: { + name: 'Instance Count Region Threshold: me-west1', + description: 'Checks for the number of running instances in the me-west1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 100 + } }, + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], run: function(cache, settings, callback) { var config = { @@ -162,6 +254,8 @@ module.exports = { instance_count_region_threshold_northamerica_northeast1: settings.instance_count_region_threshold_northamerica_northeast1 || this.settings.instance_count_region_threshold_northamerica_northeast1.default, instance_count_region_threshold_southamerica_east1: settings.instance_count_region_threshold_southamerica_east1 || this.settings.instance_count_region_threshold_southamerica_east1.default, + + instance_count_region_threshold_southamerica_west1: settings.instance_count_region_threshold_southamerica_west1 || this.settings.instance_count_region_threshold_southamerica_west1.default, instance_count_region_threshold_europe_west1: settings.instance_count_region_threshold_europe_west1 || this.settings.instance_count_region_threshold_europe_west1.default, @@ -191,10 +285,22 @@ module.exports = { instance_count_region_threshold_australia_southeast1: settings.instance_count_region_threshold_australia_southeast1 || this.settings.instance_count_region_threshold_australia_southeast1.default, + instance_count_region_threshold_us_east5: settings.instance_count_region_threshold_us_east5 || this.settings.instance_count_region_threshold_us_east5.default, + + instance_count_region_threshold_us_south1: settings.instance_count_region_threshold_us_south1 || this.settings.instance_count_region_threshold_us_south1.default, + + instance_count_region_threshold_europe_west8: settings.instance_count_region_threshold_europe_west8 || this.settings.instance_count_region_threshold_europe_west8.default, + + instance_count_region_threshold_europe_southwest1: settings.instance_count_region_threshold_europe_southwest1 || this.settings.instance_count_region_threshold_europe_southwest1.default, + + instance_count_region_threshold_europe_west9: settings.instance_count_region_threshold_europe_west9 || this.settings.instance_count_region_threshold_europe_west9.default, + + instance_count_region_threshold_me_west1: settings.instance_count_region_threshold_me_west1 || this.settings.instance_count_region_threshold_me_west1.default, + }; - for (c in config) { - if (settings.hasOwnProperty(c)) { + for (let c in config) { + if (settings[c]) { config[c] = settings[c]; } } @@ -206,7 +312,7 @@ module.exports = { var regions = helpers.regions(); var instanceCountGlobal = 0; - async.each(regions.instances.compute, function(region, rcb){ + async.each(regions.compute, function(region, rcb){ var zones = regions.zones; var instanceCount = 0; var myError = {}; @@ -214,7 +320,7 @@ module.exports = { async.each(zones[region], function(zone, zcb) { var instances = helpers.addSource(cache, source, - ['instances', 'compute','list', zone]); + ['compute','list', zone]); if (!instances) return zcb(); @@ -223,6 +329,7 @@ module.exports = { myError[region] = []; } myError[region].push(zone); + myError[region][zone] = instances.err; return zcb(); } @@ -234,52 +341,55 @@ module.exports = { return zcb(); } instances.data.forEach(instance => { - if (instance.status && instance.status == "RUNNING") { + if (instance.status && instance.status == 'RUNNING') { instanceCountGlobal +=1; instanceCount +=1; } - }) + }); + + zcb(); + }, function() { + // Print region results + var regionUnderscore = region.replace(/-/g, '_'); + var regionThreshold = config['instance_count_region_threshold_'+regionUnderscore]; + + if (myError[region] && + zones[region] && + (myError[region].join(',') === zones[region].join(','))) { + helpers.addResult(results, 3, 'Unable to query Instances', region, null, null, myError); + + } else if (noInstances[region] && + zones[region] && + (noInstances[region].join(',') === zones[region].join(','))) { + helpers.addResult(results, 0, 'No instances found in the region' , region); + + } else if (!regionThreshold) { + helpers.addResult(results, 3, + 'The region: ' + region + ' does not have a maximum instances count setting.', region); + } else if (instanceCount > regionThreshold) { + helpers.addResult(results, 2, + instanceCount + ' instances running in ' + + region + ' region, exceeding limit of: ' + + regionThreshold, region, null, custom); + } else { + helpers.addResult(results, 0, + instanceCount + ' instances in the region are within the regional expected count of: ' + regionThreshold, region, null, custom); + } + rcb(); }); - // Print region results - var regionUnderscore = region.replace(/-/g, '_'); - var regionThreshold = config['instance_count_region_threshold_'+regionUnderscore]; - - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query Instances', region); - - } else if (noInstances[region] && - zones[region] && - (noInstances[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 0, 'No instances found in the region' , region); - - } else if (!regionThreshold) { - helpers.addResult(results, 3, - 'The region: ' + region + ' does not have a maximum instances count setting.', region); - } else if (instanceCount > regionThreshold) { + }, function() { + // Print global results + var globalThreshold = config.instance_count_global_threshold; + + if (instanceCountGlobal > globalThreshold) { helpers.addResult(results, 2, - instanceCount + ' instances running in ' + - region + ' region, exceeding limit of: ' + - regionThreshold, region, null, custom); + instanceCountGlobal + ' instances running in all regions, exceeding limit of: ' + globalThreshold, null, null, custom); } else { helpers.addResult(results, 0, - instanceCount + ' instances in the region are within the regional expected count of: ' + regionThreshold, region, null, custom); + instanceCountGlobal + ' instances in the account are within the global expected count of: ' + globalThreshold, null, null, custom); } - rcb(); + + callback(null, results, source); }); - - // Print global results - var globalThreshold = config.instance_count_global_threshold; - - if (instanceCountGlobal > globalThreshold) { - helpers.addResult(results, 2, - instanceCountGlobal + ' instances running in all regions, exceeding limit of: ' + globalThreshold, null, null, custom); - } else { - helpers.addResult(results, 0, - instanceCountGlobal + ' instances in the account are within the global expected count of: ' + globalThreshold, null, null, custom); - } - - callback(null, results, source); } }; diff --git a/plugins/google/compute/instanceMaxCount.spec.js b/plugins/google/compute/instanceMaxCount.spec.js new file mode 100644 index 000000000..ed191998e --- /dev/null +++ b/plugins/google/compute/instanceMaxCount.spec.js @@ -0,0 +1,183 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instanceMaxCount'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-east1-b': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instanceMaxCount', function () { + describe('run', function () { + + it('should pass if no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if VM instance count is not within regional threshold', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('exceeding'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "1111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-3", + }, + { + "kind": "compute#instance", + "id": "1111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-2", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-2", + } + ], + null + ); + + plugin.run(cache, {instance_count_region_threshold_us_east1: 1}, callback); + }) + + it('should pass if VM instance count is within regional threshold', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('are within the'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "45444", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "canIpForward": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-2", + } + ] + ); + + plugin.run(cache, {instance_count_region_threshold_us_east1: 100}, callback); + }) + + it('should fail if VM instance count is not within global threshold', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[(results.length - 1)].status).to.equal(2); + expect(results[(results.length - 1)].message).to.include('exceeding'); + expect(results[(results.length - 1)].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "1111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-3", + }, + { + "kind": "compute#instance", + "id": "1111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-2", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-2", + } + ], + null + ); + + plugin.run(cache, {instance_count_global_threshold: 1}, callback); + }) + + it('should pass if VM instance count is within global threshold', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[(results.length - 1)].status).to.equal(0); + expect(results[(results.length - 1)].message).to.include('are within the'); + expect(results[(results.length - 1)].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "45444", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b", + "canIpForward": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-east1-b/instances/instance-2", + } + ] + ); + + plugin.run(cache, {instance_count_global_threshold: 100}, callback); + }) + + }) + +}); \ No newline at end of file diff --git a/plugins/google/compute/instanceNetworkExposure.js b/plugins/google/compute/instanceNetworkExposure.js new file mode 100644 index 000000000..62b7336c9 --- /dev/null +++ b/plugins/google/compute/instanceNetworkExposure.js @@ -0,0 +1,141 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Internet Exposure', + category: 'Compute', + domain: 'Compute', + severity: 'Info', + description: 'Check if GCP virtual machines are exposed to the internet.', + more_info: 'Virtual machines exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of network and firewall rules.', + link: 'https://cloud.google.com/firewall/docs/firewalls', + recommended_action: 'Secure VM instances by restricting access with properly configured security group and firewall rules.', + apis: ['instanceGroups:aggregatedList', 'compute:list', 'firewalls:list', 'instanceGroups:listInstances', 'urlMaps:list', 'targetHttpProxies:list', 'targetHttpsProxies:list', + 'forwardingRules:list', 'backendServices:list' + ], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.update', 'compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch', + 'compute.backendServices.insert', 'compute.backendServices.delete', 'compute.backendServices.patch', 'compute.instanceGroups.insert', 'compute.instanceGroups.delete', 'compute.instanceGroups.update', + 'compute.instanceGroups.addInstances', 'compute.instanceGroups.removeInstances', 'compute.urlMaps.insert', 'compute.urlMaps.delete', 'compute.urlMaps.update', 'compute.urlMaps.patch', + 'compute.targetHttpProxies.insert', 'compute.targetHttpProxies.delete', 'compute.targetHttpProxies.patch', 'compute.targetHttpsProxies.insert', 'compute.targetHttpsProxies.delete', 'compute.targetHttpsProxies.patch', + 'compute.forwardingRules.insert', 'compute.forwardingRules.delete', 'compute.forwardingRules.patch' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', 'global']); + + if (!firewalls) return rcb(); + + if (!firewalls || firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let networks = instance.networkInterfaces.map(nic => nic.network); + let tags = instance.tags && instance.tags.items ? instance.tags.items : []; + let serviceAccount = instance.serviceAccounts && instance.serviceAccounts[0] && instance.serviceAccounts[0].email ? instance.serviceAccounts[0].email : ''; + + let firewallRules = firewalls.data.filter(rule => { + if (!rule.network) return false; + let isNetworkMatch = networks.some(network => rule.network.endsWith(network)); + + let isTagMatch = rule.targetTags ? rule.targetTags.some(tag => tags.includes(tag)) : true; + + let isServiceAccountMatch = rule.targetServiceAccounts ? + rule.targetServiceAccounts.includes(serviceAccount) : true; + + return isNetworkMatch && isTagMatch && isServiceAccountMatch; + }); + + + + networks = networks.map(network => network.split('/').pop()); + + // get all instance groups for instance + let instanceGroups = []; + + var instanceList = helpers.addSource(cache, source, + ['instanceGroups','listInstances', 'global']); + + if (instanceList && !instanceList.err && instanceList.data && instanceList.data.length) { + let groups = instanceList.data.filter(list => list.instance === instance.selfLink); + if (groups && groups.length) { + instanceGroups = groups.map(group => group.parent); + } + } + + + let forwardingRules = []; + if (instanceGroups && instanceGroups.length) { + instanceGroups.forEach(instanceGroup => { + let igForwardingRules = helpers.getForwardingRules(cache, source, region, instanceGroup); + forwardingRules = forwardingRules.concat(igForwardingRules); + }); + + } + let internetExposed = helpers.checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules); + + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `VM is exposed to the internet through ${internetExposed}`, region, resource); + } else { + helpers.addResult(results, 0, 'VM is not exposed to the internet', region, resource); + } + + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/compute/instancePreemptibility.js b/plugins/google/compute/instancePreemptibility.js new file mode 100644 index 000000000..09e8eb1a4 --- /dev/null +++ b/plugins/google/compute/instancePreemptibility.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Preemptibility Disabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that preemptible Virtual Machine instances do not exist.', + more_info: 'Preemptible instances are excess Compute Engine capacity, so their availability varies with usage. Compute Engine can terminate preemptible instances if it requires access to these resources for other tasks.', + link: 'https://cloud.google.com/compute/docs/instances/preemptible', + recommended_action: 'Ensure that your Google Compute Engine VM instances are not preemptible.', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.compute, (region, rcb) => { + var noInstances = []; + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + if (instance.scheduling && instance.scheduling.preemptible) { + helpers.addResult(results, 2, + 'VM Instance is preemptible', region, resource); + } else { + helpers.addResult(results, 0, + 'VM Instance is not preemptible', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/instancePreemptibility.spec.js b/plugins/google/compute/instancePreemptibility.spec.js new file mode 100644 index 000000000..60ad30f4b --- /dev/null +++ b/plugins/google/compute/instancePreemptibility.spec.js @@ -0,0 +1,138 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./instancePreemptibility'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('instancePreemptibility', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if VM instance is preemptible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VM Instance is preemptible'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "53523", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": false, + "preemptible": true + } + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if VM instance is not preemptible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('VM Instance is not preemptible'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "45444", + "creationTimestamp": "2019-09-25T14:05:30.014-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/g1-small", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-2", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": true, + "preemptible": false + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/instancePublicAccess.js b/plugins/google/compute/instancePublicAccess.js new file mode 100644 index 000000000..f4a16f81a --- /dev/null +++ b/plugins/google/compute/instancePublicAccess.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Public Access Disabled', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensures that compute instances are not configured to allow public access.', + more_info: 'Compute Instances should always be configured behind load balancers instead of having public IP addresses ' + + 'in order to minimize the instance\'s exposure to the internet.', + link: 'https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address', + recommended_action: 'Modify compute instances and set External IP to None for network interface', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.updateNetworkInterface'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + if (instance.name && instance.name.startsWith('gke-')) return; + + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + let found; + if (instance.networkInterfaces && + instance.networkInterfaces.length) { + found = instance.networkInterfaces.find(networkObject => networkObject.accessConfigs); + } + + if (found) { + helpers.addResult(results, 2, + 'Public access is enabled for the instance', region, resource); + } else { + helpers.addResult(results, 0, + 'Public access is disabled for the instance', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/instancePublicAccess.spec.js b/plugins/google/compute/instancePublicAccess.spec.js new file mode 100644 index 000000000..dcf876707 --- /dev/null +++ b/plugins/google/compute/instancePublicAccess.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +var plugin = require('./instancePublicAccess'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } + } + } +}; + +describe('instancePublicAccess', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['null'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if instance public access is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Public access is enabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "1719867382827328572", + name: "testing-instance", + zone: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/zones/us-central1-a", + networkInterfaces: [ + { + network: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/default", + subnetwork: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/regions/us-central1/subnetworks/default", + networkIP: "10.128.0.3", + name: "nic0", + accessConfigs: [ + { + type: "ONE_TO_ONE_NAT", + name: "External NAT", + natIP: "34.72.52.155", + networkTier: "PREMIUM", + kind: "compute#accessConfig", + }, + ], + fingerprint: "d6n46SySJeI=", + kind: "compute#networkInterface", + }, + ], + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if instance public access is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Public access is disabled for the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "3736210870233209587", + name: "testing-instance2", + zone: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/zones/us-central1-a", + networkInterfaces: [ + { + network: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/default", + subnetwork: "https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/regions/us-central1/subnetworks/default", + networkIP: "10.128.0.4", + name: "nic0", + fingerprint: "grGInqLIF64=", + kind: "compute#networkInterface", + }, + ], + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/instanceTemplateMachineTypes.js b/plugins/google/compute/instanceTemplateMachineTypes.js new file mode 100644 index 000000000..6b5b53e7a --- /dev/null +++ b/plugins/google/compute/instanceTemplateMachineTypes.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Template Machine Type', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Cloud Virtual Machine instance templates are of given types.', + more_info: 'Virtual Machine instance templates should be of the given types to ensure the internal compliance and prevent unexpected billing charges.', + link: 'https://cloud.google.com/compute/docs/machine-types', + recommended_action: 'Ensure that Virtual Machine instance templates are not using undesired machine types.', + apis: ['instanceTemplates:list'], + settings: { + instance_template_machine_types: { + name: 'Instance Template Machine Types', + description: 'Desired Google Cloud Virtual Machine instance template type', + regex: '^.*$', + default: '' + }, + }, + realtime_triggers: ['compute.instanceTemplates.insert', 'compute.instanceTemplates.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var instance_template_machine_types = settings.instance_template_machine_types || this.settings.instance_template_machine_types.default; + + if (!instance_template_machine_types.length) return callback(null, results, source); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + async.each(regions.instanceTemplates, function(region, rcb) { + let instanceTemplates = helpers.addSource( + cache, source, ['instanceTemplates', 'list', region]); + if (!instanceTemplates) return rcb(); + + if (instanceTemplates.err || !instanceTemplates.data) { + helpers.addResult(results, 3, + 'Unable to query instance templates', region, null, null, instanceTemplates.err); + return rcb(); + } + + if (!instanceTemplates.data.length) { + helpers.addResult(results, 0, 'No instance templates found', region); + return rcb(); + } + instanceTemplates.data.forEach(instanceTemplate => { + if (instanceTemplate.properties && instanceTemplate.properties.machineType && instance_template_machine_types.includes(instanceTemplate.properties.machineType)) { + helpers.addResult(results, 0, + 'Virtual Machine instance template has desired machine type', region); + } else { + helpers.addResult(results, 2, + 'Virtual Machine instance template does not have desired machine type', region); + } + }); + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/instancesMultiAz.js b/plugins/google/compute/instancesMultiAz.js index a9637b835..59d15a2a5 100644 --- a/plugins/google/compute/instancesMultiAz.js +++ b/plugins/google/compute/instancesMultiAz.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Instances Multi AZ', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures managed instances are regional for availability purposes.', more_info: 'Creating instances in a single zone creates a single point of failure for all systems in the VPC. All managed instances should be created as Regional to ensure proper failover.', link: 'https://cloud.google.com/vpc/docs/vpc', recommended_action: 'Launch new instances as regional instance groups.', - apis: ['instanceGroups:aggregatedList', 'instances:compute:list'], + apis: ['instanceGroups:aggregatedList', 'compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], run: function(cache, settings, callback) { var results = []; @@ -16,6 +19,17 @@ module.exports = { var regions = helpers.regions(); var region = regions.instanceGroups[0]; + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + let instanceGroups = helpers.addSource(cache, source, ['instanceGroups', 'aggregatedList', region]); @@ -23,11 +37,11 @@ module.exports = { helpers.addResult(results, 3, 'Unable to query instance groups', region); return callback(null, results, source); } else if (instanceGroups.err || !instanceGroups.data) { - helpers.addResult(results, 3, 'Unable to query instance groups: ' + helpers.addError(instanceGroups), region); + helpers.addResult(results, 3, 'Unable to query instance groups', region, null, null, instanceGroups.err); return callback(null, results, source); } else { var groupName = []; - async.each(instanceGroups.data, function (instanceGroup, icb) { + async.each(instanceGroups.data, function(instanceGroup, icb) { if (instanceGroup.instanceGroups) { instanceGroup.instanceGroups.forEach(group => { if (group.region) { @@ -36,13 +50,12 @@ module.exports = { }); } icb(); - }, function () { - async.each(regions.instances.compute, function (location, loccb) { - var instancesInRegion = []; - var regionExists = false; - async.each(regions.zones[location], function (loc, lcb) { + }, function() { + async.each(regions.compute, function(location, loccb) { + var noInstances = []; + async.each(regions.zones[location], function(loc, lcb) { let instances = helpers.addSource( - cache, source, ['instances', 'compute', 'list', loc]); + cache, source, ['compute', 'list', loc]); if (!instances) return lcb(); @@ -53,31 +66,30 @@ module.exports = { //Looping by zone, ignoring the results if (!instances.data.length) { - // helpers.addResult(results, 0, 'No Instances Found', loc); + noInstances.push(loc); return lcb(); } for (let instance of instances.data) { + if (!instance.name) continue; + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', location); var instanceName = instance.name.split('-'); instanceName.splice(instanceName.length - 1, 1); instanceName = instanceName.join('-'); - if (!groupName.includes(instanceName)) { - instancesInRegion.push(instance.id); + if (groupName.includes(instanceName)) { + helpers.addResult(results, 0, + 'Instance is regional and highly available', location, resource); } else { - regionExists = true; + helpers.addResult(results, 2, + 'Instance is available in single zone', location, resource); } } lcb(); }, function() { - if (instancesInRegion.length) { - var myInstancesStr = instancesInRegion.join(', '); - helpers.addResult(results, 2, - `These instances are only available in one zone: ${myInstancesStr}`, location); - } else if (!instancesInRegion.length && regionExists) { - helpers.addResult(results, 0, 'The instance groups in the region are highly available', location); - } else if (!instancesInRegion.length && !regionExists) { - helpers.addResult(results, 0, 'No instances found in the region', location); + if (noInstances.length) { + helpers.addResult(results, 0, + `No instances found in following zones: ${noInstances.join(', ')}`, location); } loccb(); }); diff --git a/plugins/google/compute/instancesMultiAz.spec.js b/plugins/google/compute/instancesMultiAz.spec.js index 7f49d86b4..e3318ca9a 100644 --- a/plugins/google/compute/instancesMultiAz.spec.js +++ b/plugins/google/compute/instancesMultiAz.spec.js @@ -2,29 +2,15 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./instancesMultiAz'); -const createCache = (instanceData, instanceDatab, instanceGroupData, error, iGErr) => { +const createCache = (instanceData, instanceGroupData, error, iGErr) => { return { - instances: { compute: { list: { 'us-central1-a': { data: instanceData, err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, - err: error } } - } }, instanceGroups: { aggregatedList: { @@ -33,6 +19,13 @@ const createCache = (instanceData, instanceDatab, instanceGroupData, error, iGEr err: iGErr } } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } } } } @@ -52,7 +45,6 @@ describe('instancesMultiAz', function () { [], null, null, - ['error'], ['error'] ); @@ -61,16 +53,16 @@ describe('instancesMultiAz', function () { it('should return unknown if an instance error or no data returned', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.include('Unable to query instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( [], - null, - ['hellooo'] + [], + ['error'] ); plugin.run(cache, {}, callback); @@ -79,14 +71,13 @@ describe('instancesMultiAz', function () { it('should pass if no VM Instances found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No instances found'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( - [], [], [], null, @@ -99,10 +90,10 @@ describe('instancesMultiAz', function () { it('should fail if instances are available in only one zone', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('These instances are only available in one zone'); - expect(results[4].region).to.equal('us-central1'); - done() + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance is available in single zone'); + expect(results[0].region).to.equal('us-central1'); + done(); }; const cache = createCache( @@ -122,7 +113,6 @@ describe('instancesMultiAz', function () { ] } ], - [], { "regions/us-central1": { "warning": { @@ -1109,12 +1099,12 @@ describe('instancesMultiAz', function () { plugin.run(cache, {}, callback); }); - it('should pass if the instance groups in the region are highly available', function (done) { + it('should pass if the Instance is regional and highly available', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('The instance groups in the region are highly available'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Instance is regional and highly available'); + expect(results[0].region).to.equal('us-central1'); done() }; @@ -1217,7 +1207,6 @@ describe('instancesMultiAz', function () { "kind": "compute#instance" } ], - [], { "regions/us-central1": { "instanceGroups": [ diff --git a/plugins/google/compute/ipForwardingDisabled.js b/plugins/google/compute/ipForwardingDisabled.js index 348a72773..d0d172265 100644 --- a/plugins/google/compute/ipForwardingDisabled.js +++ b/plugins/google/compute/ipForwardingDisabled.js @@ -4,70 +4,71 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'IP Forwarding Disabled', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures that IP forwarding is disabled on all instances', more_info: 'Disabling IP forwarding ensures that the instance only sends and receives packets with matching destination or source IPs.', link: 'https://cloud.google.com/vpc/docs/using-routes', recommended_action: 'IP forwarding settings can only be chosen when creating a new instance. Delete the affected instances and redeploy with IP forwarding disabled.', - apis: ['instances:compute:list'], + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.compute, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { var zones = regions.zones; - var myError = {}; - var noInstances = {}; - var badInstances = []; + var noInstances = []; async.each(zones[region], function(zone, zcb) { var instances = helpers.addSource(cache, source, - ['instances', 'compute','list', zone ]); + ['compute','list', zone ]); if (!instances) return zcb(); if (instances.err || !instances.data) { - if (!myError[region]) { - myError[region] = []; - } - myError[region].push(zone); + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); return zcb(); } if (!instances.data.length) { - if (!noInstances[region]) { - noInstances[region] = []; - } - noInstances[region].push(zone); + noInstances.push(zone); return zcb(); } instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); if (instance.canIpForward) { - badInstances.push(instance.id) + helpers.addResult(results, 2, + 'Instance has IP forwarding enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'Instance does not have IP forwarding enabled', region, resource); } - }) + }); + + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); }); - if (myError[region] && - zones[region] && - (myError[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 3, 'Unable to query instances' , region); - } else if (noInstances[region] && - zones[region] && - (noInstances[region].join(',') === zones[region].join(','))) { - helpers.addResult(results, 0, 'No instances found in the region' , region); - } else if (badInstances.length) { - var myInstanceStr = badInstances.join(", "); - helpers.addResult(results, 2, - `Instance IP forwarding is enabled for the following instances: ${myInstanceStr}`, region); - } else if (!badInstances.length) { - helpers.addResult(results, 0, - 'Instance IP forwarding is disabled for all instances in the region', region); - } - rcb(); + }, function() { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/compute/ipForwardingDisabled.spec.js b/plugins/google/compute/ipForwardingDisabled.spec.js index 1c76d1c90..bb300044b 100644 --- a/plugins/google/compute/ipForwardingDisabled.spec.js +++ b/plugins/google/compute/ipForwardingDisabled.spec.js @@ -1,29 +1,21 @@ -var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./ipForwardingDisabled'); -const createCache = (instanceData, instanceDatab, error) => { +const createCache = (instanceData, error) => { return { - instances: { compute: { list: { 'us-central1-a': { data: instanceData, err: error - }, - 'us-central1-b': { - data: instanceDatab, - err: error - }, - 'us-central1-c': { - data: instanceDatab, - err: error - }, - 'us-central1-f': { - data: instanceDatab, - err: error } } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } } } } @@ -35,16 +27,15 @@ describe('ipForwardingDisabled', function () { it('should give unknown if an instance error occurs', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(3); - expect(results[4].message).to.include('Unable to query instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( [], - [], - ['null'] + ['error'] ); plugin.run(cache, {}, callback); @@ -53,14 +44,13 @@ describe('ipForwardingDisabled', function () { it('should pass no VM Instances', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.include('No instances found'); - expect(results[4].region).to.equal('us-central1'); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); done() }; const cache = createCache( - [], [], null ); @@ -70,10 +60,10 @@ describe('ipForwardingDisabled', function () { it('should fail if ip forwarding is enabled', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(2); - expect(results[4].message).to.include('Instance IP forwarding is enabled for the following instances'); - expect(results[4].region).to.equal('us-central1'); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance has IP forwarding enabled'); + expect(results[0].region).to.equal('us-central1'); done() }; @@ -166,21 +156,19 @@ describe('ipForwardingDisabled', function () { }, "kind": "compute#instance" } - ], - [], - null + ] ); plugin.run(cache, {}, callback); }) - it('should pass with block project-wide ssh key', function (done) { + it('should pass if instance does not have IP forwarding enabled', function (done) { const callback = (err, results) => { - expect(results.length).to.be.above(1); - expect(results[4].status).to.equal(0); - expect(results[4].message).to.equal('Instance IP forwarding is disabled for all instances in the region'); - expect(results[4].region).to.equal('us-central1'); - done() + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.equal('Instance does not have IP forwarding enabled'); + expect(results[0].region).to.equal('us-central1'); + done(); }; const cache = createCache( @@ -274,7 +262,6 @@ describe('ipForwardingDisabled', function () { } ] ); - plugin.run(cache, {}, callback); }) diff --git a/plugins/google/compute/osLogin2FAEnabled.js b/plugins/google/compute/osLogin2FAEnabled.js new file mode 100644 index 000000000..be5fe1003 --- /dev/null +++ b/plugins/google/compute/osLogin2FAEnabled.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'OS Login 2FA Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Virtual Machines instances have OS logic feature enabled and configured with Two-Factor Authentication.', + more_info: 'Enable OS login Two-Factor Authentication (2FA) to add an additional security layer to your VM instances. The risk of your VM instances getting attcked is reduced significantly if 2FA is enabled.', + link: 'https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication', + recommended_action: 'Set enable-oslogin-2fa to true in custom metadata for the instance.', + apis: ['compute:list'], + compliance: { + pci: 'PCI recommends implementing additional security features for ' + + 'any required service. This includes using secured technologies ' + + 'such as SSH.' + }, + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setMetadata'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + let isEnabled = false; + + if (instance.metadata && instance.metadata.items && instance.metadata.items.length) { + + if (instance.metadata.items.find(item => (item.key && item.key.toLowerCase() === 'enable-oslogin-2fa' && + item.value && item.value.toLowerCase() === 'true'))) { + isEnabled = true; + } + } + + if (isEnabled) { + helpers.addResult(results, 0, + 'OS Login 2FA is enabled for the the instance', region, resource); + } else { + helpers.addResult(results, 2, + 'OS Login 2FA is not enabled for the the instance', region, resource); + } + + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/osLogin2FAEnabled.spec.js b/plugins/google/compute/osLogin2FAEnabled.spec.js new file mode 100644 index 000000000..13c1794b0 --- /dev/null +++ b/plugins/google/compute/osLogin2FAEnabled.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var plugin = require('./osLogin2FAEnabled'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } + } + } +}; + +describe('osLogin2FAEnabled', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache([]); + + plugin.run(cache, {}, callback); + }); + + it('should fail when OS Login 2FA is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('OS Login 2FA is not enabled for the the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "17198672", + name: "testing-instance", + zone: "https://www.googleapis.com/compute/v1/projects/test-dev/zones/us-central1-a", + metadata: { items: [] } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass when OS Login 2FA is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('OS Login 2FA is enabled for the the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "321087009587", + name: "testing-instance2", + zone: "https://www.googleapis.com/compute/v1/projects/test-dev/zones/us-central1-a", + metadata: { + items: [ + { key: 'enable-oslogin', value: 'TRUE' }, + { key: 'enable-oslogin-2fa', value: 'TRUE' } + ] + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/osLoginEnabled.js b/plugins/google/compute/osLoginEnabled.js index b9339eaeb..4c4f15de2 100644 --- a/plugins/google/compute/osLoginEnabled.js +++ b/plugins/google/compute/osLoginEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'OS Login Enabled', category: 'Compute', + domain: 'Compute', + severity: 'Low', description: 'Ensures OS login is enabled for the project', more_info: 'Enabling OS login ensures that SSH keys used to connect to instances are mapped with IAM users.', link: 'https://cloud.google.com/compute/docs/instances/managing-instance-access', @@ -14,6 +16,7 @@ module.exports = { 'any required service. This includes using secured technologies ' + 'such as SSH.' }, + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete'], run: function(cache, settings, callback) { var results = []; @@ -28,7 +31,7 @@ module.exports = { if (projects.err || !projects.data) { helpers.addResult(results, 3, - 'Unable to query for projects: ' + helpers.addError(projects), region); + 'Unable to query for projects: ' + helpers.addError(projects), region, null, null, projects.err); return rcb(); } @@ -37,30 +40,23 @@ module.exports = { return rcb(); } - projects.data.forEach(project => { - var metaData = project.commonInstanceMetadata || null; + projects.data.forEach(project => { + var metaData = project.commonInstanceMetadata || null; - if (!metaData || !metaData.items || !metaData.items.length) { - helpers.addResult(results, 0, 'OS login is enabled by default', region); - return; - } + if (!metaData || !metaData.items || !metaData.items.length) { + helpers.addResult(results, 0, 'OS login is enabled by default', region); + return; + } - let isEnabled = false; + let isEnabled = metaData.items.find(item => item.key && item.key.toLowerCase() === 'enable-oslogin' && + item.value && item.value.toLowerCase() === 'true'); - metaData.items.forEach(item => { - if (item.key.toLowerCase() === 'enable-oslogin' && - item.value.toLowerCase() === 'true') { - isEnabled = true; - } - }); - - if (isEnabled === true) { - helpers.addResult(results, 0, 'OS login is enabled', region); - } else { - helpers.addResult(results, 2, 'OS login is disabled', region); - } - - }); + if (isEnabled) { + helpers.addResult(results, 0, 'OS login is enabled', region); + } else { + helpers.addResult(results, 2, 'OS login is disabled', region); + } + }); rcb(); }, function(){ @@ -68,4 +64,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/compute/persistentDisksAutoDelete.js b/plugins/google/compute/persistentDisksAutoDelete.js new file mode 100644 index 000000000..179e09fcf --- /dev/null +++ b/plugins/google/compute/persistentDisksAutoDelete.js @@ -0,0 +1,102 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Persistent Disks Auto Delete', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that auto-delete is disabled for attached persistent disks.', + more_info: 'When auto-delete is enabled, the attached persistent disk are deleted with VM instance deletion. In cloud environments, you might want to keep the attached persistent disks even when the associated VM instance is deleted.', + link: 'https://cloud.google.com/compute/docs/disks', + recommended_action: 'Ensure that auto-delete is disabled for all disks associated with your VM instances.', + apis: ['disks:list', 'compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.setDiskAutoDelete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.disks, (region, rcb) => { + var noDisks = []; + var zones = regions.zones; + + async.each(zones[region], function(zone, zcb) { + var autoDeleteEnabledDisks = []; + + var disks = helpers.addSource(cache, source, + ['disks', 'list', zone]); + + if (!disks) return zcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, + 'Unable to query compute disks', region, null, null, disks.err); + return zcb(); + } + + if (!disks.data.length) { + noDisks.push(zone); + return zcb(); + } + + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (instances.data) { + instances.data.forEach(instance => { + if (instance.disks && instance.disks.length) { + instance.disks.forEach(disk => { + if (disk.autoDelete) { + autoDeleteEnabledDisks.push(disk.deviceName); + } + }); + } + }); + } + + var disksFound = false; + + disks.data.forEach(disk => { + if (!disk.id || !disk.selfLink || !disk.creationTimestamp) return; + + disksFound = true; + + let resource = helpers.createResourceName('disks', disk.name, project, 'zone', zone); + + if (autoDeleteEnabledDisks.includes(disk.name)) { + helpers.addResult(results, 2, + 'Auto Delete is enabled for disk', region, resource); + } else { + helpers.addResult(results, 0, + 'Auto Delete is disabled for disk', region, resource); + } + + }); + + if (!disksFound) noDisks.push(zone); + + zcb(); + }, function() { + if (noDisks.length) { + helpers.addResult(results, 0, `No compute disks found in following zones: ${noDisks.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/persistentDisksAutoDelete.spec.js b/plugins/google/compute/persistentDisksAutoDelete.spec.js new file mode 100644 index 000000000..135c4e5ce --- /dev/null +++ b/plugins/google/compute/persistentDisksAutoDelete.spec.js @@ -0,0 +1,194 @@ +var expect = require('chai').expect; +var plugin = require('./persistentDisksAutoDelete'); + +const disks = [ + { + "kind": "compute#disk", + "id": "535353", + "creationTimestamp": "2019-09-25T14:05:30.090-07:00", + "name": "instance-3", + "sizeGb": "10", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "status": "READY", + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3" + } +]; +const createCache = (instanceData, error, disksData, disksErr) => { + return { + disks: { + list: { + 'us-central1-a': { + data: disksData, + err: disksErr + } + } + }, + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'tets-proj' + } + } + } + } +}; + +describe('persistentDisksAutoDelete', function () { + describe('run', function () { + + it('should give unknown if unable to query compute disks', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute disks'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null, + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disks found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No compute disks found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + + it('should fail if Auto Delete is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Auto Delete is enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "2323", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "disks": [ + { + "kind": "compute#attachedDisk", + "type": "PERSISTENT", + "mode": "READ_WRITE", + "source": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3", + "deviceName": "instance-3", + "index": 0, + "boot": true, + "autoDelete": true, + "licenses": [ + "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" + ], + "interface": "SCSI", + "guestOsFeatures": [ + { + "type": "VIRTIO_SCSI_MULTIQUEUE" + } + ] + } + ], + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + } + ], + null, + disks, + null + + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if Auto Delete is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Auto Delete is disabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + "kind": "compute#instance", + "id": "2323", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "machineType": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a", + "canIpForward": true, + "disks": [ + { + "kind": "compute#attachedDisk", + "type": "PERSISTENT", + "mode": "READ_WRITE", + "source": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/disks/instance-3", + "deviceName": "instance-3", + "index": 0, + "boot": true, + "autoDelete": false, + "licenses": [ + "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/licenses/debian-9-stretch" + ], + "interface": "SCSI", + "guestOsFeatures": [ + { + "type": "VIRTIO_SCSI_MULTIQUEUE" + } + ] + } + ], + "selfLink": "https://www.googleapis.com/compute/v1/projects/rosy-booth-253119/zones/us-central1-a/instances/instance-3", + } + ], + null, + disks, + null + ); + + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/publicDiskImages.js b/plugins/google/compute/publicDiskImages.js new file mode 100644 index 000000000..211a25845 --- /dev/null +++ b/plugins/google/compute/publicDiskImages.js @@ -0,0 +1,83 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Public Disk Images', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that your disk images are not being shared publicly.', + more_info: 'To avoid exposing sensitive information, make sure that your virtual machine disk images are not being publicly shared with all other GCP accounts.', + link: 'https://cloud.google.com/compute/docs/images', + recommended_action: 'Ensure that your VM disk images are not accessible by allUsers or allAuthenticatedUsers.', + apis: ['images:list', 'images:getIamPolicy'], + realtime_triggers: ['compute.images.insert', 'compute.images.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let images = helpers.addSource(cache, source, + ['images', 'list', 'global']); + + if (!images || images.err || !images.data) { + helpers.addResult(results, 3, 'Unable to query Disk Images: ' + helpers.addError(images), 'global'); + return callback(null, results, source); + } + + if (!images.data.length) { + helpers.addResult(results, 0, 'No Disk Images found', 'global'); + return callback(null, results, source); + } + + let getImagesIamPolicies = helpers.addSource(cache, source, + ['images', 'getIamPolicy', 'global']); + + if (!getImagesIamPolicies || getImagesIamPolicies.err || !getImagesIamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query IAM Policies for Disk Images: ' + helpers.addError(getImagesIamPolicies), 'global'); + return callback(null, results, source); + } + + if (!getImagesIamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM Policies found', 'global'); + return callback(null, results, source); + } + + getImagesIamPolicies = getImagesIamPolicies.data; + + images.data.forEach(image => { + + let imageIamPolicy = getImagesIamPolicies.find(iamPolicy => iamPolicy.parent && iamPolicy.parent.id === image.id); + + let resource = helpers.createResourceName('images', image.name, project, 'global'); + + if (!imageIamPolicy || !imageIamPolicy.bindings || !imageIamPolicy.bindings.length) { + helpers.addResult(results, 0, + 'No IAM Policies found for disk image', 'global', resource); + } else { + var allowedAllUsers = false; + imageIamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role && roleBinding.members && roleBinding.members.length && (roleBinding.members.includes('allUsers') || roleBinding.members.includes('allAuthenticatedUsers'))) { + allowedAllUsers = true; + } + }); + if (!allowedAllUsers) { + helpers.addResult(results, 0, 'Disk Image is not publicly accessible', 'global', resource); + } else { + helpers.addResult(results, 2, 'Disk Image is publicly accessible', 'global', resource); + } + } + }); + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/publicDiskImages.spec.js b/plugins/google/compute/publicDiskImages.spec.js new file mode 100644 index 000000000..2cb9d2d35 --- /dev/null +++ b/plugins/google/compute/publicDiskImages.spec.js @@ -0,0 +1,241 @@ +var expect = require('chai').expect; +var plugin = require('./publicDiskImages'); + +const imagePolicies = [ + { + "version": 1, + "etag": "BwXZ3RM6WFs=", + "bindings": [ + { + "role": "roles/compute.imageUser", + "members": [ + "allUsers", + ] + }, + ], + "parent": { + "id": "1231", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-1", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-1", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-1", + + "kind": "compute#image" + } + }, + { + "version": 1, + "etag": "BwXZ3RM6WFs=", + "bindings": [ + { + "role": "roles/compute.imageUser", + "members": [ + "myserviceaccount@gmail.com", + ] + }, + ], + "parent": { + "id": 1232, + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-2", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-2", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-2", + "kind": "compute#image" + } + } +] +const images = [ + { + "id": "1231", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-1", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-1", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-1", + "kind": "compute#image" + }, + { + "id": 1232, + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-2", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-2", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-2", + "kind": "compute#image" + }, + { + "id": "1233", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "image-3", + "sourceDisk": "https://www.googleapis.com/compute/v1/projects/test-proj/zones/us-central1-a/disks/disk-3", + "sourceDiskId": "4476293856257965646", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-proj/global/images/image-3", + "kind": "compute#image" + } +]; +const createCache = (imageData, error, policyData, policyErr) => { + return { + images: { + list: { + 'global': { + data: imageData, + err: error + } + }, + getIamPolicy: { + 'global': { + data: policyData, + err: policyErr + + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('publicDiskImages', function () { + describe('run', function () { + + it('should give unknown if unable to query disk images', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Disk Images'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'], + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no disk images found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Disk Images found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query IAM Policies for disk images', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query IAM Policies for Disk Images'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + images, + null, + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no IAM Policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM Policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + images, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if disk image is publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk Image is publicly accessible'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[0]], + null, + [imagePolicies[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no IAM policies are found for disk image', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM Policies found for disk image'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[2]], + null, + [imagePolicies], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if disk image is not publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk Image is not publicly accessible'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [images[1]], + null, + [imagePolicies[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/shieldedVmEnabled.js b/plugins/google/compute/shieldedVmEnabled.js new file mode 100644 index 000000000..9c1f1c16e --- /dev/null +++ b/plugins/google/compute/shieldedVmEnabled.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Shielded VM Enabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that instances are configured with the shielded VM enabled', + more_info: 'Shielded VM option should be configured to defend against the security attacks on the instances.', + link: 'https://cloud.google.com/security/shielded-cloud/shielded-vm', + recommended_action: 'Enable the shielded VM for all the instances for security reasons.', + apis: ['compute:list'], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.updateShieldedInstanceConfig'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + var noInstances = []; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); + } + + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + + if (instance.shieldedInstanceConfig && + instance.shieldedInstanceConfig.enableVtpm && + instance.shieldedInstanceConfig.enableIntegrityMonitoring) { + helpers.addResult(results, 0, + 'Shielded VM security is enabled for the the instance', region, resource); + } else { + helpers.addResult(results, 2, + 'Shielded VM security is not enabled for the the instance', region, resource); + } + }); + zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); + } + rcb(); + }); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/compute/shieldedVmEnabled.spec.js b/plugins/google/compute/shieldedVmEnabled.spec.js new file mode 100644 index 000000000..ae6b14a4d --- /dev/null +++ b/plugins/google/compute/shieldedVmEnabled.spec.js @@ -0,0 +1,113 @@ +var expect = require('chai').expect; +var plugin = require('./shieldedVmEnabled'); + +const createCache = (instanceData, error) => { + return { + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } + } + } +}; + +describe('shieldedVmEnabled', function () { + describe('run', function () { + + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass no VM Instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No instances found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache([]); + + plugin.run(cache, {}, callback); + }); + + it('should fail when shielded VM security is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Shielded VM security is not enabled for the the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "1719867382827328572", + name: "testing-instance", + zone: "https://www.googleapis.com/compute/v1/projects/test-dev-khulnasoft/zones/us-central1-a", + shieldedInstanceConfig: { + enableSecureBoot: false, + enableVtpm: false, + enableIntegrityMonitoring: false, + }, + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass when shielded VM security is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Shielded VM security is enabled for the the instance'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [ + { + id: "3736210870233209587", + name: "testing-instance2", + zone: "https://www.googleapis.com/compute/v1/projects/test-dev-khulnasoft/zones/us-central1-a", + shieldedInstanceConfig: { + enableSecureBoot: false, + enableVtpm: true, + enableIntegrityMonitoring: true, + }, + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}) \ No newline at end of file diff --git a/plugins/google/compute/snapshotEncryption.js b/plugins/google/compute/snapshotEncryption.js new file mode 100644 index 000000000..0516d4172 --- /dev/null +++ b/plugins/google/compute/snapshotEncryption.js @@ -0,0 +1,113 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Snapshot Encryption', + category: 'Compute', + domain: 'Compute', + severity: 'High', + description: 'Ensure Snapshots are encrypted using Customer Managed or Supplied Keys.', + more_info: 'GCP compute disk snapshots are encrypted with the encryption type of source disk. By default, the compute disks are encrypted using the Google-managed encryption keys. However, to have better control on the encryption process and adhere to compliance requirements, use either customer-managed keys or customer-supplied keys for encryption.', + link: 'https://cloud.google.com/compute/docs/disks/customer-managed-encryption', + recommended_action: 'Ensure that all disk snapshots are encrypted using desired protection level.', + apis: ['snapshots:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + snapshot_encryption_level: { + name: 'Snapshot Encryption Protection Level', + description: 'Desired protection level for disk snapshot. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + }, + }, + realtime_triggers: ['compute.snapshots.insert', 'compute.snapshots.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.snapshot_encryption_level || this.settings.snapshot_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) { + helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + } + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + + let snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', 'global']); + + if (!snapshots) return callback(null, results, source); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for disk snapshots: ' + helpers.addError(snapshots), 'global'); + return callback(null, results, source); + } + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No disk snapshots found', 'global'); + return callback(null, results, source); + } + + var snapshotsFound = false; + + snapshots.data.forEach(snapshot => { + + if (snapshot.creationTimestamp) { + snapshotsFound = true; + let resource = helpers.createResourceName('snapshot', snapshot.name, project, 'global'); + let currentEncryptionLevel = 1; // default + + if (snapshot.snapshotEncryptionKey && snapshot.snapshotEncryptionKey.kmsKeyName) { + let keyName = Object.keys(keysObj).find(key => snapshot.snapshotEncryptionKey.kmsKeyName.includes(key)); + if (keyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[keyName], helpers.PROTECTION_LEVELS); + } + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Disk snapshot has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, 'global', resource); + } else { + helpers.addResult(results, 2, + `Disk snapshot has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, 'global', resource); + } + } + }); + + if (!snapshotsFound) { + helpers.addResult(results, 0, 'No snapshots found in the project', 'global', project); + } + cb(); + } + ], function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/compute/snapshotEncryption.spec.js b/plugins/google/compute/snapshotEncryption.spec.js new file mode 100644 index 000000000..c40c71cea --- /dev/null +++ b/plugins/google/compute/snapshotEncryption.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var plugin = require('./snapshotEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const snapshots = [ + { + id: '11111', + creationTimestamp: new Date(), + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + }, + { + id: '22222', + creationTimestamp: new Date(), + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-2', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + snapshotEncryptionKey: { + kmsKeyName: 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2/cryptoKeyVersions/1' + }, + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-2', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } +]; + +const createCache = (snapshotData, error, keysErr, keysList) => { + return { + + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + snapshots: { + list: { + 'global': { + data: snapshotData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('snapshotEncryption', function () { + describe('run', function () { + + it('should give unknown if unable to query compute disk snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for disk snapshots'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'], + null, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disk snapshots found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No disk snapshots found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + null, + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if disk snapshot has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [snapshots[1]], + null, + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if disk snapshot does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [snapshots[0]], + null, + null, + cryptoKeys + ); + + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/compute/snapshotLabelsAdded.js b/plugins/google/compute/snapshotLabelsAdded.js new file mode 100644 index 000000000..e91edd727 --- /dev/null +++ b/plugins/google/compute/snapshotLabelsAdded.js @@ -0,0 +1,72 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Snapshot Labels Added', + category: 'Compute', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Compute disk snapshots have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/compute/docs/labeling-resources', + recommended_action: 'Ensure labels are added to all Compute disk snapshots.', + apis: ['snapshots:list'], + realtime_triggers: ['compute.snapshots.insert', 'compute.snapshots.delete', 'compute.snapshots.setLabels'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + + var project = projects.data[0].name; + + let snapshots = helpers.addSource(cache, source, + ['snapshots', 'list', 'global']); + + if (!snapshots) return callback(null, results, source); + + if (snapshots.err || !snapshots.data) { + helpers.addResult(results, 3, 'Unable to query for disk snapshots: ' + helpers.addError(snapshots), 'global'); + return callback(null, results, source); + } + if (!snapshots.data.length) { + helpers.addResult(results, 0, 'No disk snapshots found', 'global'); + return callback(null, results, source); + } + + var snapshotsFound = false; + + snapshots.data.forEach(snapshot => { + if (snapshot.creationTimestamp) { + + snapshotsFound = true; + let resource = helpers.createResourceName('snapshot', snapshot.name, project, 'global'); + + if (snapshot.labels && + Object.keys(snapshot.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(snapshot.labels).length} labels found for compute disk snapshot`, 'global', resource); + } else { + helpers.addResult(results, 2, + 'Compute disk snapshot does not have any labels', 'global', resource); + } + + } + }); + + if (!snapshotsFound) { + helpers.addResult(results, 0, 'No snapshots found in the project', 'global', project); + } + + callback(null, results, source); + + } +}; \ No newline at end of file diff --git a/plugins/google/compute/snapshotLabelsAdded.spec.js b/plugins/google/compute/snapshotLabelsAdded.spec.js new file mode 100644 index 000000000..96f3a6988 --- /dev/null +++ b/plugins/google/compute/snapshotLabelsAdded.spec.js @@ -0,0 +1,129 @@ +var expect = require('chai').expect; +var plugin = require('./snapshotLabelsAdded'); + +const createCache = (snapshotData, error) => { + return { + snapshots: { + list: { + 'global': { + data: snapshotData, + err: error + } + } + }, + projects: { + get: { + 'global': { + data: 'test-proj' + } + } + } + } +}; + +describe('snapshotLabelsAdded', function () { + describe('run', function () { + it('should give unknown if unable to query compute disk snapshots', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for disk snapshots'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if No compute disk snapshots found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No disk snapshots found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if snapshot does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: '2020-09-08T01:48:16.346-07:00', + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should pass if snapshot has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for compute disk snapshot'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ + { + id: '11111', + creationTimestamp: new Date(), + name: 'snapshot-1', + status: 'READY', + sourceDisk: 'https://www.googleapis.com/compute/v1/projects/my-project-1/zones/us-central1-a/disks/disk-1', + sourceDiskId: '7633933784896409327', + diskSizeGb: '10', + storageBytes: '0', + labels: {test: "test"}, + storageBytesStatus: 'UP_TO_DATE', + selfLink: 'https://www.googleapis.com/compute/v1/projects/my-project-1/global/snapshots/snapshot-1', + labelFingerprint: '42WmSpB8rSM=', + storageLocations: ['us-central1'], + downloadBytes: '1390', + kind: 'compute#snapshot' + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/cryptographickeys/keyProtectionLevel.js b/plugins/google/cryptographickeys/keyProtectionLevel.js new file mode 100644 index 000000000..591115ee7 --- /dev/null +++ b/plugins/google/cryptographickeys/keyProtectionLevel.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Key Protection Level', + category: 'Cryptographic Keys', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that cryptographic keys have protection level equal to or above desired protection level.', + more_info: 'Cloud KMS cryptographic keys should be created with protection level set by your organization\'s compliance and security rules.', + link: 'https://cloud.google.com/kms/docs/reference/rest/v1/ProtectionLevel', + recommended_action: 'Create cryptographic keys according to desired protection level', + apis: ['keyRings:list','cryptoKeys:list'], + settings: { + kms_crypto_keys_protection_level: { + name: 'Cloud Cryptographic Keys Desired Protection Level', + description: 'Desired protection level for cryptographic keys. cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['CreateKeyRing', 'CreateCryptoKey'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + desiredProtectionLevel: settings.kms_crypto_keys_protection_level || this.settings.kms_crypto_keys_protection_level.default + }; + + async.each(regions.keyRings, function(region, rcb){ + let keyRings = helpers.addSource( + cache, source, ['keyRings', 'list', region]); + + if (!keyRings) return rcb(); + + if (keyRings.err || !keyRings.data) { + helpers.addResult(results, 3, 'Unable to query key rings', region, null, null, keyRings.err); + return rcb(); + } + + if (!keyRings.data.length) { + helpers.addResult(results, 0, 'No key rings found', region); + return rcb(); + } + + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (!cryptoKeys) return rcb(); + + if (cryptoKeys.err || !cryptoKeys.data) { + helpers.addResult(results, 3, 'Unable to query cryptographic keys', region, null, null, cryptoKeys.err); + return rcb(); + } + + if (!cryptoKeys.data.length) { + helpers.addResult(results, 0, 'No cryptographic keys found', region); + return rcb(); + } + + cryptoKeys.data.forEach(cryptoKey => { + let currentProtectionLevel = helpers.getProtectionLevel(cryptoKey, helpers.PROTECTION_LEVELS); + let currentProtectionLevelStr = helpers.PROTECTION_LEVELS[currentProtectionLevel]; + if (currentProtectionLevel >= helpers.PROTECTION_LEVELS.indexOf(config.desiredProtectionLevel)) { + helpers.addResult(results, 0, + `Key protection level is ${currentProtectionLevelStr} which is greater than or equal to ${config.desiredProtectionLevel}`, + region, cryptoKey.name); + } else { + helpers.addResult(results, 2, + `Key protection level is ${currentProtectionLevelStr} which is less than ${config.desiredProtectionLevel}`, + region, cryptoKey.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cryptographickeys/keyProtectionLevel.spec.js b/plugins/google/cryptographickeys/keyProtectionLevel.spec.js new file mode 100644 index 000000000..76196000b --- /dev/null +++ b/plugins/google/cryptographickeys/keyProtectionLevel.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +var plugin = require('./keyProtectionLevel'); + +const cryptoKeys = [ + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek", + "primary": { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek/cryptoKeyVersions/1", + "protectionLevel": "HSM", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION", + "generateTime": "2021-06-15T13:22:44.808595111Z" + }, + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T13:22:44.808595111Z", + "versionTemplate": { + "protectionLevel": "HSM", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION" + } + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1", + "primary": { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1/cryptoKeyVersions/1", + "createTime": "2021-06-15T13:27:10.444152476Z", + "protectionLevel": "SOFTWARE", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION", + "generateTime": "2021-06-15T13:27:10.444152476Z" + }, + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-csek-1", + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T14:05:00.127824829Z", + "versionTemplate": { + "protectionLevel": "EXTERNAL", + "algorithm": "EXTERNAL_SYMMETRIC_ENCRYPTION" + } + } +]; + +const createCache = (err, data) => { + return { + keyRings: { + list: { + 'us-central1': { + data: [ + { + 'name': 'projects/test-project/locations/us-central1/keyRings/test-kr-1', + 'createTime': '2021-06-14T13:58:29.562215224Z' + } + ] + }, + } + }, + cryptoKeys: { + list: { + 'us-central1': { + err: err, + data: data + } + } + } + } +}; + +describe('keyProtectionLevel', function () { + describe('run', function () { + it('should give unknown result if unable to query cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query cryptographic keys'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no cryptographic keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No cryptographic keys found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if key protection level greater than or equal to desired protection level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include(' greater than or equal to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [cryptoKeys[0]] + ); + + plugin.run(cache, { kms_crypto_keys_protection_level: 'cloudhsm' }, callback); + }); + + it('should give failing result if key protection level is which is less than desired protection level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [cryptoKeys[1]] + ); + + plugin.run(cache, { kms_crypto_keys_protection_level: 'cloudcmek' }, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/cryptographickeys/keyRotation.js b/plugins/google/cryptographickeys/keyRotation.js index 553e5a209..42e52d80f 100644 --- a/plugins/google/cryptographickeys/keyRotation.js +++ b/plugins/google/cryptographickeys/keyRotation.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Key Rotation', category: 'Cryptographic Keys', + domain: 'Application Integration', + severity: 'Low', description: 'Ensures cryptographic keys are set to rotate on a regular schedule', more_info: 'All cryptographic keys should have key rotation enabled. Google will handle the rotation of the encryption key itself, as well as storage of previous keys, so previous data does not need to be re-encrypted before the rotation occurs.', link: 'https://cloud.google.com/vpc/docs/using-cryptoKeys', @@ -19,20 +21,36 @@ module.exports = { 'users or systems accessing HIPAA-compliant environments.', }, + realtime_triggers: ['CreateKeyRing', 'CreateCryptoKey', 'UpdateCryptoKey'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.cryptoKeys, function(region, rcb){ + async.each(regions.keyRings, function(region, rcb){ + let keyRings = helpers.addSource( + cache, source, ['keyRings', 'list', region]); + + if (!keyRings) return rcb(); + + if (keyRings.err || !keyRings.data) { + helpers.addResult(results, 3, 'Unable to query key rings', region, null, null, keyRings.err); + return rcb(); + } + + if (!keyRings.data.length) { + helpers.addResult(results, 0, 'No key rings found', region); + return rcb(); + } + let cryptoKeys = helpers.addSource( cache, source, ['cryptoKeys', 'list', region]); if (!cryptoKeys) return rcb(); if (cryptoKeys.err || !cryptoKeys.data) { - helpers.addResult(results, 3, 'Unable to query cryptographic keys: ' + helpers.addError(cryptoKeys), region); + helpers.addResult(results, 3, 'Unable to query cryptographic keys', region, null, null, cryptoKeys.err); return rcb(); } @@ -55,4 +73,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/cryptographickeys/keyRotation.spec.js b/plugins/google/cryptographickeys/keyRotation.spec.js new file mode 100644 index 000000000..f6c4c8d63 --- /dev/null +++ b/plugins/google/cryptographickeys/keyRotation.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +var plugin = require('./keyRotation'); + +const cryptoKeys = [ + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek", + "primary": { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek/cryptoKeyVersions/1", + "protectionLevel": "HSM", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION", + "generateTime": "2021-06-15T13:22:44.808595111Z" + }, + "rotationPeriod": '7776000s', + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T13:22:44.808595111Z", + "versionTemplate": { + "protectionLevel": "HSM", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION" + } + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1", + "primary": { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1/cryptoKeyVersions/1", + "createTime": "2021-06-15T13:27:10.444152476Z", + "protectionLevel": "SOFTWARE", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION", + "generateTime": "2021-06-15T13:27:10.444152476Z" + }, + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-csek-1", + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T14:05:00.127824829Z", + "versionTemplate": { + "protectionLevel": "EXTERNAL", + "algorithm": "EXTERNAL_SYMMETRIC_ENCRYPTION" + } + } +]; + +const createCache = (err, data) => { + return { + keyRings: { + list: { + 'us-central1': { + data: [ + { + 'name': 'projects/test-project/locations/us-central1/keyRings/test-kr-1', + 'createTime': '2021-06-14T13:58:29.562215224Z' + } + ] + }, + } + }, + cryptoKeys: { + list: { + 'us-central1': { + err: err, + data: data + } + } + } + } +}; + +describe('keyRotation', function () { + describe('run', function () { + it('should give unknown result if unable to query cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query cryptographic keys'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no cryptographic keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No cryptographic keys found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if key rotation is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key rotation is enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [cryptoKeys[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if key rotation is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key rotation is not enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [cryptoKeys[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/cryptographickeys/kmsPublicAccess.js b/plugins/google/cryptographickeys/kmsPublicAccess.js new file mode 100644 index 000000000..c87ea438b --- /dev/null +++ b/plugins/google/cryptographickeys/kmsPublicAccess.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'KMS Public Access', + category: 'Cryptographic Keys', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures cryptographic keys are not publicly accessible.', + more_info: 'To prevent exposing sensitive data and information leaks, make sure that your cryptokeys do not allow access from anonymous and public users.', + link: 'https://cloud.google.com/kms/docs/reference/permissions-and-roles', + recommended_action: 'Ensure that your cryptographic keys are not accessible by allUsers or allAuthenticatedUsers.', + apis: ['keyRings:list', 'cryptoKeys:list', 'cryptoKeys:getIamPolicy'], + realtime_triggers: ['CreateKeyRing', 'CreateCryptoKey'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.keyRings, function(region, rcb) { + let keyRings = helpers.addSource( + cache, source, ['keyRings', 'list', region]); + + if (!keyRings) return rcb(); + + if (keyRings.err || !keyRings.data) { + helpers.addResult(results, 3, 'Unable to query key rings', region, null, null, keyRings.err); + return rcb(); + } + + if (!keyRings.data.length) { + helpers.addResult(results, 0, 'No key rings found', region); + return rcb(); + } + + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (!cryptoKeys) return rcb(); + + if (cryptoKeys.err || !cryptoKeys.data) { + helpers.addResult(results, 3, 'Unable to query cryptographic keys', region, null, null, cryptoKeys.err); + return rcb(); + } + + if (!cryptoKeys.data.length) { + helpers.addResult(results, 0, 'No cryptographic keys found', region); + return rcb(); + } + + let cryptoKeysIamPolicies = helpers.addSource( + cache, source, ['cryptoKeys', 'getIamPolicy', region]); + + if (!cryptoKeysIamPolicies || cryptoKeysIamPolicies.err || !cryptoKeysIamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query IAM Policies for Cryptographic Keys: ' + helpers.addError(cryptoKeysIamPolicies), region); + return rcb(); + } + + if (!cryptoKeysIamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM Policies found', region); + return rcb(); + } + + cryptoKeysIamPolicies = cryptoKeysIamPolicies.data; + + cryptoKeys.data.forEach(cryptoKey => { + if (!cryptoKey.name) return; + + let keyIamPolicy = cryptoKeysIamPolicies.find(iamPolicy => iamPolicy.parent && iamPolicy.parent.name === cryptoKey.name); + + if (!keyIamPolicy || !keyIamPolicy.bindings || !keyIamPolicy.bindings.length) { + helpers.addResult(results, 0, + 'No IAM Policies found for cryptographic key', region, cryptoKey.name); + } else { + var allowedAllUsers = false; + keyIamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role && roleBinding.members && roleBinding.members.length && (roleBinding.members.includes('allUsers') || roleBinding.members.includes('allAuthenticatedUsers'))) { + allowedAllUsers = true; + } + }); + if (!allowedAllUsers) { + helpers.addResult(results, 0, 'Cryptographic Key is not publicly accessible', region, cryptoKey.name); + } else { + helpers.addResult(results, 2, 'Cryptographic Key is publicly accessible', region, cryptoKey.name); + } + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/cryptographickeys/kmsPublicAccess.spec.js b/plugins/google/cryptographickeys/kmsPublicAccess.spec.js new file mode 100644 index 000000000..95d11cc5f --- /dev/null +++ b/plugins/google/cryptographickeys/kmsPublicAccess.spec.js @@ -0,0 +1,231 @@ +var expect = require('chai').expect; +var plugin = require('./kmsPublicAccess'); + +const cryptoKeys = [ + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek", + "rotationPeriod": '7776000s', + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T13:22:44.808595111Z" + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1", + "primary": { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1/cryptoKeyVersions/1", + "createTime": "2021-06-15T13:27:10.444152476Z", + "protectionLevel": "SOFTWARE", + "algorithm": "GOOGLE_SYMMETRIC_ENCRYPTION", + "generateTime": "2021-06-15T13:27:10.444152476Z" + }, + }, + { + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-csek-1", + "purpose": "ENCRYPT_DECRYPT", + "createTime": "2021-06-15T14:05:00.127824829Z" + } +]; + +const keyPolicies = [ + { + "version": 1, + "etag": "BwXZ3RM6WFs=", + "bindings": [ + { + "role": "roles/cloudkms.cryptoKeyEncrypterDecrypter", + "members": [ + "allUsers", + ] + }, + ], + "parent": { + "id": "1231", + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek" + } + }, + { + "version": 1, + "etag": "BwXZ3RM6WFs=", + "bindings": [ + { + "role": "roles/cloudkms.cryptoKeyEncrypterDecrypter", + "members": [ + "myserviceaccount@gmail.com", + ] + }, + ], + "parent": { + "id": 1232, + "creationTimestamp": "2022-03-09T16:05:01.878-08:00", + "name": "projects/test-project/locations/us-central1/keyRings/test-kr-1/cryptoKeys/test-cmek-1", + } + } +] + +const createCache = (data, err, policyData, policyErr) => { + return { + keyRings: { + list: { + 'us-central1': { + data: [ + { + 'name': 'projects/test-project/locations/us-central1/keyRings/test-kr-1', + 'createTime': '2021-06-14T13:58:29.562215224Z' + } + ] + }, + } + }, + cryptoKeys: { + list: { + 'us-central1': { + err: err, + data: data + } + }, + getIamPolicy: { + 'us-central1': { + err: policyErr, + data: policyData + } + } + } + } +}; + +describe('kmsPublicAccess', function () { + describe('run', function () { + it('should give unknown result if unable to query cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query cryptographic keys'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + ['error'], + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no cryptographic keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No cryptographic keys found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query IAM Policies for cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query IAM Policies for Cryptographic Keys'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + cryptoKeys, + null, + [], + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no IAM Policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM Policies found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + cryptoKeys, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail if crypto key is publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cryptographic Key is publicly accessible'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [cryptoKeys[0]], + null, + [keyPolicies[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if no IAM policies are found for cryptographic key', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM Policies found for cryptographic key'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [cryptoKeys[2]], + null, + [keyPolicies], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should pass if cryptographic key is not publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cryptographic Key is not publicly accessible'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [cryptoKeys[1]], + null, + [keyPolicies[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/dataflow/dataflowHangedJobs.js b/plugins/google/dataflow/dataflowHangedJobs.js new file mode 100644 index 000000000..11f45312c --- /dev/null +++ b/plugins/google/dataflow/dataflowHangedJobs.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataflow Hanged Jobs', + category: 'Dataflow', + domain: 'Content Delivery', + severity: 'Medium', + description: 'Ensure that Cloud Dataflow jobs are not in same state for more than defined amount of time.', + more_info: 'Cloud Dataflow jobs transit between different states and normally reach terminal state. If they stay in same state ' + + 'for abnormal amount of time, job administrator should stop such jobs to save unnecessary cost.', + link: 'https://cloud.google.com/sdk/gcloud/reference/dataflow/jobs/cancel', + recommended_action: 'Cancel/stop Dataflow jobs which are in same state for more than set amount of time', + apis: ['jobs:list'], + settings: { + dataflow_job_state_time: { + name: 'Dataflow Job Maximum State Time', + description: 'Maximum allowed amount of time in hours for a Dataflow job state', + regex: '^(0?[1-9]|[1-9][0-9])$', + default: '6' + } + }, + realtime_triggers: ['dataflow.jobs.create', 'dataflow.jobs.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + dataflow_job_state_time: parseInt(settings.dataflow_job_state_time || this.settings.dataflow_job_state_time.default) + }; + + async.each(regions.jobs, function(region, rcb){ + let jobs = helpers.addSource(cache, source, + ['jobs', 'list', region]); + + if (!jobs) return rcb(); + + if (jobs.err || !jobs.data) { + helpers.addResult(results, 3, 'Unable to query Dataflow jobs: ' + helpers.addError(jobs), region, null, null, jobs.err); + return rcb(); + } + + if (!jobs.data.length) { + helpers.addResult(results, 0, 'No Dataflow jobs found', region); + return rcb(); + } + + async.each(jobs.data, (job, cb) => { + if (!job.id) return cb(); + + let resource = `projects/${job.projectId}/jobs/${job.id}`; + + if (job.currentState && !['JOB_STATE_RUNNING', 'JOB_STATE_DRAINING', 'JOB_STATE_CANCELLING'].includes(job.currentState.toUpperCase())) { + helpers.addResult(results, 0, + 'Dataflow job has completed', region, resource); + return cb(); + } + + let stateTime = job.currentStateTime || new Date(); + let now = new Date(); + + let difference = helpers.hoursBetween(now, stateTime); + let status = (difference > config.dataflow_job_state_time) ? 2 : 0; + + helpers.addResult(results, status, + `Dataflow job is in ${job.currentState} for ${difference} hours`, + region, resource); + + cb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/dataflow/dataflowHangedJobs.spec.js b/plugins/google/dataflow/dataflowHangedJobs.spec.js new file mode 100644 index 000000000..78792a25f --- /dev/null +++ b/plugins/google/dataflow/dataflowHangedJobs.spec.js @@ -0,0 +1,161 @@ +var expect = require('chai').expect; +var plugin = require('./dataflowHangedJobs'); + +let failDate = new Date(); +failDate.setHours(failDate.getHours() - 7); + +const createCache = (err, data) => { + return { + jobs: { + list: { + 'us-east1': { + err: err, + data: data + } + } + } + } +}; + +describe('dataflowHangedJobs', function () { + describe('run', function () { + it('should give unknown result if a jobs error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Dataflow jobs'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no Dataflow jobs are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Dataflow jobs found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if the Dataflow job has completed', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Dataflow job has completed'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2021-07-28_06_24_04-9234006834741851541", + "projectId": "test-dev-khulnasoft", + "name": "test_job", + "type": "JOB_TYPE_BATCH", + "currentState": "JOB_STATE_DONE", + "currentStateTime": "2021-07-28T13:28:20.757358Z", + "createTime": "2021-07-28T13:24:06.416139Z", + "location": "us-central1", + "jobMetadata": { + "sdkVersion": { + "version": "2.29.0", + "versionDisplayName": "Apache Beam SDK for Java", + "sdkSupportStatus": "SUPPORTED" + } + }, + "startTime": "2021-07-28T13:24:06.416139Z" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if the Dataflow job is in same state for less than set hours', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Dataflow job is in JOB_STATE_RUNNING'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2021-07-28_06_24_04-9234006834741851541", + "projectId": "test-dev-khulnasoft", + "name": "test_job", + "type": "JOB_TYPE_BATCH", + "currentState": "JOB_STATE_RUNNING", + "currentStateTime": new Date(), + "createTime": "2021-07-28T13:24:06.416139Z", + "location": "us-central1", + "jobMetadata": { + "sdkVersion": { + "version": "2.29.0", + "versionDisplayName": "Apache Beam SDK for Java", + "sdkSupportStatus": "SUPPORTED" + } + }, + "startTime": "2021-07-28T13:24:06.416139Z" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if the Dataflow job is in same state for more than set hours', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Dataflow job is in JOB_STATE_RUNNING'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2021-07-28_06_24_04-9234006834741851541", + "projectId": "test-dev-khulnasoft", + "name": "test_job", + "type": "JOB_TYPE_BATCH", + "currentState": "JOB_STATE_RUNNING", + "currentStateTime": failDate, + "createTime": "2021-07-28T13:24:06.416139Z", + "location": "us-central1", + "jobMetadata": { + "sdkVersion": { + "version": "2.29.0", + "versionDisplayName": "Apache Beam SDK for Java", + "sdkSupportStatus": "SUPPORTED" + } + }, + "startTime": "2021-07-28T13:24:06.416139Z" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/dataflow/dataflowJobsEncryption.js b/plugins/google/dataflow/dataflowJobsEncryption.js new file mode 100644 index 000000000..e7015c286 --- /dev/null +++ b/plugins/google/dataflow/dataflowJobsEncryption.js @@ -0,0 +1,115 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataflow Jobs Encryption', + category: 'Dataflow', + domain: 'Content Delivery', + severity: 'High', + description: 'Ensure that Google Dataflow jobs are encrypted with desired encryption level.', + more_info: 'Google encrypts all jobs in Dataflow by default. Protecting source and sinks data for Dataflow batch pipeline with CMEK gives user more granular access to encryption and decryption process.', + link: 'https://cloud.google.com/dataflow/docs/guides/customer-managed-encryption-keys', + recommended_action: 'Use desired encryption level to encrypt Dataflow jobs.', + apis: ['jobs:list', 'jobs:get', 'keyRings:list','cryptoKeys:list'], + compliance: { + hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + + 'Enabling encryption for Dataflow jobs/pipelines helps to protect this data.', + pci: 'PCI requires proper encryption of cardholder data at rest. ' + + 'Encryption should be enabled for all jobs storing this ' + + 'type of data.' + }, + settings: { + dataflow_job_encryption_level: { + name: 'Dataflow Job Encryption Protection Level', + description: 'Desired protection level for Dataflow jobs. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['dataflow.jobs.create', 'dataflow.jobs.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + desiredEncryptionLevelStr: settings.dataflow_job_encryption_level || this.settings.dataflow_job_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(config.desiredEncryptionLevelStr); + var keysObj = {}; + + async.series([ + function(cb){ + async.each(regions.cryptoKeys, function(region, rcb){ + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function(){ + cb(); + }); + }, + function(cb){ + async.each(regions.jobs, function(jregion, jrcb){ + let jobs = helpers.addSource(cache, source, + ['jobs', 'get', jregion]); + + if (!jobs) return jrcb(); + + if (jobs.err || !jobs.data) { + helpers.addResult(results, 3, 'Unable to query Dataflow jobs: ' + helpers.addError(jobs), jregion); + return jrcb(); + } + + if (!jobs.data.length) { + helpers.addResult(results, 0, 'No Dataflow jobs found', jregion); + return jrcb(); + } + + async.each(jobs.data, (job, jcb) => { + if (!job.id) return jcb(); + + let currentEncryptionLevel; + let resource = `projects/${job.projectId}/jobs/${job.id}`; + + if (job.type && job.type.toUpperCase() != 'JOB_TYPE_BATCH') { + helpers.addResult(results, 0, + `CMEK is not supported for ${job.type}`, jregion, resource); + return jcb(); + } + + if (!job.environment || !job.environment.serviceKmsKeyName || !job.environment.serviceKmsKeyName.length) { + currentEncryptionLevel = 1; + } else { + let cryptoKey = keysObj[job.environment.serviceKmsKeyName]; + currentEncryptionLevel = helpers.getProtectionLevel(cryptoKey, helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Dataflow job is encrypted with ${currentEncryptionLevelStr} which is greater than or equal to ${config.desiredEncryptionLevelStr}`, + jregion, resource); + } else { + helpers.addResult(results, 2, + `Dataflow job is encrypted with ${currentEncryptionLevelStr} which is less than ${config.desiredEncryptionLevelStr}`, + jregion, resource); + } + + jcb(); + }, function(){ + jrcb(); + }); + }, function(){ + cb(); + }); + } + ], function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/dataflow/dataflowJobsEncryption.spec.js b/plugins/google/dataflow/dataflowJobsEncryption.spec.js new file mode 100644 index 000000000..8ee5d9e81 --- /dev/null +++ b/plugins/google/dataflow/dataflowJobsEncryption.spec.js @@ -0,0 +1,220 @@ +var expect = require('chai').expect; +var plugin = require('./dataflowJobsEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const jobs = [ + { + id: '2021-07-29_00_17_04-11865074052061045884', + projectId: 'test-dev-khulnasoft', + name: 'test-made2', + type: 'JOB_TYPE_BATCH', + environment: { + userAgent: { + name: 'Apache Beam SDK for Java', + 'fnapi.environment.major.version': '8', + 'os.name': 'Linux', + version: '2.29.0', + 'legacy.environment.major.version': '8', + 'os.version': '4.15.0-smp-913.27.0.0', + 'java.version': '11.0.11', + 'java.vendor': 'Google Inc.', + 'os.arch': 'amd64', + 'container.version': 'beam-2.29.0' + }, + shuffleMode: 'SERVICE_BASED' + }, + currentState: 'JOB_STATE_DONE', + currentStateTime: '2021-07-29T07:22:00.002168Z', + createTime: '2021-07-29T07:17:07.832499Z', + location: 'europe-west1', + }, + { + id: '2021-07-29_00_17_04-11865074052061045884', + projectId: 'test-dev-khulnasoft', + name: 'test-made2', + type: 'JOB_TYPE_BATCH', + environment: { + userAgent: { + name: 'Apache Beam SDK for Java', + 'fnapi.environment.major.version': '8', + 'os.name': 'Linux', + version: '2.29.0', + 'legacy.environment.major.version': '8', + 'os.version': '4.15.0-smp-913.27.0.0', + 'java.version': '11.0.11', + 'java.vendor': 'Google Inc.', + 'os.arch': 'amd64', + 'container.version': 'beam-2.29.0' + }, + serviceKmsKeyName: 'projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2', + shuffleMode: 'SERVICE_BASED' + }, + currentState: 'JOB_STATE_DONE', + currentStateTime: '2021-07-29T07:22:00.002168Z', + createTime: '2021-07-29T07:17:07.832499Z', + location: 'europe-west1', + }, + { + id: '2021-07-29_00_17_04-11865074052061045884', + projectId: 'test-dev-khulnasoft', + name: 'test-made2', + type: 'JOB_TYPE_STREAMING', + environment: { + userAgent: { + name: 'Apache Beam SDK for Java', + 'fnapi.environment.major.version': '8', + 'os.name': 'Linux', + version: '2.29.0', + 'legacy.environment.major.version': '8', + 'os.version': '4.15.0-smp-913.27.0.0', + 'java.version': '11.0.11', + 'java.vendor': 'Google Inc.', + 'os.arch': 'amd64', + 'container.version': 'beam-2.29.0' + }, + shuffleMode: 'SERVICE_BASED' + }, + currentState: 'JOB_STATE_DONE', + currentStateTime: '2021-07-29T07:22:00.002168Z', + createTime: '2021-07-29T07:17:07.832499Z', + location: 'europe-west1', + } +]; + +const createCache = (getJobs, errJobs, listKeys, errKeys) => { + return { + jobs: { + get: { + 'us-east1': { + err: errJobs, + data: getJobs + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: errKeys, + data: listKeys + } + } + } + } +}; + +describe('dataflowJobsEncryption', function () { + describe('run', function () { + it('should give passing result if no Dataflow jobs found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Dataflow jobs found'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Dataflow job is encrypted with desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('greater than or equal to'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [jobs[1]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Dataflow job is not of JOB_TYPE_BATCH type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('CMEK is not supported'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [jobs[2]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Dataflow job is not encrypted with desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [jobs[0]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Dataflow jobs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Dataflow jobs'); + expect(results[0].region).to.equal('us-east1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + }) +}); + diff --git a/plugins/google/dataproc/dataprocClusterEncryption.js b/plugins/google/dataproc/dataprocClusterEncryption.js new file mode 100644 index 000000000..0c7867174 --- /dev/null +++ b/plugins/google/dataproc/dataprocClusterEncryption.js @@ -0,0 +1,113 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataproc Cluster Encryption', + category: 'Dataproc', + domain: 'Compute', + severity: 'High', + description: 'Ensure that Dataproc clusters have encryption enabled using desired protection level.', + more_info: 'By default, all dataproc clusters are encrypted using Google-managed keys. To have better control over how your dataproc clusters are encrypted, you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/dataproc/docs/concepts/configuring-clusters/customer-managed-encryption', + recommended_action: 'Ensure that all Dataproc clusters have desired encryption level.', + apis: ['dataproc:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + dataproc_cluster_encryption_level: { + name: 'Dataproc Cluster Encryption Level', + description: 'Desired protection level for Dataproc clusters. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['dataproc.ClusterController.CreateCluster', 'dataprocClusterController.DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.dataproc_cluster_encryption_level || this.settings.dataproc_cluster_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.dataproc, function(region, rcb) { + + let clusters = helpers.addSource( + cache, source, ['dataproc', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Dataproc clusters: ' + helpers.addError(clusters), region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Dataproc clusters found', region); + return rcb(); + } + + if (clusters && clusters.data) { + clusters.data.forEach(cluster => { + if (!cluster.clusterName) return; + + let resource = helpers.createResourceName('clusters', cluster.clusterName, project, 'region', region); + let currentEncryptionLevel; + + if (cluster && cluster.config && cluster.config.encryptionConfig && cluster.config.encryptionConfig.gcePdKmsKeyName + && keysObj[cluster.config.encryptionConfig.gcePdKmsKeyName]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[cluster.config.encryptionConfig.gcePdKmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Dataproc cluster has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Dataproc cluster has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + }); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/dataproc/dataprocClusterEncryption.spec.js b/plugins/google/dataproc/dataprocClusterEncryption.spec.js new file mode 100644 index 000000000..be0a030d9 --- /dev/null +++ b/plugins/google/dataproc/dataprocClusterEncryption.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var plugin = require('./dataprocClusterEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const clusters = [ + { + projectId: 'testproj', + clusterName: 'cluster-1', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + config: { + encryptionConfig: { + gcePdKmsKeyName: 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + } + } + }, + { + projectId: 'testproj', + clusterName: 'cluster-2', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + config: { + securityConfig: { kerberosConfig: {} }, + endpointConfig: {} + }, + labels: {} + }, +]; + +const createCache = (err, data, keysList, keysErr) => { + return { + dataproc: { + list: { + 'us-central1': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('dataprocClusterEncryption', function () { + describe('run', function () { + it('should give unknown result if a cluster error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Dataproc clusters'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no dataproc clusters found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Dataproc clusters found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if cluster has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [clusters[0]], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if cluster does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + const cache = createCache( + null, + [clusters[1]], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/google/dataproc/dataprocClusterLabelsAdded.js b/plugins/google/dataproc/dataprocClusterLabelsAdded.js new file mode 100644 index 000000000..9dc9205ba --- /dev/null +++ b/plugins/google/dataproc/dataprocClusterLabelsAdded.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dataproc Cluster Labels Added', + category: 'Dataproc', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that all Dataproc clusters have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/dataproc/docs/guides/creating-managing-labels', + recommended_action: 'Ensure labels are added to all Dataproc clusters.', + apis: ['dataproc:list'], + realtime_triggers: ['dataproc.ClusterController.CreateCluster', 'dataprocClusterController.DeleteCluster', 'dataproc.ClusterController.UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.dataproc, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['dataproc' ,'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Dataproc clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Dataproc clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + if (!cluster.clusterName) return; + + let resource = helpers.createResourceName('clusters', cluster.clusterName, project, 'region', region); + + if (cluster.labels && + Object.keys(cluster.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(cluster.labels).length} labels found for Dataproc cluster`, region, resource); + } else { + helpers.addResult(results, 2, + 'Dataproc cluster does not have any labels', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/dataproc/dataprocClusterLabelsAdded.spec.js b/plugins/google/dataproc/dataprocClusterLabelsAdded.spec.js new file mode 100644 index 000000000..59ed734d3 --- /dev/null +++ b/plugins/google/dataproc/dataprocClusterLabelsAdded.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var plugin = require('./dataprocClusterLabelsAdded'); + +const clusters = [ + { + projectId: 'testproj', + clusterName: 'cluster-1', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + labels: { + 'goog-dataproc-cluster-name': 'cluster-bd18', + 'goog-dataproc-cluster-uuid': '090e0094-dfb8-4d21-944c-d452452e528f', + 'goog-dataproc-location': 'us-central1' + } + }, + { + projectId: 'testproj', + clusterName: 'cluster-2', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + labels: {} + }, +]; + +const createCache = (err, data) => { + return { + dataproc: { + list: { + 'us-central1': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('dataprocClusterLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a cluster error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Dataproc clusters'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Dataproc clusters found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Dataproc cluster'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [clusters[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Dataproc cluster does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [clusters[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/dataproc/hadoopSecureModeEnabled.js b/plugins/google/dataproc/hadoopSecureModeEnabled.js new file mode 100644 index 000000000..2b401b1f8 --- /dev/null +++ b/plugins/google/dataproc/hadoopSecureModeEnabled.js @@ -0,0 +1,70 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Hadoop Secure Mode Enabled', + category: 'Dataproc', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that all Dataproc clusters have hadoop secure mode enabled.', + more_info: 'Enabling Hadoop secure mode will allow multi-tenancy with security features like isolation, encryption, and user authentication within the cluster. It also enforces all Hadoop services and users to be authenticated via Kerberos Key distribution.', + link: 'https://cloud.google.com/dataproc/docs/concepts/configuring-clusters/security', + recommended_action: 'Enable Hadoop secure mode for all Dataproc clusters.', + apis: ['dataproc:list'], + realtime_triggers: ['dataproc.ClusterController.CreateCluster', 'dataprocClusterController.DeleteCluster', 'dataproc.ClusterController.UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.dataproc, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['dataproc' ,'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Dataproc clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Dataproc clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + + if (!cluster.clusterName) return; + + let resource = helpers.createResourceName('clusters', cluster.clusterName, project, 'region', region); + + if (cluster.config && cluster.config.securityConfig + && cluster.config.securityConfig.kerberosConfig && cluster.config.securityConfig.kerberosConfig.enableKerberos) { + helpers.addResult(results, 0, + 'Hadoop Secure mode is enabled for Dataproc cluster', region, resource); + } else { + helpers.addResult(results, 2, + 'Hadoop Secure mode is not enabled for Dataproc cluster', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/dataproc/hadoopSecureModeEnabled.spec.js b/plugins/google/dataproc/hadoopSecureModeEnabled.spec.js new file mode 100644 index 000000000..d8bc126a0 --- /dev/null +++ b/plugins/google/dataproc/hadoopSecureModeEnabled.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var plugin = require('./hadoopSecureModeEnabled'); + +const clusters = [ + { + projectId: 'testproj', + clusterName: 'cluster-1', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + config: { + securityConfig: { + kerberosConfig: {enableKerberos: true} + } + } + }, + { + projectId: 'testproj', + clusterName: 'cluster-2', + status: { state: 'RUNNING', stateStartTime: '2022-10-31T19:51:22.817294Z' }, + statusHistory: [ + { + state: 'CREATING', + stateStartTime: '2022-10-31T19:49:56.933052Z' + } + ], + labels: {} + }, +]; + +const createCache = (err, data) => { + return { + dataproc: { + list: { + 'us-central1': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('hadoopSecureModeEnabled', function () { + describe('run', function () { + it('should give unknown result if a cluster error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Dataproc clusters'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Dataproc clusters found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if hadoop secure mode is enabled for the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Hadoop Secure mode is enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [clusters[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if hadoop secure mode is not enabled for the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Hadoop Secure mode is not enabled'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [clusters[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/deploymentmanager/deleteExpiredDeployments.js b/plugins/google/deploymentmanager/deleteExpiredDeployments.js new file mode 100644 index 000000000..e58eab4e1 --- /dev/null +++ b/plugins/google/deploymentmanager/deleteExpiredDeployments.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Delete Expired Deployments', + category: 'Deployment Manager', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that Cloud Deployment Manager deployment are deleted after desired number of days from their creation time.', + more_info: 'Cloud Deployment Manager deployments should be deleted after desired time period from their creation time as determined by your governance rules.', + link: 'https://cloud.google.com/deployment-manager/docs/deployments/deleting-deployments', + recommended_action: 'Delete expired deoplyments from Deployment Manager', + apis: ['deployments:list'], + settings: { + deployments_expiration_time: { + name: 'Deployments Expiration Time', + description: 'Number of days from creation of deployment after which it should be considered expired', + regex: '^.*$', + default: false + } + }, + realtime_triggers: ['deploymentmanager.deployments.create', 'deploymentmanager.deployments.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + deployments_expiration_time: settings.deployments_expiration_time || this.settings.deployments_expiration_time.default + }; + + if (config.deployments_expiration_time === false) return callback(null, results, source); + + config.deployments_expiration_time = parseInt(config.deployments_expiration_time); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.deployments, function(region, rcb){ + let deployments = helpers.addSource(cache, source, + ['deployments', 'list', region]); + + if (!deployments) return rcb(); + + if (deployments.err || !deployments.data) { + helpers.addResult(results, 3, 'Unable to query Deployment Manager deployments: ' + helpers.addError(deployments), region, null, null, deployments.err); + return rcb(); + } + + if (!deployments.data.length) { + helpers.addResult(results, 0, 'No Deployment Manager deployments found', region); + return rcb(); + } + + deployments.data.forEach(deployment => { + if (!deployment.name || !deployment.insertTime) return; + + let resource = helpers.createResourceName('deployments', deployment.name, project, 'global'); + let insertTime = deployment.insertTime; + let now = new Date(); + + let difference = Math.round((new Date(now).getTime() - new Date(insertTime).getTime())/(24*60*60*1000)); + + if (difference > config.deployments_expiration_time) { + helpers.addResult(results, 2, + `Deployment Manager deployment was created ${difference} days ago and has expired`, region, resource); + } else { + helpers.addResult(results, 0, + `Deployment Manager deployment was created ${difference} days ago`, region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/deploymentmanager/deleteExpiredDeployments.spec.js b/plugins/google/deploymentmanager/deleteExpiredDeployments.spec.js new file mode 100644 index 000000000..81c9c12c7 --- /dev/null +++ b/plugins/google/deploymentmanager/deleteExpiredDeployments.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var plugin = require('./deleteExpiredDeployments'); + +var failTime = new Date(); +failTime.setMonth(failTime.getMonth() - 1); + +const createCache = (err, data) => { + return { + deployments: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('deleteExpiredDeployments', function () { + describe('run', function () { + it('should give unknown result if a deployment error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Deployment Manager deployments'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, { deployments_expiration_time: '20' }, callback); + }); + + it('should give passing result if no Deployement Manager deployments found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Deployment Manager deployments found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, { deployments_expiration_time: '20' }, callback); + }); + + it('should give passing result if Deployment Manager deployment has not expired', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Deployment Manager deployment was created'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2251375723239485948", + "name": "wordpress-1", + "operation": { + "id": "0", + "name": "operation-1628245779848-5c8e183509e02-53ff2030-53da6f15", + "operationType": "insert", + "targetId": "2251375723239485948", + "status": "DONE", + "progress": 100, + "insertTime": "1969-12-31T16:00:00.000-08:00", + "startTime": "2021-08-06T03:29:40.293-07:00", + "endTime": "2021-08-06T03:30:50.983-07:00", + "kind": "deploymentmanager#operation" + }, + "fingerprint": "NG5zUVDkJK9BFxE-qP31iA==", + "insertTime": new Date(), + "updateTime": "2021-08-06T03:30:50.865-07:00", + } + + ] + ); + + plugin.run(cache, { deployments_expiration_time: '0' }, callback); + }); + + it('should give failing result if Deployment Manager deployment has expired', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('has expired'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2251375723239485948", + "name": "wordpress-1", + "operation": { + "id": "0", + "name": "operation-1628245779848-5c8e183509e02-53ff2030-53da6f15", + "operationType": "insert", + "targetId": "2251375723239485948", + "status": "DONE", + "progress": 100, + "insertTime": "1969-12-31T16:00:00.000-08:00", + "startTime": "2021-08-06T03:29:40.293-07:00", + "endTime": "2021-08-06T03:30:50.983-07:00", + "kind": "deploymentmanager#operation" + }, + "fingerprint": "NG5zUVDkJK9BFxE-qP31iA==", + "insertTime": failTime, + "updateTime": "2021-08-06T03:30:50.865-07:00", + } + ] + ); + + plugin.run(cache, { deployments_expiration_time: '20' }, callback); + }); + + it('should give nothing if deployment name or insertTime is not provided', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.equal(0); + done() + }; + + const cache = createCache( + null, + [ + { + "id": "2251375723239485948", + "operation": { + "id": "0", + "name": "operation-1628245779848-5c8e183509e02-53ff2030-53da6f15", + "operationType": "insert", + "targetId": "2251375723239485948", + "status": "DONE", + "progress": 100, + "insertTime": "1969-12-31T16:00:00.000-08:00", + "startTime": "2021-08-06T03:29:40.293-07:00", + "endTime": "2021-08-06T03:30:50.983-07:00", + "kind": "deploymentmanager#operation" + }, + "fingerprint": "NG5zUVDkJK9BFxE-qP31iA==", + "insertTime": failTime, + "updateTime": "2021-08-06T03:30:50.865-07:00", + } + ] + ); + + plugin.run(cache, { deployments_expiration_time: '20' }, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/google/dns/dnsSecEnabled.js b/plugins/google/dns/dnsSecEnabled.js index 886d3b908..9a6a4977f 100644 --- a/plugins/google/dns/dnsSecEnabled.js +++ b/plugins/google/dns/dnsSecEnabled.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DNS Security Enabled', category: 'DNS', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures that DNS Security is enabled on all managed zones', more_info: 'DNS Security is a feature that authenticates all responses to domain name lookups. This prevents attackers from committing DNS hijacking or man in the middle attacks.', link: 'https://cloud.google.com/dns/docs/dnssec', recommended_action: 'Ensure DNSSEC is enabled for all managed zones in the cloud DNS service.', apis: ['managedZones:list'], + realtime_triggers : ['dns.managedZones.create, dns.managedZones.delete', 'dns.managedZones.patch'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.managedZones, function(region, rcb){ let managedZones = helpers.addSource(cache, source, ['managedZones', 'list', region]); @@ -22,7 +36,7 @@ module.exports = { if (!managedZones) return rcb(); if (managedZones.err || !managedZones.data) { - helpers.addResult(results, 3, 'Unable to query DNS managed zones: ' + helpers.addError(managedZones), region); + helpers.addResult(results, 3, 'Unable to query DNS managed zones: ' + helpers.addError(managedZones), region, null, null, managedZones.err); return rcb(); } @@ -32,16 +46,17 @@ module.exports = { } managedZones.data.forEach(managedZone => { - if (!managedZone.dnssecConfig || - (managedZone.dnssecConfig && - (!managedZone.dnssecConfig.state || - (managedZone.dnssecConfig.state && - managedZone.dnssecConfig.state !== 'on')))) { - helpers.addResult(results, 2, - `The managed zone does not have DNS security enabled`, region, managedZone.id); - } else { - helpers.addResult(results, 0, 'The managed zone has DNS security enabled', region, managedZone.id); - } + let resource = helpers.createResourceName('zones', managedZone.name, project); + if (!managedZone.dnssecConfig || + (managedZone.dnssecConfig && + (!managedZone.dnssecConfig.state || + (managedZone.dnssecConfig.state && + managedZone.dnssecConfig.state !== 'on')))) { + helpers.addResult(results, 2, + 'The managed zone does not have DNS security enabled', region, resource); + } else { + helpers.addResult(results, 0, 'The managed zone has DNS security enabled', region, resource); + } }); rcb(); @@ -50,4 +65,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/dns/dnsSecEnabled.spec.js b/plugins/google/dns/dnsSecEnabled.spec.js index a653444d9..511dad69e 100644 --- a/plugins/google/dns/dnsSecEnabled.spec.js +++ b/plugins/google/dns/dnsSecEnabled.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } } } }; @@ -63,7 +70,7 @@ describe('dnsSecEnabled', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ @@ -114,7 +121,7 @@ describe('dnsSecEnabled', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ diff --git a/plugins/google/dns/dnsSecSigningAlgorithm.js b/plugins/google/dns/dnsSecSigningAlgorithm.js index 7d2b53df6..04a094e89 100644 --- a/plugins/google/dns/dnsSecSigningAlgorithm.js +++ b/plugins/google/dns/dnsSecSigningAlgorithm.js @@ -4,17 +4,30 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DNS Security Signing Algorithm', category: 'DNS', + domain: 'Content Delivery', + severity: 'Medium', description: 'Ensures that DNS Security is not using the RSASHA1 algorithm for key or zone signing', more_info: 'DNS Security is a feature that authenticates all responses to domain name lookups. This prevents attackers from committing DNS hijacking or man in the middle attacks.', link: 'https://cloud.google.com/dns/docs/dnssec', recommended_action: 'Ensure that all managed zones using DNSSEC are not using the RSASHA1 algorithm for key or zone signing.', apis: ['managedZones:list'], - + realtime_triggers : ['dns.managedZones.create, dns.managedZones.delete', 'dns.managedZones.patch'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.managedZones, function(region, rcb){ let managedZones = helpers.addSource(cache, source, ['managedZones', 'list', region]); @@ -23,7 +36,7 @@ module.exports = { if (managedZones.err || !managedZones.data) { helpers.addResult(results, 3, - 'Unable to query DNS managed zones: ' + helpers.addError(managedZones), region); + 'Unable to query DNS managed zones: ' + helpers.addError(managedZones), region, null, null, managedZones.err); return rcb(); } @@ -32,36 +45,37 @@ module.exports = { return rcb(); } - var dnsSecEnabled = false; managedZones.data.forEach(managedZone => { + let resource = helpers.createResourceName('zones', managedZone.name, project); + if (managedZone.dnssecConfig && managedZone.dnssecConfig.state && managedZone.dnssecConfig.state === 'on' && managedZone.dnssecConfig.defaultKeySpecs && managedZone.dnssecConfig.defaultKeySpecs.length) { - dnsSecEnabled = true; managedZone.dnssecConfig.defaultKeySpecs.forEach(keySpec => { if (keySpec.keyType === 'keySigning') { if (keySpec.algorithm.toLowerCase() === 'rsasha1') { helpers.addResult(results, 2, - 'RSASHA1 algorithm is being used for key signing', region, managedZone.id); + 'RSASHA1 algorithm is being used for key signing', region, resource); } else { helpers.addResult(results, 0, - 'RSASHA1 algorithm is not being for key signing', region, managedZone.id); + 'RSASHA1 algorithm is not being for key signing', region, resource); } } else if (keySpec.keyType === 'zoneSigning') { if (keySpec.algorithm.toLowerCase() === 'rsasha1') { helpers.addResult(results, 2, - 'RSASHA1 algorithm is being used for zone signing', region, managedZone.id); + 'RSASHA1 algorithm is being used for zone signing', region, resource); } else { helpers.addResult(results, 0, - 'RSASHA1 algorithm is not being used for zone signing', region, managedZone.id); + 'RSASHA1 algorithm is not being used for zone signing', region, resource); } } }); - helpers.addResult(results, 2, - 'DNSSEC is not enabled on the managed zone', region, managedZone.id); - + } else { + // DNSSEC not enabled + helpers.addResult(results, 0, + 'RSASHA1 algorithm is not being used for zone signing', region, resource); } }); diff --git a/plugins/google/dns/dnsSecSigningAlgorithm.spec.js b/plugins/google/dns/dnsSecSigningAlgorithm.spec.js index 1c4eac93c..a2e93a302 100644 --- a/plugins/google/dns/dnsSecSigningAlgorithm.spec.js +++ b/plugins/google/dns/dnsSecSigningAlgorithm.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } } } }; @@ -64,7 +71,7 @@ describe('dnsSecSigningAlgorithm', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ @@ -116,7 +123,7 @@ describe('dnsSecSigningAlgorithm', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ @@ -154,6 +161,39 @@ describe('dnsSecSigningAlgorithm', function () { plugin.run(cache, {}, callback); }); + it('should give passing result if the managed zone does not have DNSSEC configuration', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('RSASHA1 algorithm is not being used for zone signing'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "giotestdnszone1", + "dnsName": "cloudexploit.com.", + "description": "", + "id": "4534388710135378441", + "nameServers": [ + "ns-cloud-e1.googledomains.com.", + "ns-cloud-e2.googledomains.com.", + "ns-cloud-e3.googledomains.com.", + "ns-cloud-e4.googledomains.com." + ], + "creationTime": "2019-10-03T21:11:18.894Z", + "visibility": "public", + "kind": "dns#managedZone" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if the managed zone has key signing using RSASHA1', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); @@ -168,7 +208,7 @@ describe('dnsSecSigningAlgorithm', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ @@ -220,7 +260,7 @@ describe('dnsSecSigningAlgorithm', function () { [ { "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", + "dnsName": "cloudexploit.com.", "description": "", "id": "4534388710135378441", "nameServers": [ diff --git a/plugins/google/dns/dnsZoneLabelsAdded.js b/plugins/google/dns/dnsZoneLabelsAdded.js new file mode 100644 index 000000000..adc0a67c4 --- /dev/null +++ b/plugins/google/dns/dnsZoneLabelsAdded.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'DNS Zone Labels Added', + category: 'DNS', + domain: 'Content Delivery', + severity: 'Low', + description: 'Ensure Cloud DNS zones have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/dns/docs/zones', + recommended_action: 'Ensure labels are added for all managed zones in the cloud DNS service.', + apis: ['managedZones:list'], + realtime_triggers : ['dns.managedZones.create, dns.managedZones.delete', 'dns.managedZones.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.managedZones, function(region, rcb){ + let managedZones = helpers.addSource(cache, source, + ['managedZones', 'list', region]); + + if (!managedZones) return rcb(); + + if (managedZones.err || !managedZones.data) { + helpers.addResult(results, 3, 'Unable to query DNS managed zones: ' + helpers.addError(managedZones), region, null, null, managedZones.err); + return rcb(); + } + + if (!managedZones.data.length) { + helpers.addResult(results, 0, 'No DNS managed zones found', region); + return rcb(); + } + + managedZones.data.forEach(managedZone => { + let resource = helpers.createResourceName('zones', managedZone.name, project); + + if (managedZone.labels && + Object.keys(managedZone.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(managedZone.labels).length} labels found for DNS managed zone`, region, resource); + } else { + helpers.addResult(results, 2, + 'DNS managed zone does not have any labels', region, resource); + } + + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/dns/dnsZoneLabelsAdded.spec.js b/plugins/google/dns/dnsZoneLabelsAdded.spec.js new file mode 100644 index 000000000..344f6482b --- /dev/null +++ b/plugins/google/dns/dnsZoneLabelsAdded.spec.js @@ -0,0 +1,124 @@ +var expect = require('chai').expect; +var plugin = require('./dnsZoneLabelsAdded'); + +const createCache = (err, data) => { + return { + managedZones: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('dnsZoneLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a managed zone error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query DNS managed zones'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no managed zone records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DNS managed zones found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if the managed zone has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for DNS managed zone'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "giotestdnszone1", + "dnsName": "cloudexploit.com.", + "description": "", + "id": "4534388710135378441", + "nameServers": [ + "ns-cloud-e1.googledomains.com.", + "ns-cloud-e2.googledomains.com.", + "ns-cloud-e3.googledomains.com.", + "ns-cloud-e4.googledomains.com." + ], + "creationTime": "2019-10-03T21:11:18.894Z", + "labels": {"test": "test"}, + "visibility": "public", + "kind": "dns#managedZone" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if the managed zone does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "giotestdnszone1", + "dnsName": "cloudexploit.com.", + "description": "", + "id": "4534388710135378441", + "nameServers": [ + "ns-cloud-e1.googledomains.com.", + "ns-cloud-e2.googledomains.com.", + "ns-cloud-e3.googledomains.com.", + "ns-cloud-e4.googledomains.com." + ], + "creationTime": "2019-10-03T21:11:18.894Z", + "visibility": "public", + "kind": "dns#managedZone" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/bigqueryAdmin.js b/plugins/google/iam/bigqueryAdmin.js new file mode 100644 index 000000000..1d49ae749 --- /dev/null +++ b/plugins/google/iam/bigqueryAdmin.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'BigQuery Admin', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that there are no IAM Users with BigQuery Admin, BigQuery Data Owner or BigQuery Data Editor role at the project level.', + more_info: 'The pre-defined roles like "BigQuery Admin" or "BigQuery Data Owner" or "BigQuery Data Editor" grant full permissions over Big Query resources and its data. As a best practice, avoid granting access to these roles at the project level; instead, grant specific Big Query related permissions to IAM members.', + link: 'https://cloud.google.com/iam/docs/overview', + recommended_action: 'Ensure that no IAM member has the pre-defined BigQuery Admin, BigQuery Data Owner or BigQuery Data Editor roles.', + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.projects, function(region, rcb){ + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + let notFoundMessage = 'No accounts have the pre-defined BigQuery Admin, Data Owner or Data Editor roles'; + + helpers.checkIAMRole(iamPolicy, ['roles/bigquery.admin', 'roles/bigquery.dataEditor', 'roles/bigquery.dataOwner'], region, results, project, notFoundMessage); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/bigqueryAdmin.spec.js b/plugins/google/iam/bigqueryAdmin.spec.js new file mode 100644 index 000000000..225863a23 --- /dev/null +++ b/plugins/google/iam/bigqueryAdmin.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var plugin = require('./bigqueryAdmin'); + +const createCache = (err, data) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + }, + } +}; + +describe('bigqueryAdmin', function () { + describe('run', function () { + it('should give unknown result if unable to query for IAM policies', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for IAM policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no user has the bigquery admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No accounts have the pre-defined BigQuery Admin, Data Owner or Data Editor roles'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/cloudbuild.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@gcp-sa-cloudbuild.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.admin", + "members": [ + "serviceAccount:giotestservice111@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if the user has the bigquery admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The account has the pre-defined role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/editor", + "members": [ + "serviceAccount:2323462-compute@developer.gserviceaccount.com", + "serviceAccount:2222222@cloudservices.gserviceaccount.com" + ] + }, + { + "role": "roles/bigquery.admin", + "members": [ + "user:john@right.com" + ] + }, + { + "role": "roles/viewer", + "members": [ + "serviceAccount:rightservice@right-weather-281330.iam.gserviceaccount.com", + "serviceAccount:mytest@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/bigtableAdmin.js b/plugins/google/iam/bigtableAdmin.js new file mode 100644 index 000000000..f11e35a7b --- /dev/null +++ b/plugins/google/iam/bigtableAdmin.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Bigtable Admin', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that there are no IAM Users with Bigtable Administrator role at the project level.', + more_info: 'The pre-defined role "Bigtable Administrator" grants full permission over Bigtable resources and its data. As a best practice, avoid giving access to this role at the project level; instead, grant specific Bigtable related permissions to IAM members.', + link: 'https://cloud.google.com/iam/docs/overview', + recommended_action: 'Ensure that no IAM member has the pre-defined Bigtable Administrator role.', + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.projects, function(region, rcb){ + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + let notFoundMessage = 'No accounts have the pre-defined Bigtable Admin role'; + + helpers.checkIAMRole(iamPolicy, ['roles/bigtable.admin'], region, results, project, notFoundMessage); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/bigtableAdmin.spec.js b/plugins/google/iam/bigtableAdmin.spec.js new file mode 100644 index 000000000..3683cc2cf --- /dev/null +++ b/plugins/google/iam/bigtableAdmin.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var plugin = require('./bigtableAdmin'); + +const createCache = (err, data) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + }, + } +}; + +describe('bigtableAdmin', function () { + describe('run', function () { + it('should give unknown result if unable to query for IAM policies', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for IAM policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no user has the bigtable admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No accounts have the pre-defined Bigtable Admin role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/cloudbuild.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@gcp-sa-cloudbuild.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.admin", + "members": [ + "serviceAccount:giotestservice111@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if the user has the bigtable admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The account has the pre-defined role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/editor", + "members": [ + "serviceAccount:2323462-compute@developer.gserviceaccount.com", + "serviceAccount:2222222@cloudservices.gserviceaccount.com" + ] + }, + { + "role": "roles/bigtable.admin", + "members": [ + "user:john@right.com" + ] + }, + { + "role": "roles/viewer", + "members": [ + "serviceAccount:rightservice@right-weather-281330.iam.gserviceaccount.com", + "serviceAccount:mytest@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/corporateEmailsOnly.js b/plugins/google/iam/corporateEmailsOnly.js index b61f807b3..cd7c102ee 100644 --- a/plugins/google/iam/corporateEmailsOnly.js +++ b/plugins/google/iam/corporateEmailsOnly.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Corporate Emails Only', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that no users are using their Gmail accounts for access to GCP.', - more_info: 'Gmail accounts are personally created and are not controlled by organizations. Fully managed accounts are recommended for increased visiblity, auditing and control over access to resources.', + more_info: 'Gmail accounts are personally created and are not controlled by organizations. Fully managed accounts are recommended for increased visibility, auditing and control over access to resources.', link: 'https://cloud.google.com/iam/docs/overview', recommended_action: 'Ensure that no users are actively using their Gmail accounts to access GCP.', apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); return rcb(); } @@ -33,22 +36,27 @@ module.exports = { var iamPolicy = iamPolicies.data[0]; var gmailUsers = []; - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.members && roleBinding.members.length) { - roleBinding.members.forEach(member => { - var emailArr = member.split('@'); - var provider = emailArr[1].split('.'); - if (provider[0] === 'gmail' && (gmailUsers.indexOf(member) === -1)) { - gmailUsers.push(member); - } - }) - } - }); + if (iamPolicy.bindings) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.members && roleBinding.members.length) { + roleBinding.members.forEach(member => { + var emailArr = member.split('@'); + if (emailArr.length && emailArr.length > 1) { + var provider = emailArr[1].split('.'); + if (provider[0] === 'gmail' && (gmailUsers.indexOf(member) === -1)) { + gmailUsers.push(member); + } + } + }); + } + }); + } if (gmailUsers.length) { - var gmailUsersStr = gmailUsers.join(', '); - helpers.addResult(results, 2, - `The following accounts are using Gmail login credentials: ${gmailUsersStr}`, region); + gmailUsers.forEach(user => { + helpers.addResult(results, 2, + 'Account is using Gmail login credentials', region, user); + }); } else { helpers.addResult(results, 0, 'No accounts are using Gmail login credentials', region); } diff --git a/plugins/google/iam/corporateEmailsOnly.spec.js b/plugins/google/iam/corporateEmailsOnly.spec.js index 96bea9f87..66d2100c1 100644 --- a/plugins/google/iam/corporateEmailsOnly.spec.js +++ b/plugins/google/iam/corporateEmailsOnly.spec.js @@ -132,7 +132,7 @@ describe('corporateEmailsOnly', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('The following accounts are using Gmail login credentials'); + expect(results[0].message).to.include('Account is using Gmail login credentials'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/iam/kmsUserSeparation.js b/plugins/google/iam/kmsUserSeparation.js index 8d364fb47..6c1483291 100644 --- a/plugins/google/iam/kmsUserSeparation.js +++ b/plugins/google/iam/kmsUserSeparation.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'KMS User Separation', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that no users have the KMS admin role and any one of the CryptoKey roles.', more_info: 'Ensuring that no users have the KMS admin role and any one of the CryptoKey roles follows separation of duties, where no user should have access to resources out of the scope of duty.', link: 'https://cloud.google.com/iam/docs/overview', recommended_action: 'Ensure that no service accounts have both the KMS admin role and any of CryptoKey roles attached.', apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.projects, function(region, rcb){ let iamPolicies = helpers.addSource(cache, source, ['projects', 'getIamPolicy', region]); @@ -22,7 +36,7 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); return rcb(); } @@ -34,36 +48,42 @@ module.exports = { var iamPolicy = iamPolicies.data[0]; var serviceAccountUsers = []; var notSeparated = []; - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role === 'roles/cloudkms.admin') { - serviceAccountUsers = serviceAccountUsers.concat(roleBinding.members) - } - }); - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role === 'roles/cloudkms.cryptoKeyDecrypter' && - roleBinding.members) { - notSeparated = roleBinding.members.filter(member => { - return (serviceAccountUsers.indexOf(member) > -1) - }).concat(notSeparated); - } else if (roleBinding.role === 'roles/cloudkms.cryptoKeyEncrypter' && - roleBinding.members) { - notSeparated = roleBinding.members.filter(member => { - return (serviceAccountUsers.indexOf(member) > -1) - }).concat(notSeparated); - } else if (roleBinding.role === 'roles/cloudkms.cryptoKeyEncrypterDecrypter' && - roleBinding.members) { - notSeparated = roleBinding.members.filter(member => { - return (serviceAccountUsers.indexOf(member) > -1) - }).concat(notSeparated); - } - }); + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/cloudkms.admin') { + serviceAccountUsers = serviceAccountUsers.concat(roleBinding.members); + } + }); + + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/cloudkms.cryptoKeyDecrypter' && + roleBinding.members) { + notSeparated = roleBinding.members.filter(member => { + return (serviceAccountUsers.indexOf(member) > -1); + }).concat(notSeparated); + } else if (roleBinding.role === 'roles/cloudkms.cryptoKeyEncrypter' && + roleBinding.members) { + notSeparated = roleBinding.members.filter(member => { + return (serviceAccountUsers.indexOf(member) > -1); + }).concat(notSeparated); + } else if (roleBinding.role === 'roles/cloudkms.cryptoKeyEncrypterDecrypter' && + roleBinding.members) { + notSeparated = roleBinding.members.filter(member => { + return (serviceAccountUsers.indexOf(member) > -1); + }).concat(notSeparated); + } + }); + } if (notSeparated.length) { notSeparated = [...new Set(notSeparated)]; - var notSeparatedStr = notSeparated.join(', '); - helpers.addResult(results, 2, - `The following accounts have the KMS admin role and one or more CryptoKey roles: ${notSeparatedStr}`, region); + notSeparated.forEach(account => { + let accountName = (account.includes(':')) ? account.split(':')[1] : account; + let resource = helpers.createResourceName('serviceAccounts', accountName, project); + helpers.addResult(results, 2, + 'Account has the KMS admin role and one or more CryptoKey roles', region, resource); + }); } else { helpers.addResult(results, 0, 'No accounts have a KMS admin role or a CryptoKey key role', region); } diff --git a/plugins/google/iam/kmsUserSeparation.spec.js b/plugins/google/iam/kmsUserSeparation.spec.js index 2ab21aadf..5ff9b05f2 100644 --- a/plugins/google/iam/kmsUserSeparation.spec.js +++ b/plugins/google/iam/kmsUserSeparation.spec.js @@ -11,6 +11,11 @@ const createCache = (err, data) => { data: data } }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } }, } }; @@ -132,7 +137,7 @@ describe('kmsUserSeparation', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('The following accounts have the KMS admin role and one or more CryptoKey roles'); + expect(results[0].message).to.include('Account has the KMS admin role and one or more CryptoKey roles'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/iam/memberAdmin.js b/plugins/google/iam/memberAdmin.js new file mode 100644 index 000000000..989118ae2 --- /dev/null +++ b/plugins/google/iam/memberAdmin.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Member Admin', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that IAM members do not use primitive roles such as owner, editor or viewer.', + more_info: 'For best security practices, use only predefined IAM roles and do not use primitive roles to prevent any unauthorized access to your resources.', + link: 'https://cloud.google.com/iam/docs/overview', + recommended_action: 'Ensure that no IAM member has a primitive role.', + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.projects, function(region, rcb){ + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + var primitiveRoleExists = false; + + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role && ['roles/editor', 'roles/viewer', 'roles/owner'].includes(roleBinding.role)) { + primitiveRoleExists = true; + roleBinding.members.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let memberType = member.startsWith('serviceAccount') ? 'serviceAccounts' : 'users'; + let resource = helpers.createResourceName(memberType, accountName, project); + helpers.addResult(results, 2, + `The account has the primitive role: ${roleBinding.role}`, region, resource); + }); + } + }); + } + + if (!primitiveRoleExists) { + helpers.addResult(results, 0, 'No accounts have primitive roles', region); + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/memberAdmin.spec.js b/plugins/google/iam/memberAdmin.spec.js new file mode 100644 index 000000000..7c5ac673a --- /dev/null +++ b/plugins/google/iam/memberAdmin.spec.js @@ -0,0 +1,128 @@ +var expect = require('chai').expect; +var plugin = require('./memberAdmin'); + +const createCache = (err, data) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + }, + } +}; + +describe('memberAdmin', function () { + describe('run', function () { + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no user has a primitive role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No accounts have primitive roles'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/cloudbuild.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@gcp-sa-cloudbuild.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.admin", + "members": [ + "serviceAccount:giotestservice111@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if a user has a primitive role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The account has the primitive role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/editor", + "members": [ + "serviceAccount:2323462-compute@developer.gserviceaccount.com", + "serviceAccount:2222222@cloudservices.gserviceaccount.com" + ] + }, + { + "role": "roles/owner", + "members": [ + "user:john@right.com" + ] + }, + { + "role": "roles/viewer", + "members": [ + "serviceAccount:rightservice@right-weather-281330.iam.gserviceaccount.com", + "serviceAccount:mytest@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/pubsubAdmin.js b/plugins/google/iam/pubsubAdmin.js new file mode 100644 index 000000000..375f5a0f0 --- /dev/null +++ b/plugins/google/iam/pubsubAdmin.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Pub/Sub Admin', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that there are no IAM Users with Pub/Sub Administrator role at the project level.', + more_info: 'The pre-defined role "Pub/Sub Admin" grants full access over Pub/Sub topics, subscriptions, and snapshots. As a best practice, avoid granting access to Pub/Sub Admin roles at the project level; instead, grant specific Pub/Sub permissions to IAM members.', + link: 'https://cloud.google.com/iam/docs/overview', + recommended_action: 'Ensure that no IAM member has the pre-defined Pub/Sub Administrator role.', + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.projects, function(region, rcb){ + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + let notFoundMessage = 'No accounts have the pre-defined Pub/Sub Administrator role'; + + helpers.checkIAMRole(iamPolicy, ['roles/pubsub.admin'], region, results, project, notFoundMessage); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/pubsubAdmin.spec.js b/plugins/google/iam/pubsubAdmin.spec.js new file mode 100644 index 000000000..e86d4438f --- /dev/null +++ b/plugins/google/iam/pubsubAdmin.spec.js @@ -0,0 +1,144 @@ +var expect = require('chai').expect; +var plugin = require('./pubsubAdmin'); + +const createCache = (err, data) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } + }, + } +}; + +describe('pubsubAdmin', function () { + describe('run', function () { + it('should give unknown result if unable to query for IAM policies', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for IAM policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no user has the Pub/Sub admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No accounts have the pre-defined Pub/Sub Administrator role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/cloudbuild.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@gcp-sa-cloudbuild.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.admin", + "members": [ + "serviceAccount:giotestservice111@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if the user has the Pub/Sub admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The account has the pre-defined role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/editor", + "members": [ + "serviceAccount:2323462-compute@developer.gserviceaccount.com", + "serviceAccount:2222222@cloudservices.gserviceaccount.com" + ] + }, + { + "role": "roles/pubsub.admin", + "members": [ + "user:john@right.com" + ] + }, + { + "role": "roles/viewer", + "members": [ + "serviceAccount:rightservice@right-weather-281330.iam.gserviceaccount.com", + "serviceAccount:mytest@right-weather-281330.iam.gserviceaccount.com" + ] + } + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountAdmin.js b/plugins/google/iam/serviceAccountAdmin.js index eea63200a..ee9a22622 100644 --- a/plugins/google/iam/serviceAccountAdmin.js +++ b/plugins/google/iam/serviceAccountAdmin.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Account Admin', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that user managed service accounts do not have any admin, owner, or write privileges.', more_info: 'Service accounts are primarily used for API access to Google. It is recommended to not use admin access for service accounts.', link: 'https://cloud.google.com/iam/docs/overview', recommended_action: 'Ensure that no service accounts have admin, owner, or write privileges.', - apis: ['projects:get','projects:getIamPolicy'], + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], run: function(cache, settings, callback) { var results = []; @@ -25,7 +28,7 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM Policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM Policies', region, null, null, iamPolicies.err); return rcb(); } @@ -42,54 +45,59 @@ module.exports = { var serviceAccountObj = {}; var iamPolicy = iamPolicies.data[0]; - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role) { - var role = roleBinding.role.split('.'); - if (role.length > 1) { - role = role[1]; - } - if (role === 'admin') { - roleBinding.members.forEach(member => { - var memberStrArr = member.split('@'); - if (memberStrArr[1] === serviceAccountCheck) { - if (!serviceAccountObj[member]) { - serviceAccountObj[member] = []; - } - serviceAccountObj[member].push(roleBinding.role) - - } - }) - } else if (roleBinding.role === 'roles/editor') { - roleBinding.members.forEach(member => { - var memberStrArr = member.split('@'); - if (memberStrArr[1] === serviceAccountCheck) { - if (!serviceAccountObj[member]) { - serviceAccountObj[member] = []; - } - serviceAccountObj[member].push('editor') + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role) { + var role = roleBinding.role.split('.'); + if (role.length > 1) { + role = role[1]; + } + if (roleBinding.members && roleBinding.members.length) { + if (role === 'admin') { + roleBinding.members.forEach(member => { + var memberStrArr = member.split('@'); + if (memberStrArr[1] === serviceAccountCheck) { + if (!serviceAccountObj[member]) { + serviceAccountObj[member] = []; + } + serviceAccountObj[member].push(roleBinding.role); + } + }); + } else if (roleBinding.role === 'roles/editor') { + roleBinding.members.forEach(member => { + var memberStrArr = member.split('@'); + if (memberStrArr[1] === serviceAccountCheck) { + if (!serviceAccountObj[member]) { + serviceAccountObj[member] = []; + } + serviceAccountObj[member].push('editor'); + } + }); + } else if (roleBinding.role === 'roles/owner') { + roleBinding.members.forEach(member => { + var memberStrArr = member.split('@'); + if (memberStrArr[1] === serviceAccountCheck) { + if (!serviceAccountObj[member]) { + serviceAccountObj[member] = []; + } + serviceAccountObj[member].push('owner'); + } + }); } - }) - } else if (roleBinding.role === 'roles/owner') { - roleBinding.members.forEach(member => { - var memberStrArr = member.split('@'); - if (memberStrArr[1] === serviceAccountCheck) { - if (!serviceAccountObj[member]) { - serviceAccountObj[member] = []; - } - serviceAccountObj[member].push('owner') - } - }) + } } - } - }); + }); + } if (!Object.keys(serviceAccountObj).length) { helpers.addResult(results, 0, 'All service accounts have least access', region); } else { for (let member in serviceAccountObj) { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let resource = helpers.createResourceName('serviceAccounts', accountName, projectName); var permissionStr = serviceAccountObj[member].join(', '); helpers.addResult(results, 2, - `The Service account has the following permissions: ${permissionStr}`, region, member); + `The Service account has the following permissions: ${permissionStr}`, region, resource); } } diff --git a/plugins/google/iam/serviceAccountKeyRotation.js b/plugins/google/iam/serviceAccountKeyRotation.js index 9ce0ba469..996be4a42 100644 --- a/plugins/google/iam/serviceAccountKeyRotation.js +++ b/plugins/google/iam/serviceAccountKeyRotation.js @@ -4,11 +4,21 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Account Key Rotation', category: 'IAM', - description: 'Ensures that service account keys are rotated within 90 days of creation.', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that service account keys are rotated within desired number of days.', more_info: 'Service account keys should be rotated so older keys that that might have been lost or compromised cannot be used to access Google services.', link: 'https://cloud.google.com/iam/docs/creating-managing-service-account-keys', - recommended_action: 'Rotate service account keys that have not been rotated in over 90 days.', + recommended_action: 'Rotate service account keys that have not been rotated in over defined threshold time.', apis: ['serviceAccounts:list','keys:list'], + settings: { + service_account_keys_rotated_fail: { + name: 'Service Account Keys Rotated Fail', + description: 'Return a failing result when service account keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, compliance: { hipaa: 'Rotating access keys helps to ensure that those keys have not been ' + 'compromised. HIPAA requires strict controls around authentication of ' + @@ -17,12 +27,17 @@ module.exports = { 'IAM roles handle rotation automatically, access keys need to be manually ' + 'rotated.' }, - + realtime_triggers: ['iam.admin.CreateServiceAccountKey', 'iam.admin.CreateServiceAccount','iam.admin.DeleteServiceAccountKey', 'iam.admin.DeleteServiceAccount'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + var config = { + service_account_keys_rotated_fail: parseFloat(settings.service_account_keys_rotated_fail || this.settings.service_account_keys_rotated_fail.default) + }; + async.each(regions.keys, function(region, rcb){ let keys = helpers.addSource(cache, source, ['keys', 'list', region]); @@ -30,37 +45,42 @@ module.exports = { if (!keys) return rcb(); if (keys.err || !keys.data) { - helpers.addResult(results, 3, 'Unable to query service account keys, check permissions.', region); + helpers.addResult(results, 3, 'Unable to query service account keys, check permissions.', region, null, null, keys.err); return rcb(); - }; + } if (!keys.data.length) { helpers.addResult(results, 0, 'No service account keys found', region); return rcb(); } - var keysNotRotated = []; + var userManagedKeyFound = false; + keys.data.forEach(key => { if (key.keyType && key.keyType === 'USER_MANAGED') { - var ninety_days = 90*24*60*60*1000; - var validAfterTime = key.validAfterTime.split("T")[0]; + userManagedKeyFound = true; + var validAfterTime = key.validAfterTime.split('T')[0]; var timeFromCreation = new Date().getTime() - new Date(validAfterTime).getTime(); - timeFromCreation /= ninety_days; - + var daysInTime = config.service_account_keys_rotated_fail*24*60*60*1000; + timeFromCreation /= daysInTime; if (timeFromCreation > 1) { helpers.addResult(results, 2, - 'The service account key has not been rotated in over 90 days', region, key.name); + `The service account key has not been rotated in over ${config.service_account_keys_rotated_fail} days`, region, key.name); } else { - helpers.addResult(results, 0, 'The service account key has been rotated within 90 days', region, key.name); + helpers.addResult(results, 0, `The service account key has been rotated within ${config.service_account_keys_rotated_fail} days`, region, key.name); } } }); + if (!userManagedKeyFound) { + helpers.addResult(results, 0, 'No user managed service account keys found', region); + } + rcb(); }, function(){ // Global checking goes here callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountKeyRotation.spec.js b/plugins/google/iam/serviceAccountKeyRotation.spec.js index 14db24d36..177a3d408 100644 --- a/plugins/google/iam/serviceAccountKeyRotation.spec.js +++ b/plugins/google/iam/serviceAccountKeyRotation.spec.js @@ -17,7 +17,7 @@ const createCache = (err, data) => { describe('serviceAccountKeyRotation', function () { describe('run', function () { - it('should give unknown result if a project error is passed or no data is present', function (done) { + it('should give unknown result if a keys error is passed or no data is present', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(3); @@ -34,7 +34,7 @@ describe('serviceAccountKeyRotation', function () { plugin.run(cache, {}, callback); }); - it('should give passing result if no project records are found', function (done) { + it('should give passing result if no service account keys found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); @@ -51,11 +51,37 @@ describe('serviceAccountKeyRotation', function () { plugin.run(cache, {}, callback); }); - it('should give passing result if the projects services are all within service limits', function (done) { + it('should give passing result if no user managed service account keys found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('he service account key has been rotated within 90 days'); + expect(results[0].message).to.include('No user managed service account keys found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "projects/example-project/serviceAccounts/test@example-project.iam.gserviceaccount.com/keys/1234564354235fg34523562536", + "validBeforeTime": "2019-11-17T18:56:00Z", + "keyAlgorithm": "KEY_ALG_RSA_2048", + "keyOrigin": "GOOGLE_PROVIDED", + "keyType": "SYSTEM_MANAGED" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give passing result if the service account key has been rotated within defined threshold time', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The service account key has been rotated within 90 days'); expect(results[0].region).to.equal('global'); done() }; @@ -77,14 +103,14 @@ describe('serviceAccountKeyRotation', function () { ] ); - plugin.run(cache, {}, callback); + plugin.run(cache, { service_account_keys_rotated_fail: '90' }, callback); }); - it('should give failing result if the projects services are very close to the service limits', function (done) { + it('should give failing result if the the service account key has not been rotated within defined threshold time', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('The service account key has not been rotated in over 90 days'); + expect(results[0].message).to.include('The service account key has not been rotated in over 80 days'); expect(results[0].region).to.equal('global'); done() }; @@ -107,7 +133,7 @@ describe('serviceAccountKeyRotation', function () { ] ); - plugin.run(cache, {}, callback); + plugin.run(cache, { service_account_keys_rotated_fail: '80' }, callback); }) }) }); \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountManagedKeys.js b/plugins/google/iam/serviceAccountManagedKeys.js index eab596299..0d3a8527b 100644 --- a/plugins/google/iam/serviceAccountManagedKeys.js +++ b/plugins/google/iam/serviceAccountManagedKeys.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Account Managed Keys', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that service account keys are being managed by Google.', more_info: 'Service account keys should be managed by Google to ensure that they are as secure as possible, including key rotations and restrictions to the accessibility of the keys.', link: 'https://cloud.google.com/iam/docs/creating-managing-service-account-keys', recommended_action: 'Ensure all user service account keys are being managed by Google.', apis: ['serviceAccounts:list','keys:list'], + realtime_triggers: ['iam.admin.CreateServiceAccountKey', 'iam.admin.CreateServiceAccount','iam.admin.DeleteServiceAccountKey', 'iam.admin.DeleteServiceAccount'], run: function(cache, settings, callback) { var results = []; @@ -22,9 +25,9 @@ module.exports = { if (!keys) return rcb(); if (keys.err || !keys.data) { - helpers.addResult(results, 3, 'Unable to query service account keys, check permissions.', region); + helpers.addResult(results, 3, 'Unable to query service account keys, check permissions.', region, null, null, keys.err); return rcb(); - }; + } if (!keys.data.length) { helpers.addResult(results, 0, 'No service account keys found', region); @@ -51,4 +54,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountRole.js b/plugins/google/iam/serviceAccountRole.js new file mode 100644 index 000000000..e46489fb1 --- /dev/null +++ b/plugins/google/iam/serviceAccountRole.js @@ -0,0 +1,73 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Service Account Role', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure no Service Account exists without any associated role.', + more_info: 'Service Account acts as identity for the applications to authenticate to Google cloud platform. It is a security best practice to always have roles associated with the user managed Service Accounts.', + link: 'https://cloud.google.com/iam/docs/service-account-permissionsw', + recommended_action: 'Ensure that no service accounts exists without an associated role.', + apis: ['projects:getIamPolicy', 'serviceAccounts:list'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy', 'iam.admin.CreateServiceAccount' , 'iam.admin.DeleteServiceAccount'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.projects, function(region, rcb){ + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM Policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found.', region); + return rcb(); + } + + let serviceAccounts = helpers.addSource(cache, source, + ['serviceAccounts', 'list', region]); + + if (!serviceAccounts) return rcb(); + + if (serviceAccounts.err || !serviceAccounts.data) { + helpers.addResult(results, 3, 'Unable to query for service accounts', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!serviceAccounts.data.length) { + helpers.addResult(results, 0, 'No service accounts found.', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + + serviceAccounts.data.forEach(serviceAccount => { + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length + && iamPolicy.bindings.find(binding => binding.members + && binding.members.find(member => member.includes(serviceAccount.email))) + ) { + helpers.addResult(results, 0, + 'Service Account has one or more roles associated with it', region, serviceAccount.name); + } else { + helpers.addResult(results, 2, + 'Service Account does not have any role associated with it', region, serviceAccount.name); + } + + }); + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountRole.spec.js b/plugins/google/iam/serviceAccountRole.spec.js new file mode 100644 index 000000000..688dfc740 --- /dev/null +++ b/plugins/google/iam/serviceAccountRole.spec.js @@ -0,0 +1,184 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./serviceAccountRole'); + +const createCache = (err, data, serviceAccErr, serviceAccData) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + err: null, + data: [ { name: 'testproj' } ] + } + } + }, + serviceAccounts: { + list: { + 'global': { + err: serviceAccErr, + data: serviceAccData + } + } + } + } +}; + +const iamPolicies = [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/viewer", + "members": [ + "serviceAccount:testserviceacc@testservice-11111.iam.gserviceaccount.com", + ] + } + ] + } +] + +const serviceAccounts = [ + { + name: 'projects/test-proj/serviceAccounts/testserviceacc@testservice-11111.iam.gserviceaccount.com', + projectId: 'test-proj', + uniqueId: '111111111', + email: 'testserviceacc@testservice-11111.iam.gserviceaccount.com', + displayName: 'testacc1', + etag: 'MDEwMjE5MjA=', + description: 'Test Account', + oauth2ClientId: '111111111' + }, + { + name: 'projects/test-proj/serviceAccounts/testaccw@test-proj.iam.gserviceaccount.com', + projectId: 'test-proj', + uniqueId: '111111111', + email: 'testaccw@test-proj.iam.gserviceaccount.com', + displayName: 'testaccw', + etag: 'MDEwMjE5MjA=', + description: 'Test Account', + oauth2ClientId: '111111111' + }, +] + +describe('serviceAccountRole', function () { + describe('run', function () { + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found.'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for iam policies', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for IAM Policies'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no service accounts are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No service accounts found.'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + iamPolicies, + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for service accounts', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for service accounts'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + iamPolicies, + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + + + it('should give passing result if service account has roles associated with it', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Service Account has one or more roles associated with it'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + iamPolicies, + null, + [serviceAccounts[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if service account does not have any role associated with it', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Service Account does not have any role associated with it'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + iamPolicies, + null, + [serviceAccounts[1]] + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountSeparation.js b/plugins/google/iam/serviceAccountSeparation.js index 5636d7321..d6b9958db 100644 --- a/plugins/google/iam/serviceAccountSeparation.js +++ b/plugins/google/iam/serviceAccountSeparation.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Account Separation', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures that no users have both the Service Account User and Service Account Admin role.', more_info: 'Ensuring that no users have both roles follows separation of duties, where no user should have access to resources out of the scope of duty.', link: 'https://cloud.google.com/iam/docs/overview', recommended_action: 'Ensure that no service accounts have both the Service Account User and Service Account Admin role attached.', apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.projects, function(region, rcb){ let iamPolicies = helpers.addSource(cache, source, ['projects', 'getIamPolicy', region]); @@ -22,7 +36,7 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); return rcb(); } @@ -34,27 +48,32 @@ module.exports = { var iamPolicy = iamPolicies.data[0]; var serviceAccountUsers = []; var notSeparated = {}; - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role === 'roles/iam.serviceAccountUser') { - serviceAccountUsers = serviceAccountUsers.concat(roleBinding.members) - } - }); - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role === 'roles/iam.serviceAccountAdmin' && - roleBinding.members) { - notSeparated = roleBinding.members.filter(member => { - return (serviceAccountUsers.indexOf(member) > -1) - }); + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/iam.serviceAccountUser') { + serviceAccountUsers = serviceAccountUsers.concat(roleBinding.members); + } + }); + + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/iam.serviceAccountAdmin' && + roleBinding.members) { + notSeparated = roleBinding.members.filter(member => { + return (serviceAccountUsers.indexOf(member) > -1); + }); - if (notSeparated && notSeparated.length) { - notSeparated.forEach(member => { - helpers.addResult(results, 2, - 'The account has both the service account user and admin role', region, member); - }) + if (notSeparated && notSeparated.length) { + notSeparated.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let resource = helpers.createResourceName('serviceAccounts', accountName, project); + helpers.addResult(results, 2, + 'The account has both the service account user and admin role', region, resource); + }); + } } - } - }); + }); + } if (!notSeparated.length) { helpers.addResult(results, 0, 'No accounts have both the service account user and admin roles', region); diff --git a/plugins/google/iam/serviceAccountSeparation.spec.js b/plugins/google/iam/serviceAccountSeparation.spec.js index 3949db0f5..f2bdd607b 100644 --- a/plugins/google/iam/serviceAccountSeparation.spec.js +++ b/plugins/google/iam/serviceAccountSeparation.spec.js @@ -11,6 +11,11 @@ const createCache = (err, data) => { data: data } }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } }, } }; diff --git a/plugins/google/iam/serviceAccountTokenCreator.js b/plugins/google/iam/serviceAccountTokenCreator.js new file mode 100644 index 000000000..7be973f29 --- /dev/null +++ b/plugins/google/iam/serviceAccountTokenCreator.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Service Account Token Creator', + category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that no users have the Service Account Token Creator role.', + more_info: 'For best security practices, IAM users should not have Service Account Token Creator role.', + link: 'https://cloud.google.com/iam/docs/overview', + recommended_action: 'Ensure that no IAM user have Service Account Token Creator Role at GCP project level.', + apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.projects, function(region, rcb) { + let iamPolicies = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); + + if (!iamPolicies) return rcb(); + + if (iamPolicies.err || !iamPolicies.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); + return rcb(); + } + + if (!iamPolicies.data.length) { + helpers.addResult(results, 0, 'No IAM policies found', region); + return rcb(); + } + + var iamPolicy = iamPolicies.data[0]; + + var serviceAccountExists = false; + + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/iam.serviceAccountTokenCreator') { + serviceAccountExists = true; + roleBinding.members.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let memberType = member.startsWith('serviceAccount') ? 'serviceAccounts' : 'users'; + let resource = helpers.createResourceName(memberType, accountName, project); + helpers.addResult(results, 2, + 'The account has a service account token creator role', region, resource); + }); + } + }); + } + + if (!serviceAccountExists) { + helpers.addResult(results, 0, 'No accounts have service account token creator roles', region); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountTokenCreator.spec.js b/plugins/google/iam/serviceAccountTokenCreator.spec.js new file mode 100644 index 000000000..43aa19e3c --- /dev/null +++ b/plugins/google/iam/serviceAccountTokenCreator.spec.js @@ -0,0 +1,133 @@ +var expect = require('chai').expect; +var plugin = require('./serviceAccountTokenCreator'); + +const createCache = (err, data) => { + return { + projects: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + }, + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + }, + } +}; + +describe('serviceAccountTokenCreator', function () { + describe('run', function () { + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no user has the Service Account Token Creator role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No accounts have service account token creator roles'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/cloudbuild.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@gcp-sa-cloudbuild.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.admin", + "members": [ + "serviceAccount:giotestservice111@right-weather-281330.iam.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@compute-system.iam.gserviceaccount.com" + ] + }, + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if an account has service account token creator role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('The account has a service account token creator role'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXO8yOKJo=", + "bindings": [ + { + "role": "roles/cloudbuild.builds.builder", + "members": [ + "serviceAccount:281330800462@cloudbuild.gserviceaccount.com" + ] + }, + { + "role": "roles/compute.serviceAgent", + "members": [ + "serviceAccount:service-281330800462@compute-system.iam.gserviceaccount.com" + ] + }, + { + "role": 'roles/iam.serviceAccountTokenCreator', + "members": [ + 'serviceAccount:my-service-account@testproj.iam.gserviceaccount.com' + ] + } + + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + }) +}); \ No newline at end of file diff --git a/plugins/google/iam/serviceAccountUser.js b/plugins/google/iam/serviceAccountUser.js index d4dd24947..ca77c5278 100644 --- a/plugins/google/iam/serviceAccountUser.js +++ b/plugins/google/iam/serviceAccountUser.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Account User', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures that no users have the Service Account User role.', more_info: 'The Service Account User role gives users the access to all service accounts of a project. This can result in an elevation of privileges and is not recommended.', link: 'https://cloud.google.com/iam/docs/overview', recommended_action: 'Ensure that no service accounts have the Service Account User role attached.', apis: ['projects:getIamPolicy'], + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.projects, function(region, rcb){ let iamPolicies = helpers.addSource(cache, source, ['projects', 'getIamPolicy', region]); @@ -22,7 +36,7 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); return rcb(); } @@ -33,15 +47,20 @@ module.exports = { var iamPolicy = iamPolicies.data[0]; var serviceAccountExists = false; - iamPolicy.bindings.forEach(roleBinding => { - if (roleBinding.role === 'roles/iam.serviceAccountUser') { - serviceAccountExists = true; - roleBinding.members.forEach(member => { - helpers.addResult(results, 2, - 'The account has a service account user role', region, member); - }); - } - }); + + if (iamPolicy && iamPolicy.bindings && iamPolicy.bindings.length) { + iamPolicy.bindings.forEach(roleBinding => { + if (roleBinding.role === 'roles/iam.serviceAccountUser') { + serviceAccountExists = true; + roleBinding.members.forEach(member => { + let accountName = (member.includes(':')) ? member.split(':')[1] : member; + let resource = helpers.createResourceName('serviceAccounts', accountName, project); + helpers.addResult(results, 2, + 'The account has a service account user role', region, resource); + }); + } + }); + } if (!serviceAccountExists) { helpers.addResult(results, 0, 'No accounts have service account user roles', region); diff --git a/plugins/google/iam/serviceAccountUser.spec.js b/plugins/google/iam/serviceAccountUser.spec.js index cfe1f89b4..d853ed2ca 100644 --- a/plugins/google/iam/serviceAccountUser.spec.js +++ b/plugins/google/iam/serviceAccountUser.spec.js @@ -11,6 +11,11 @@ const createCache = (err, data) => { data: data } }, + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } }, } }; diff --git a/plugins/google/iam/serviceLimits.js b/plugins/google/iam/serviceLimits.js index 571cee23e..eef754d95 100644 --- a/plugins/google/iam/serviceLimits.js +++ b/plugins/google/iam/serviceLimits.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Service Limits', category: 'IAM', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Determines if the number of resources is close to the per-account limit.', more_info: 'Google limits accounts to certain numbers of resources. Exceeding those limits could prevent resources from launching.', link: 'https://cloud.google.com/resource-manager/docs/limits', @@ -23,6 +25,7 @@ module.exports = { default: 75 } }, + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete'], run: function(cache, settings, callback) { var config = { @@ -43,14 +46,14 @@ module.exports = { if (!projects) return rcb(); if (projects.err || !projects.data) { - helpers.addResult(results, 3, 'Unable to query projects: ' + helpers.addError(projects), region); + helpers.addResult(results, 3, 'Unable to query projects', region, null, null, projects.err); return rcb(); - }; + } if (!projects.data.length) { helpers.addResult(results, 0, 'No projects found', region); return rcb(); - }; + } projects.data.forEach(project => { var warnReturnMsg = `The following services are over the ${config.service_limit_percentage_warn}% limit: `; @@ -62,23 +65,23 @@ module.exports = { var percentage = Math.ceil((quota.usage / quota.limit)*100); if (percentage >= config.service_limit_percentage_fail) { - failReturnMsg += `${quota.metric} has ${quota.usage} of ${quota.limit} resources, ` - failTrigger = true + failReturnMsg += `${quota.metric} has ${quota.usage} of ${quota.limit} resources, `; + failTrigger = true; } else if (percentage >= config.service_limit_percentage_warn) { - warnReturnMsg += `${quota.metric} has ${quota.usage} of ${quota.limit} resources, ` + warnReturnMsg += `${quota.metric} has ${quota.usage} of ${quota.limit} resources, `; warnTrigger = true; - }; + } }); if (warnTrigger) { helpers.addResult(results, 1, warnReturnMsg, region, project.id, custom); - }; + } if (failTrigger) { helpers.addResult(results, 2, failReturnMsg, region, project.id, custom); - }; + } if (!failTrigger && !warnTrigger) { helpers.addResult(results, 0, 'All resources are within the service limits', region); - }; + } }); rcb(); @@ -87,4 +90,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/iam/serviceLimits.spec.js b/plugins/google/iam/serviceLimits.spec.js index 87eedb455..3a63bdd42 100644 --- a/plugins/google/iam/serviceLimits.spec.js +++ b/plugins/google/iam/serviceLimits.spec.js @@ -21,7 +21,7 @@ describe('serviceLimits', function () { const callback = (err, results) => { expect(results.length).to.be.above(0) expect(results[0].status).to.equal(3) - expect(results[0].message).to.include('Unable to query projects:') + expect(results[0].message).to.include('Unable to query projects') expect(results[0].region).to.equal('global') done() }; diff --git a/plugins/google/kubernetes/aliasIpRangesEnabled.js b/plugins/google/kubernetes/aliasIpRangesEnabled.js index 4513158d6..fe18ecf58 100644 --- a/plugins/google/kubernetes/aliasIpRangesEnabled.js +++ b/plugins/google/kubernetes/aliasIpRangesEnabled.js @@ -4,39 +4,59 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Alias IP Ranges Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes clusters have alias IP ranges enabled', more_info: 'Alias IP ranges allow users to assign ranges of internal IP addresses as alias to a network interface.', link: 'https://cloud.google.com/monitoring/kubernetes-engine/', recommended_action: 'Ensure that Kubernetes clusters have alias IP ranges enabled.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } if (!clusters.data.length) { - helpers.addResult(results, 0, 'No clusters found', region); + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); if (cluster.ipAllocationPolicy && cluster.ipAllocationPolicy.useIpAliases) { - helpers.addResult(results, 0, 'Kubernetes alias IP ranges enabled', region, cluster.name); + helpers.addResult(results, 0, 'Kubernetes alias IP ranges enabled', region, resource); } else { - helpers.addResult(results, 2, 'Kubernetes alias IP ranges disabled', region, cluster.name); + helpers.addResult(results, 2, 'Kubernetes alias IP ranges disabled', region, resource); } }); @@ -47,4 +67,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/aliasIpRangesEnabled.spec.js b/plugins/google/kubernetes/aliasIpRangesEnabled.spec.js index 2e433108e..f411fc7a3 100644 --- a/plugins/google/kubernetes/aliasIpRangesEnabled.spec.js +++ b/plugins/google/kubernetes/aliasIpRangesEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./aliasIpRangesEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -37,7 +44,7 @@ describe('aliasIpRangesEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No clusters found'); + expect(results[0].message).to.include('No Kubernetes clusters found'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/autoNodeRepairEnabled.js b/plugins/google/kubernetes/autoNodeRepairEnabled.js index 714eeac1f..b9f57788c 100644 --- a/plugins/google/kubernetes/autoNodeRepairEnabled.js +++ b/plugins/google/kubernetes/autoNodeRepairEnabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Automatic Node Repair Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes cluster nodes have automatic repair enabled', more_info: 'When automatic repair on nodes is enabled, the Kubernetes engine performs health checks on all nodes, automatically repairing nodes that fail health checks. This ensures that the Kubernetes environment stays optimal.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/node-auto-repair', recommended_action: 'Ensure that automatic node repair is enabled on all node pools in Kubernetes clusters', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,20 +46,32 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let found = false; + let nonAutoRepairNodes = []; + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); if (cluster.nodePools && cluster.nodePools.length) { + found = true; cluster.nodePools.forEach(nodePool => { - if (nodePool.management && - nodePool.management.autoRepair) { - helpers.addResult(results, 0, - `Auto repair is enabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } else { - helpers.addResult(results, 2, - `Auto repair is disabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } - }) + if (!nodePool.management || !nodePool.management.autoRepair) nonAutoRepairNodes.push(nodePool.name); + }); + } + + if (nonAutoRepairNodes.length) { + helpers.addResult(results, 2, + `Auto repair is disabled for these node pools: ${nonAutoRepairNodes.join(', ')}`, region, resource); } else { - helpers.addResult(results, 0, 'No node pools found', region, cluster.name); + helpers.addResult(results, 0, + 'Auto repair is enabled for all node pools', region, resource); + } + + if (!found) { + helpers.addResult(results, 0, 'No node pools found', region, resource); } }); @@ -55,4 +81,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/autoNodeRepairEnabled.spec.js b/plugins/google/kubernetes/autoNodeRepairEnabled.spec.js index 27692e6b2..fcc5ccfb2 100644 --- a/plugins/google/kubernetes/autoNodeRepairEnabled.spec.js +++ b/plugins/google/kubernetes/autoNodeRepairEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./autoNodeRepairEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -55,7 +62,7 @@ describe('autoNodeRepairEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Auto repair is enabled for the node pool of the cluster'); + expect(results[0].message).to.include('Auto repair is enabled for all node pools'); expect(results[0].region).to.equal('global'); done() }; @@ -214,7 +221,7 @@ describe('autoNodeRepairEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Auto repair is disabled for the node pool of the cluster'); + expect(results[0].message).to.include('Auto repair is disabled for these node pools'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/autoNodeUpgradesEnabled.js b/plugins/google/kubernetes/autoNodeUpgradesEnabled.js index d30e53be8..6abb8fdb2 100644 --- a/plugins/google/kubernetes/autoNodeUpgradesEnabled.js +++ b/plugins/google/kubernetes/autoNodeUpgradesEnabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Automatic Node Upgrades Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes cluster nodes have automatic upgrades enabled', more_info: 'Enabling automatic upgrades on nodes ensures that each node stays current with the latest version of the master branch, also ensuring that the latest security patches are installed to provide the most secure environment.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/node-auto-upgrades', recommended_action: 'Ensure that automatic node upgrades are enabled on all node pools in Kubernetes clusters', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,20 +46,32 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let found = false; + let nonAutoUpgradeNodes = []; + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); if (cluster.nodePools && cluster.nodePools.length) { cluster.nodePools.forEach(nodePool => { - if (nodePool.management && - nodePool.management.autoUpgrade) { - helpers.addResult(results, 0, - `Auto upgrades are enabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } else { - helpers.addResult(results, 2, - `Auto upgrades are disabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } - }) + if (!nodePool.management || !nodePool.management.autoUpgrade) nonAutoUpgradeNodes.push(nodePool.name); + }); + } + + if (nonAutoUpgradeNodes.length) { + helpers.addResult(results, 2, + `Auto upgrades are disabled for these node pools: ${nonAutoUpgradeNodes.join(', ')}`, + region, resource); } else { - helpers.addResult(results, 0, 'No node pools found', region, cluster.name); + helpers.addResult(results, 0, + 'Auto upgrades are enabled for all node pools', region, resource); + } + + if (!found) { + helpers.addResult(results, 0, 'No node pools found', region, resource); } }); @@ -55,4 +81,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/autoNodeUpgradesEnabled.spec.js b/plugins/google/kubernetes/autoNodeUpgradesEnabled.spec.js index c3f6220ce..e61f4fbe0 100644 --- a/plugins/google/kubernetes/autoNodeUpgradesEnabled.spec.js +++ b/plugins/google/kubernetes/autoNodeUpgradesEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./autoNodeUpgradesEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -55,7 +62,7 @@ describe('autoNodeUpgradesEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Auto upgrades are enabled for the node pool of the cluster'); + expect(results[0].message).to.include('Auto upgrades are enabled for all node pools'); expect(results[0].region).to.equal('global'); done() }; @@ -214,7 +221,7 @@ describe('autoNodeUpgradesEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Auto upgrades are disabled for the node pool of the cluster'); + expect(results[0].message).to.include('Auto upgrades are disabled for these node pools'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/basicAuthenticationDisabled.js b/plugins/google/kubernetes/basicAuthenticationDisabled.js index 71b0ad73d..b335390be 100644 --- a/plugins/google/kubernetes/basicAuthenticationDisabled.js +++ b/plugins/google/kubernetes/basicAuthenticationDisabled.js @@ -4,26 +4,40 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Basic Authentication Disabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensure basic authentication is set to disabled on Kubernetes clusters.', more_info: 'Basic authentication uses static passwords to authenticate, which is not ' + 'the recommended method to authenticate into the Kubernetes API server.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster', recommended_action: 'Disable basic authentication on all clusters', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -33,12 +47,19 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.masterAuth && cluster.masterAuth.username && cluster.masterAuth.password) { - helpers.addResult(results, 2, 'Basic authentication is enabled on the cluster', region, cluster.name); + helpers.addResult(results, 2, 'Basic authentication is enabled on the cluster', region, resource); } else { - helpers.addResult(results, 0, 'Basic authentication is disabled on the cluster', region, cluster.name); + helpers.addResult(results, 0, 'Basic authentication is disabled on the cluster', region, resource); } }); @@ -48,4 +69,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/basicAuthenticationDisabled.spec.js b/plugins/google/kubernetes/basicAuthenticationDisabled.spec.js index 1cd3481ab..933a81de5 100644 --- a/plugins/google/kubernetes/basicAuthenticationDisabled.spec.js +++ b/plugins/google/kubernetes/basicAuthenticationDisabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./basicAuthenticationDisabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/binaryAuthorizationEnabled.js b/plugins/google/kubernetes/binaryAuthorizationEnabled.js new file mode 100644 index 000000000..68bf3386c --- /dev/null +++ b/plugins/google/kubernetes/binaryAuthorizationEnabled.js @@ -0,0 +1,72 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Binary Authorization Enabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure Binary Authorization is enabled on Kubernetes Clusters', + more_info: 'Binary authorization ensures that only trusted and signed container images are deployed within a kubernetes cluster. This provides tighter security control for images and container deployment. As a security best practice and to adhere to compliance standards, ensure this feature is enabled on all kubernetes clusters.', + link: 'https://cloud.google.com/binary-authorization/docs/overview', + recommended_action: 'Ensure binary authorization is enabled for all Kubernetes clusters', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + if (cluster.binaryAuthorization && cluster.binaryAuthorization.evaluationMode + && cluster.binaryAuthorization.evaluationMode.toLowerCase() !== 'disabled') { + helpers.addResult(results, 0, + 'Binary Authorization is enabled on the cluster', region, resource); + } else { + helpers.addResult(results, 2, + 'Binary Authorization is not enabled on the cluster', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/binaryAuthorizationEnabled.spec.js b/plugins/google/kubernetes/binaryAuthorizationEnabled.spec.js new file mode 100644 index 000000000..af2fcbcf4 --- /dev/null +++ b/plugins/google/kubernetes/binaryAuthorizationEnabled.spec.js @@ -0,0 +1,315 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./binaryAuthorizationEnabled'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('binaryAuthorizationEnabled', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if binary authorization is enabled for the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled on the cluster'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "nodeConfig": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "binaryAuthorization": { + "evaluationMode": 'PROJECT_SINGLETON_POLICY_ENFORCE' + }, + "subnetwork": "default", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "initialNodeCount": 3, + "autoscaling": {}, + "management": { + "autoUpgrade": true, + "autoRepair": true + }, + "maxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "podIpv4CidrSize": 24, + "locations": [ + "us-central1-a" + ], + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/zones/us-central1-a/clusters/standard-cluster-2/nodePools/default-pool", + "version": "1.12.8-gke.10", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-central1-a/instanceGroupManagers/gke-standard-cluster-2-default-pool-941e601d-grp" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "labelFingerprint": "a9dc16a7", + "legacyAbac": {}, + "networkPolicy": {}, + "ipAllocationPolicy": { + "useIpAliases": true, + "clusterIpv4Cidr": "10.4.0.0/14", + "servicesIpv4Cidr": "10.70.0.0/20", + "clusterSecondaryRangeName": "gke-standard-cluster-2-pods-c6001cf3", + "servicesSecondaryRangeName": "gke-standard-cluster-2-services-c6001cf3", + "clusterIpv4CidrBlock": "10.4.0.0/14", + "servicesIpv4CidrBlock": "10.70.0.0/20" + }, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "shieldedNodes": {}, + "tierSettings": { + "tier": "STANDARD" + }, + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/zones/us-central1-a/clusters/standard-cluster-2", + "zone": "us-central1-a", + "endpoint": "10.127.0.2", + "initialClusterVersion": "1.12.8-gke.10", + "currentMasterVersion": "1.12.8-gke.10", + "currentNodeVersion": "1.12.8-gke.10", + "createTime": "2019-08-20T20:21:44+00:00", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-central1-a/instanceGroupManagers/gke-standard-cluster-2-default-pool-941e601d-grp" + ], + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if binary authorization is not enabled on the cluster', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enabled on the cluster'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-1", + "binaryAuthorization": { + "evaluationMode": 'DISABLED' + }, + "nodeConfig": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "masterAuth": { + "clusterCaCertificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURDekNDQWZPZ0F3SUJBZ0lRR2txVDk3YWJsQkR5VEdyRXFCL2dIREFOQmdrcWhraUc5dzBCQVFzRkFEQXYKTVMwd0t3WURWUVFERXlRM05ERXhNalZpT0MwMk1qSXlMVFEwWm1VdFlUVXlNQzAyTWpKaE5EZ3haalV3WW1VdwpIaGNOTVRrd09ESXdNVGt3T0RRM1doY05NalF3T0RFNE1qQXdPRFEzV2pBdk1TMHdLd1lEVlFRREV5UTNOREV4Ck1qVmlPQzAyTWpJeUxUUTBabVV0WVRVeU1DMDJNakpoTkRneFpqVXdZbVV3Z2dFaU1BMEdDU3FHU0liM0RRRUIKQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUN4Kzd0d3RNUE1QTXNVeVpENzFQeTB3N3FMUTZRMUV3WGhMQkhSMWIwVApaZWZZSVRVZkdCOTNySVJDdnRYWkxhb2pKNjFpODFnZGxUNFZIRFFTNkJ6UGppaUM4aXhpMjdGUEp6cVRQeW5KCkdMeThGUnFhUm9CYjBOTVdjQnJwakZSR25CbzF5ZjNpYzEvNnZtcEd4SmU2L2NQd2UvbGR4MWlkOUVKa2g5RjkKa1hTSEsrM1F0MnJ2ZVdGdHQxYVdMWDBkblkreGVPRUZJNzdxYWVhWFVKQVJPekhzMDFtZitWT2VOOUd1d2lIMAorL2V3YmtEL2doN3RvS3VyTlR2c3RNR1hJWHNSajZWZi91Z2xsRmxKSm9iUWYzenR3MldLZjgxR3NMZ0pFeWo4CnR2K3pxTGRIV0pycHBMYnZQak4wTTMvVHFDK3drZ2FEVjdEMXJ5cVNmVHo5QWdNQkFBR2pJekFoTUE0R0ExVWQKRHdFQi93UUVBd0lDQkRBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCQgp2TktBRDdyOVgvT01xdVVtVmNmTDU2cmRsaVdEOGE2SXU4cWZUK0F1aXV1TWFBd05ReHVnZDc5MnhSdkJlN1B3CkY0czhxaXZxbTQvdXhXbERFWnUzUXpzMFhZcFdIVzlvYVNmb1lXRFNya3p0TEQ0U3ppb2pHWWNHWWZnNjNpVG0KV0toSjMrdTB1T0JLSXVaWU9ESUlJNzNUWGVpR1phdUxpTFlURWlrMHpwMzN6VjI5VGE3SXNGNXFaZ1cwWWRlWgpwbk92Z28wc1FwNlZzbkw0eUFLSWpvLzlkT1JMUmFZN1d3K2RTWTIxL0FPQllXWGNsc3krdzBHRHA1M09odXB1CkZxbElqbkZROXRrWldaMXpaS0ZoU0dhcE9oTVR6UHg3aDlFeFdlSkpGQkJyTUlTdHFJM3o3YTd2WWhBcUlRM1EKdFJITXhISW9WeUU1blhMTkxYaXgKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=" + }, + "loggingService": "none", + "monitoringService": "none", + "network": "default", + "clusterIpv4Cidr": "10.48.0.0/14", + "subnetwork": "default", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "initialNodeCount": 3, + "autoscaling": { + "enabled": true, + "minNodeCount": 1, + "maxNodeCount": 3 + }, + "management": { + "autoUpgrade": true, + "autoRepair": true + }, + "maxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "podIpv4CidrSize": 24, + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/locations/us-east1/clusters/standard-cluster-1/nodePools/default-pool", + "version": "1.12.8-gke.10", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-b/instanceGroupManagers/gke-standard-cluster-1-default-pool-60ff7186-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-c/instanceGroupManagers/gke-standard-cluster-1-default-pool-f7958043-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-d/instanceGroupManagers/gke-standard-cluster-1-default-pool-51806d28-grp" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "labelFingerprint": "a9dc16a7", + "legacyAbac": {}, + "networkPolicy": {}, + "ipAllocationPolicy": { + "useIpAliases": true, + "clusterIpv4Cidr": "10.48.0.0/14", + "servicesIpv4Cidr": "10.114.0.0/20", + "clusterSecondaryRangeName": "gke-standard-cluster-1-pods-1de110e2", + "servicesSecondaryRangeName": "gke-standard-cluster-1-services-1de110e2", + "clusterIpv4CidrBlock": "10.48.0.0/14", + "servicesIpv4CidrBlock": "10.114.0.0/20" + }, + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/locations/us-east1/clusters/standard-cluster-1", + "zone": "us-east1", + "endpoint": "35.231.28.40", + "initialClusterVersion": "1.12.8-gke.10", + "currentMasterVersion": "1.12.8-gke.10", + "currentNodeVersion": "1.12.8-gke.10", + "createTime": "2019-08-20T20:08:47+00:00", + "status": "RUNNING", + "servicesIpv4Cidr": "10.114.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-b/instanceGroupManagers/gke-standard-cluster-1-default-pool-60ff7186-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-c/instanceGroupManagers/gke-standard-cluster-1-default-pool-f7958043-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-d/instanceGroupManagers/gke-standard-cluster-1-default-pool-51806d28-grp" + ], + "currentNodeCount": 5, + "location": "us-east1" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/clientCertificateDisabled.js b/plugins/google/kubernetes/clientCertificateDisabled.js new file mode 100644 index 000000000..99a531a03 --- /dev/null +++ b/plugins/google/kubernetes/clientCertificateDisabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Client Certificate Disabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'High', + description: 'Ensure client certificate authentication to Kubernetes clusters is disabled.', + more_info: 'In authentication using client certificates, the client presents a certificate signed by cluster root certificate authority which is only base64 encoded and not encrypted. The client certificate authentication method is considered legacy and cause potential security risks. It is recommended to use the default GKE OAuth method for authentication.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster#restrict_authn_methods', + recommended_action: 'Ensure no kubernetes clusters are using client certificates for authentication', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + if (cluster.masterAuth && cluster.masterAuth.clientCertificate) { + helpers.addResult(results, 2, + 'Cluster is using client certificate for authentication', region, resource); + } else { + helpers.addResult(results, 0, + 'Cluster is not using client certificate for authentication', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/clientCertificateDisabled.spec.js b/plugins/google/kubernetes/clientCertificateDisabled.spec.js new file mode 100644 index 000000000..f41408822 --- /dev/null +++ b/plugins/google/kubernetes/clientCertificateDisabled.spec.js @@ -0,0 +1,260 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./clientCertificateDisabled'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('clientCertificateDisabled', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if client certificates are not used for cluster authentication', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cluster is not using'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "nodeConfig": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "masterAuth": { + "clientCaCertificate": "sdsdsdaLSd0tLS1CRUdJTiBDRVJUSUZJQ0t3aalV3WW1VdwpItLSSSSSS0tLQo=", + }, + "binaryAuthorization": { + "evaluationMode": 'PROJECT_SINGLETON_POLICY_ENFORCE' + }, + + "locations": [ + "us-central1-a" + ], + "labelFingerprint": "a9dc16a7", + "legacyAbac": {}, + "networkPolicy": {}, + "ipAllocationPolicy": { + "useIpAliases": true, + "clusterIpv4Cidr": "10.4.0.0/14", + "servicesIpv4Cidr": "10.70.0.0/20", + "clusterSecondaryRangeName": "gke-standard-cluster-2-pods-c6001cf3", + "servicesSecondaryRangeName": "gke-standard-cluster-2-services-c6001cf3", + "clusterIpv4CidrBlock": "10.4.0.0/14", + "servicesIpv4CidrBlock": "10.70.0.0/20" + }, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "shieldedNodes": {}, + "tierSettings": { + "tier": "STANDARD" + }, + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/zones/us-central1-a/clusters/standard-cluster-2", + "zone": "us-central1-a", + "endpoint": "10.127.0.2", + "initialClusterVersion": "1.12.8-gke.10", + "currentMasterVersion": "1.12.8-gke.10", + "currentNodeVersion": "1.12.8-gke.10", + "createTime": "2019-08-20T20:21:44+00:00", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-central1-a/instanceGroupManagers/gke-standard-cluster-2-default-pool-941e601d-grp" + ], + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if client certifcate is used for cluster authentication', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cluster is using'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-1", + "binaryAuthorization": { + "evaluationMode": 'DISABLED' + }, + "masterAuth": { + "clientCaCertificate": "sdsdsdaLSd0tLS1CRUdJTiBDRVJUSUZJQ0t3aalV3WW1VdwpItLSSSSSS0tLQo=", + "clientCertificate": "AHDHAB6GDGDGSTKKXNCNSHHSODSSDDFF1==", + "clientKey": "RSVADJDKSLDSMD2242HDHDHDDLA" + }, + "loggingService": "none", + "monitoringService": "none", + "network": "default", + "clusterIpv4Cidr": "10.48.0.0/14", + "subnetwork": "default", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "oauthScopes": [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/trace.append" + ], + "metadata": { + "disable-legacy-endpoints": "true" + }, + "imageType": "COS", + "serviceAccount": "default", + "diskType": "pd-standard" + }, + "initialNodeCount": 3, + "autoscaling": { + "enabled": true, + "minNodeCount": 1, + "maxNodeCount": 3 + }, + "management": { + "autoUpgrade": true, + "autoRepair": true + }, + "maxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "podIpv4CidrSize": 24, + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/locations/us-east1/clusters/standard-cluster-1/nodePools/default-pool", + "version": "1.12.8-gke.10", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-b/instanceGroupManagers/gke-standard-cluster-1-default-pool-60ff7186-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-c/instanceGroupManagers/gke-standard-cluster-1-default-pool-f7958043-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-d/instanceGroupManagers/gke-standard-cluster-1-default-pool-51806d28-grp" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "labelFingerprint": "a9dc16a7", + "legacyAbac": {}, + "networkPolicy": {}, + "ipAllocationPolicy": { + "useIpAliases": true, + "clusterIpv4Cidr": "10.48.0.0/14", + "servicesIpv4Cidr": "10.114.0.0/20", + "clusterSecondaryRangeName": "gke-standard-cluster-1-pods-1de110e2", + "servicesSecondaryRangeName": "gke-standard-cluster-1-services-1de110e2", + "clusterIpv4CidrBlock": "10.48.0.0/14", + "servicesIpv4CidrBlock": "10.114.0.0/20" + }, + "selfLink": "https://container.googleapis.com/v1beta1/projects/frost-forest-281330/locations/us-east1/clusters/standard-cluster-1", + "zone": "us-east1", + "endpoint": "35.231.28.40", + "initialClusterVersion": "1.12.8-gke.10", + "currentMasterVersion": "1.12.8-gke.10", + "currentNodeVersion": "1.12.8-gke.10", + "createTime": "2019-08-20T20:08:47+00:00", + "status": "RUNNING", + "servicesIpv4Cidr": "10.114.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-b/instanceGroupManagers/gke-standard-cluster-1-default-pool-60ff7186-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-c/instanceGroupManagers/gke-standard-cluster-1-default-pool-f7958043-grp", + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-east1-d/instanceGroupManagers/gke-standard-cluster-1-default-pool-51806d28-grp" + ], + "currentNodeCount": 5, + "location": "us-east1" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/clusterEncryption.js b/plugins/google/kubernetes/clusterEncryption.js new file mode 100644 index 000000000..f51a84a03 --- /dev/null +++ b/plugins/google/kubernetes/clusterEncryption.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cluster Encryption Enabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'High', + description: 'Ensure that GKE clusters have KMS encryption enabled to encrypt application-layer secrets.', + more_info: 'Application-layer secrets encryption adds additional security layer to sensitive data such as Kubernetes secrets stored in etcd.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/encrypting-secrets', + recommended_action: 'Ensure that all GKE clusters have the desired application-layer secrets encryption level.', + apis: ['kubernetes:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + kubernetes_cluster_encryption_level: { + name: 'Kubernetes Cluster Encryption Protection Level', + description: 'Desired protection level for GKE clusters. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.kubernetes_cluster_encryption_level || this.settings.kubernetes_cluster_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.kubernetes, function(region, rcb) { + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + let currentEncryptionLevel; + + if (cluster.databaseEncryption && cluster.databaseEncryption.state && + cluster.databaseEncryption.state.toUpperCase() == 'ENCRYPTED' && + cluster.databaseEncryption.keyName && cluster.databaseEncryption.keyName.length && + keysObj[cluster.databaseEncryption.keyName]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[cluster.databaseEncryption.keyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `GKE Cluster has application-layer secrets encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `GKE Cluster has application-layer secrets encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + + }); + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/kubernetes/clusterEncryption.spec.js b/plugins/google/kubernetes/clusterEncryption.spec.js new file mode 100644 index 000000000..f3ae72113 --- /dev/null +++ b/plugins/google/kubernetes/clusterEncryption.spec.js @@ -0,0 +1,169 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./clusterEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const clusters = [ + { + "name": "standard-cluster-2", + "nodePools": [ + { + "name": "default-pool", + "config": { + }, + "initialNodeCount": 3, + "locations": [ + "us-central1-a" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "currentNodeCount": 2, + "location": "us-central1-a" + }, + { + "name": "standard-cluster-1", + "databaseEncryption": { + keyName: 'projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2', + state: 'ENCRYPTED' + }, + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "currentNodeCount": 2, + "location": "us-central1-a" + } +]; + +const createCache = (clustersList, clusterError, keysList, keysErr) => { + return { + kubernetes: { + list: { + 'global': { + err: clusterError, + data: clustersList + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + + +describe('clusterEncryption', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'], + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([],null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if cluster application-layer secrets encryption level is equal to or greater than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache([clusters[1]], null, cryptoKeys, null); + plugin.run(cache, {}, callback); + }); + + it('should give failing result if cluster application-layer secrets encryption level is less than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([clusters[0]], null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if cluster application-layer secrets encryption level key is not found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([clusters[0]], null, [], null); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/clusterLabelsAdded.js b/plugins/google/kubernetes/clusterLabelsAdded.js index 8bb35e14f..7d92ba731 100644 --- a/plugins/google/kubernetes/clusterLabelsAdded.js +++ b/plugins/google/kubernetes/clusterLabelsAdded.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Cluster Labels Added', category: 'Kubernetes', + domain: 'Containers', + severity: 'Low', description: 'Ensures all Kubernetes clusters have labels added', more_info: 'It is recommended to add labels to Kubernetes clusters to apply specific security settings and auto configure objects at creation.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/creating-managing-labels', recommended_action: 'Ensure labels are added to Kubernetes clusters', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,13 +46,20 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.resourceLabels && Object.keys(cluster.resourceLabels).length) { helpers.addResult(results, 0, - `${Object.keys(cluster.resourceLabels).length} labels found for the cluster.`, region, cluster.name); + `${Object.keys(cluster.resourceLabels).length} labels found for the cluster.`, region, resource); } else { helpers.addResult(results, 2, - 'The cluster does not have any labels added.', region, cluster.name); + 'The cluster does not have any labels added.', region, resource); } }); diff --git a/plugins/google/kubernetes/clusterLabelsAdded.spec.js b/plugins/google/kubernetes/clusterLabelsAdded.spec.js index 128d2a885..fb253f745 100644 --- a/plugins/google/kubernetes/clusterLabelsAdded.spec.js +++ b/plugins/google/kubernetes/clusterLabelsAdded.spec.js @@ -4,11 +4,18 @@ var plugin = require('./clusterLabelsAdded'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/clusterLeastPrivilege.js b/plugins/google/kubernetes/clusterLeastPrivilege.js index 6751f076c..f81e294c9 100644 --- a/plugins/google/kubernetes/clusterLeastPrivilege.js +++ b/plugins/google/kubernetes/clusterLeastPrivilege.js @@ -4,33 +4,47 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Cluster Least Privilege', category: 'Kubernetes', - description: 'Ensures Kubernetes clusters are created with limited service account access scopes', - more_info: 'Kubernetes service accounts should be limited in scope to the services necessary to operate the clusters.', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures Kubernetes clusters using default service account are using minimal service account access scopes', + more_info: 'As a best practice, Kubernetes clusters should not be created with default service account. But if they are, ' + + 'Kubernetes default service account should be limited to minimal access scopes necessary to operate the clusters.', link: 'https://cloud.google.com/compute/docs/access/service-accounts', - recommended_action: 'Ensure that all Kubernetes clusters are created with limited access scope.', - apis: ['clusters:list'], + recommended_action: 'Ensure that all Kubernetes clusters are created with minimal access scope.', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, (region, rcb) => { var clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { helpers.addResult(results, 3, - 'Unable to query for clusters: ' + helpers.addError(clusters), region); + 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } if (!clusters.data.length) { - helpers.addResult(results, 0, 'No clusters found', region); + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } @@ -43,29 +57,27 @@ module.exports = { 'https://www.googleapis.com/auth/trace.append' ]; - let otherScope = false; - clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + let otherScope = false; if (cluster.nodeConfig && cluster.nodeConfig.serviceAccount && cluster.nodeConfig.serviceAccount == 'default') { cluster.nodeConfig.oauthScopes.forEach((oneScope) => { - let sameExist= false; - - for (let i = 0; i < minimalAccess.length; i++) { - if (oneScope == minimalAccess[i]) { - sameExist = true; - } - } - if (sameExist == false) { - otherScope = true; - } + if (!minimalAccess.includes(oneScope)) otherScope = true; }); } - if (otherScope == true) { - helpers.addResult(results, 2, 'No minimal access is allowed on Kubernetes cluster', region, cluster.name); + + if (otherScope) { + helpers.addResult(results, 2, 'No minimal access is allowed on Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 0, 'Minimal access is allowed on Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 0, 'Minimal access is allowed on Kubernetes cluster', region, resource); } }); rcb(); @@ -73,4 +85,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/google/kubernetes/clusterLeastPrivilege.spec.js b/plugins/google/kubernetes/clusterLeastPrivilege.spec.js index c726fb0fb..9ef82c40a 100644 --- a/plugins/google/kubernetes/clusterLeastPrivilege.spec.js +++ b/plugins/google/kubernetes/clusterLeastPrivilege.spec.js @@ -3,12 +3,19 @@ var plugin = require('./clusterLeastPrivilege'); const createCache = (clusterData) => { return { - clusters: { + kubernetes: { list: { 'global': { data: clusterData } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } } } }; @@ -19,7 +26,7 @@ describe('clusterLeastPrivilege', function () { const callback = (err, results) => { expect(results.length).to.be.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.equal('No clusters found'); + expect(results[0].message).to.equal('No Kubernetes clusters found'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/clusterNetworkExposure.js b/plugins/google/kubernetes/clusterNetworkExposure.js new file mode 100644 index 000000000..f7572be4f --- /dev/null +++ b/plugins/google/kubernetes/clusterNetworkExposure.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Internet Exposure', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Info', + description: 'Check if GKE clusters are exposed to the internet.', + more_info: 'GKE clusters exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing the Kubernetes API, nodes, and services through proper configuration of network, firewall rules, and private clusters.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters', + recommended_action: 'Secure GKE clusters by enabling private clusters, restricting access to the Kubernetes API, and ensuring nodes and services are protected through properly configured firewall rules and network policies.', + apis: ['kubernetes:list', 'firewalls:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster', 'container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool', + 'compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', 'global']); + + if (!firewalls || firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', 'global', null, null, firewalls.err); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', 'global'); + } + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + + clusters.data.forEach(cluster => { + + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + let internetExposed = ''; + if (helpers.checkClusterExposure(cluster)) { + internetExposed = 'public endpoint access'; + } else { + let clusterNetwork = cluster.networkConfig && cluster.networkConfig.network ? cluster.networkConfig.network : cluster.network; + if (clusterNetwork && !clusterNetwork.includes('/')) clusterNetwork = `${clusterNetwork}`; + let firewallRules = firewalls.data.filter(rule => { + return rule.network && rule.network.endsWith(clusterNetwork); + }); + + + let isExposed = helpers.checkFirewallRules(firewallRules); + if (isExposed && isExposed.exposed && isExposed.networkName) { + internetExposed = isExposed.networkName; + } else { + // check node pools + let exposedNodePools = Array.isArray(cluster.nodePools) ? cluster.nodePools.filter(nodepool => nodepool.networkConfig && !nodepool.networkConfig.enablePrivateNodes).map(nodepool => nodepool.name) : [] ; + if (exposedNodePools.length) { + internetExposed = `node pools ${exposedNodePools.join(',')}`; + } + } + + } + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Cluster is exposed to the internet through ${internetExposed}`, region, resource); + } else { + helpers.addResult(results, 0, 'Cluster is not exposed to the internet', region, resource); + } + + + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/kubernetes/cosImageEnabled.js b/plugins/google/kubernetes/cosImageEnabled.js index 6a25c7e7a..6f8bf72e4 100644 --- a/plugins/google/kubernetes/cosImageEnabled.js +++ b/plugins/google/kubernetes/cosImageEnabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'COS Image Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'High', description: 'Ensures all Kubernetes cluster nodes have Container-Optimized OS enabled', more_info: 'Container-Optimized OS is optimized to enhance node security. It is backed by a team at Google that can quickly patch it.', link: 'https://cloud.google.com/container-optimized-os/', recommended_action: 'Enable Container-Optimized OS on all Kubernetes cluster nodes', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster','container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster','container.ClusterManager.UpdateNodePool', 'container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,21 +46,33 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + let found = false; + let nonCosNodes = []; if (cluster.nodePools && cluster.nodePools.length) { + found = true; cluster.nodePools.forEach(nodePool => { - if (nodePool.config && - nodePool.config.imageType && - nodePool.config.imageType === "COS") { - helpers.addResult(results, 0, - `Container-Optimized OS is enabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } else { - helpers.addResult(results, 2, - `Container-Optimized OS disabled for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } - }) + if (!nodePool.config || !nodePool.config.imageType || !nodePool.config.imageType === 'COS') nonCosNodes.push(nodePool.name); + }); + } + + if (nonCosNodes.length) { + helpers.addResult(results, 2, + `Container-Optimized OS disabled for these node pools: ${cluster.name}`, region, resource); } else { - helpers.addResult(results, 0, 'No node pools found', region, cluster.name); + helpers.addResult(results, 0, + 'Container-Optimized OS is enabled for all node pools', region, resource); + } + + if (!found) { + helpers.addResult(results, 0, 'No node pools found', region, resource); } }); @@ -56,4 +82,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/cosImageEnabled.spec.js b/plugins/google/kubernetes/cosImageEnabled.spec.js index 6eb2e26a4..7736cd34c 100644 --- a/plugins/google/kubernetes/cosImageEnabled.spec.js +++ b/plugins/google/kubernetes/cosImageEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./cosImageEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -51,11 +58,11 @@ describe('cosImageEnabled', function () { plugin.run(cache, {}, callback); }); - it('should give passing result if Container-Optimized OS is enabled for the node pool of the cluster', function (done) { + it('should give passing result if Container-Optimized OS is enabled for all node pools', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Container-Optimized OS is enabled for the node pool of the cluster'); + expect(results[0].message).to.include('Container-Optimized OS is enabled for all node pools'); expect(results[0].region).to.equal('global'); done() }; @@ -214,7 +221,7 @@ describe('cosImageEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Container-Optimized OS disabled for the node pool of the cluster'); + expect(results[0].message).to.include('Container-Optimized OS disabled for these node pools'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/defaultServiceAccount.js b/plugins/google/kubernetes/defaultServiceAccount.js index d72cedfdb..ec90db06d 100644 --- a/plugins/google/kubernetes/defaultServiceAccount.js +++ b/plugins/google/kubernetes/defaultServiceAccount.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Default Service Account', category: 'Kubernetes', + domain: 'Containers', + severity: 'High', description: 'Ensures all Kubernetes cluster nodes are not using the default service account.', more_info: 'Kubernetes cluster nodes should use customized service accounts that have minimal privileges to run. This reduces the attack surface in the case of a malicious attack on the cluster.', link: 'https://cloud.google.com/container-optimized-os/', recommended_action: 'Ensure that no Kubernetes cluster nodes are using the default service account', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,21 +46,35 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + let found = false; + let defaultSaNodes = []; if (cluster.nodePools && cluster.nodePools.length) { cluster.nodePools.forEach(nodePool => { + found = true; if (nodePool.config && nodePool.config.serviceAccount && - nodePool.config.serviceAccount === "default") { - helpers.addResult(results, 2, - `The default service account is being used for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } else { - helpers.addResult(results, 0, - `The default service account is not being used for the node pool of the cluster: ${cluster.name}`, region, nodePool.name); - } - }) + nodePool.config.serviceAccount === 'default') defaultSaNodes.push(nodePool.name); + }); + } + + if (defaultSaNodes.length) { + helpers.addResult(results, 2, + `The default service account is being used for these node pools: ${cluster.name}`, region, resource); } else { - helpers.addResult(results, 0, 'No node pools found', region, cluster.name); + helpers.addResult(results, 0, + 'The default service account is not being used for the node pools', region, resource); + } + + if (!found) { + helpers.addResult(results, 0, 'No node pools found', region, resource); } }); diff --git a/plugins/google/kubernetes/defaultServiceAccount.spec.js b/plugins/google/kubernetes/defaultServiceAccount.spec.js index fcb43ea53..d7cfd5dfb 100644 --- a/plugins/google/kubernetes/defaultServiceAccount.spec.js +++ b/plugins/google/kubernetes/defaultServiceAccount.spec.js @@ -4,11 +4,18 @@ var plugin = require('./defaultServiceAccount'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -55,7 +62,7 @@ describe('defaultServiceAccount', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('The default service account is not being used for the node pool of the cluster'); + expect(results[0].message).to.include('The default service account is not being used for the node pools'); expect(results[0].region).to.equal('global'); done() }; @@ -214,7 +221,7 @@ describe('defaultServiceAccount', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('The default service account is being used for the node pool of the cluster'); + expect(results[0].message).to.include('The default service account is being used for these node pools'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/integrityMonitoringEnabled.js b/plugins/google/kubernetes/integrityMonitoringEnabled.js new file mode 100644 index 000000000..ff656603c --- /dev/null +++ b/plugins/google/kubernetes/integrityMonitoringEnabled.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Integrity Monitoring Enabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures all Kubernetes shielded cluster node have integrity monitoring enabled', + more_info: 'Integrity Monitoring feature automatically monitors the integrity of your cluster nodes.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/shielded-gke-nodes#integrity_monitoring', + recommended_action: 'Enable Integrity Monitoring feature for your cluster nodes', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let disbaledIntegrityMonitoringNodes = []; + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.nodePools && + cluster.nodePools.length) { + cluster.nodePools.forEach(nodePool => { + if (!nodePool.config || !nodePool.config.shieldedInstanceConfig || !nodePool.config.shieldedInstanceConfig.enableIntegrityMonitoring) disbaledIntegrityMonitoringNodes.push(nodePool.name); + }); + if (disbaledIntegrityMonitoringNodes.length) { + helpers.addResult(results, 2, + `Integrity Monitoring is disabled for these node pools: ${disbaledIntegrityMonitoringNodes.join(', ')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'Integrity Monitoring is enabled for all node pools', region, resource); + } + } else { + helpers.addResult(results, 0, 'No node pools found', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/integrityMonitoringEnabled.spec.js b/plugins/google/kubernetes/integrityMonitoringEnabled.spec.js new file mode 100644 index 000000000..c0f370ab4 --- /dev/null +++ b/plugins/google/kubernetes/integrityMonitoringEnabled.spec.js @@ -0,0 +1,157 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./integrityMonitoringEnabled'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('integrityMonitoringEnabled', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if integrity monitoring is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Integrity Monitoring is enabled for all node pools'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "shieldedInstanceConfig": { + "enableIntegrityMonitoring": true + } + }, + "initialNodeCount": 3, + "locations": [ + "us-central1-a" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-central1-a/instanceGroupManagers/gke-standard-cluster-2-default-pool-941e601d-grp" + ], + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if integrity monitoring is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Integrity Monitoring is disabled for these node pools'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-1", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "shieldedInstanceConfig": {} + }, + "initialNodeCount": 3, + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "zone": "us-east1", + "status": "RUNNING", + "currentNodeCount": 5, + "location": "us-east1" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/kubernetesAlphaDisabled.js b/plugins/google/kubernetes/kubernetesAlphaDisabled.js new file mode 100644 index 000000000..2467844cb --- /dev/null +++ b/plugins/google/kubernetes/kubernetesAlphaDisabled.js @@ -0,0 +1,71 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Kubernetes Alpha Disabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure the GKE Cluster alpha cluster feature is disabled.', + more_info: 'It is recommended to not use Alpha clusters as they expire after thirty days and do not receive security updates.', + link: 'https://cloud.google.com/kubernetes-engine/docs/concepts/alpha-clusters', + recommended_action: '1. Create a new cluster with the alpha feature disabled. 2. Migrate all required cluster data from the cluster with alpha to this newly created cluster. 3.Delete the engine cluster with alpha enabled.', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + if (!cluster.name) return; + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + if (!cluster.enableKubernetesAlpha) { + helpers.addResult(results, 0, + 'Kubernetes cluster has alpha feature disabled', region, resource); + } else { + helpers.addResult(results, 2, + 'Kubernetes cluster does not have alpha feature disabled', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/kubernetesAlphaDisabled.spec.js b/plugins/google/kubernetes/kubernetesAlphaDisabled.spec.js new file mode 100644 index 000000000..d6be686b3 --- /dev/null +++ b/plugins/google/kubernetes/kubernetesAlphaDisabled.spec.js @@ -0,0 +1,102 @@ +var expect = require('chai').expect; +var plugin = require('./kubernetesAlphaDisabled'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('kubernetesAlphaDisabled', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Kubernetes cluster has alpha feature disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Kubernetes cluster has alpha feature disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2" } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Kubernetes cluster does not have alpha feature disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Kubernetes cluster does not have alpha feature disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-1", + "enableKubernetesAlpha": true + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js b/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js new file mode 100644 index 000000000..9e6242672 --- /dev/null +++ b/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js @@ -0,0 +1,24 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Info', + description: 'Ensures that Kubernetes workloads and service accounts are not granted excessive permissions.', + more_info: 'Kubernetes workloads often use service accounts to interact with the Kubernetes API and other GCP resources. Over-privileged service accounts can lead to privilege escalation or lateral movement within the cluster or the cloud environment. Following the principle of least privilege helps minimize potential attack surfaces.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/iam', + recommended_action: 'Review and minimize IAM permissions granted to Kubernetes service accounts and workload identities. Use role-based access control (RBAC) and GCP IAM best practices to ensure only required access is permitted.', + realtime_triggers: [ + 'container.projects.updateCluster', + 'container.projects.createCluster', + 'container.projects.deleteCluster', + 'iam.serviceAccounts.setIamPolicy', + 'iam.serviceAccounts.getIamPolicy' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +}; diff --git a/plugins/google/kubernetes/legacyAuthorizationDisabled.js b/plugins/google/kubernetes/legacyAuthorizationDisabled.js index c7d42177a..897b2d68d 100644 --- a/plugins/google/kubernetes/legacyAuthorizationDisabled.js +++ b/plugins/google/kubernetes/legacyAuthorizationDisabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Legacy Authorization Disabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'High', description: 'Ensure legacy authorization is set to disabled on Kubernetes clusters', more_info: 'The legacy authorizer in Kubernetes grants broad, statically defined permissions.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster', recommended_action: 'Disable legacy authorization on all clusters.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,11 +46,18 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.legacyAbac && cluster.legacyAbac.enabled) { - helpers.addResult(results, 2, 'Legacy Authorization is enabled on the cluster', region, cluster.name); + helpers.addResult(results, 2, 'Legacy Authorization is enabled on the cluster', region, resource); } else { - helpers.addResult(results, 0, 'Legacy Authorization is disabled on the cluster', region, cluster.name); + helpers.addResult(results, 0, 'Legacy Authorization is disabled on the cluster', region, resource); } }); @@ -46,4 +67,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/legacyAuthorizationDisabled.spec.js b/plugins/google/kubernetes/legacyAuthorizationDisabled.spec.js index d5610d67a..14fead8b0 100644 --- a/plugins/google/kubernetes/legacyAuthorizationDisabled.spec.js +++ b/plugins/google/kubernetes/legacyAuthorizationDisabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./legacyAuthorizationDisabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/loggingEnabled.js b/plugins/google/kubernetes/loggingEnabled.js index 8a89a931b..b56441de3 100644 --- a/plugins/google/kubernetes/loggingEnabled.js +++ b/plugins/google/kubernetes/loggingEnabled.js @@ -4,30 +4,44 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Logging Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes clusters have logging enabled', more_info: 'This setting should be enabled to ensure Kubernetes control plane logs are properly recorded.', link: 'https://cloud.google.com/monitoring/kubernetes-engine/legacy-stackdriver/logging', recommended_action: 'Ensure that logging is enabled on all Kubernetes clusters.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { helpers.addResult(results, 3, - 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -37,13 +51,20 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (!cluster.loggingService || (cluster.loggingService && cluster.loggingService === 'none')) { helpers.addResult(results, 2, - 'Logging is disabled on the Kubernetes cluster', region, cluster.name); + 'Logging is disabled on the Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 0, 'Logging is enabled on the cluster', region, cluster.name); + helpers.addResult(results, 0, 'Logging is enabled on the cluster', region, resource); } }); diff --git a/plugins/google/kubernetes/loggingEnabled.spec.js b/plugins/google/kubernetes/loggingEnabled.spec.js index ff465c776..0397d39ee 100644 --- a/plugins/google/kubernetes/loggingEnabled.spec.js +++ b/plugins/google/kubernetes/loggingEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./loggingEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/masterAuthorizedNetwork.js b/plugins/google/kubernetes/masterAuthorizedNetwork.js index 6837b0b09..58eaef6ff 100644 --- a/plugins/google/kubernetes/masterAuthorizedNetwork.js +++ b/plugins/google/kubernetes/masterAuthorizedNetwork.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Master Authorized Network', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures master authorized networks is set to enabled on Kubernetes clusters', more_info: 'Authorized networks are a way of specifying a restricted range of IP addresses that are permitted to access your container clusters Kubernetes master endpoint.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/authorized-networks', recommended_action: 'Enable master authorized networks on all clusters.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,11 +46,18 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.masterAuthorizedNetworksConfig && cluster.masterAuthorizedNetworksConfig.enabled) { - helpers.addResult(results, 0, 'Master Authorized Network is enabled on the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 0, 'Master Authorized Network is enabled on the Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 2, 'Master Authorized Network is disabled on the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 2, 'Master Authorized Network is disabled on the Kubernetes cluster', region, resource); } }); diff --git a/plugins/google/kubernetes/masterAuthorizedNetwork.spec.js b/plugins/google/kubernetes/masterAuthorizedNetwork.spec.js index cdcfcc5e8..9f8bc2863 100644 --- a/plugins/google/kubernetes/masterAuthorizedNetwork.spec.js +++ b/plugins/google/kubernetes/masterAuthorizedNetwork.spec.js @@ -4,11 +4,18 @@ var plugin = require('./masterAuthorizedNetwork'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/monitoringEnabled.js b/plugins/google/kubernetes/monitoringEnabled.js index 2578ff9d8..19eee97c3 100644 --- a/plugins/google/kubernetes/monitoringEnabled.js +++ b/plugins/google/kubernetes/monitoringEnabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Monitoring Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes clusters have monitoring enabled', more_info: 'Kubernetes supports monitoring through Stackdriver.', link: 'https://cloud.google.com/monitoring/kubernetes-engine/', recommended_action: 'Ensure monitoring is enabled on all Kubernetes clusters.', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -30,14 +44,20 @@ module.exports = { helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } - var badClusters = false; + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.monitoringService && cluster.monitoringService == 'none') { - badClusters = true; - helpers.addResult(results, 2, 'Monitoring is disabled on the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 2, 'Monitoring is disabled on the Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 0, 'Monitoring is enabled on the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 0, 'Monitoring is enabled on the Kubernetes cluster', region, resource); } }); @@ -47,4 +67,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/monitoringEnabled.spec.js b/plugins/google/kubernetes/monitoringEnabled.spec.js index f04523cc9..ddd269042 100644 --- a/plugins/google/kubernetes/monitoringEnabled.spec.js +++ b/plugins/google/kubernetes/monitoringEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./monitoringEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/networkPolicyEnabled.js b/plugins/google/kubernetes/networkPolicyEnabled.js index d6fb0c504..5b57c9141 100644 --- a/plugins/google/kubernetes/networkPolicyEnabled.js +++ b/plugins/google/kubernetes/networkPolicyEnabled.js @@ -4,39 +4,60 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Network Policy Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures all Kubernetes clusters have network policy enabled', more_info: 'Kubernetes network policy creates isolation between cluster pods, this creates a more secure environment with only specified connections allowed.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/network-policy', recommended_action: 'Enable network policy on all Kubernetes clusters.', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } if (!clusters.data.length) { - helpers.addResult(results, 0, 'No clusters found', region); + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } clusters.data.forEach(cluster => { - if (cluster.networkPolicy && - cluster.networkPolicy.enabled) { - helpers.addResult(results, 0, 'Network policy is enabled for the Kubernetes cluster', region, cluster.name); + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if ((cluster.networkPolicy && cluster.networkPolicy.enabled) || + (cluster.networkConfig && cluster.networkConfig.datapathProvider + && cluster.networkConfig.datapathProvider === 'ADVANCED_DATAPATH')) { + helpers.addResult(results, 0, 'Network policy is enabled for the Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 2, 'Network policy is disabled for the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 2, 'Network policy is disabled for the Kubernetes cluster', region, resource); } }); @@ -46,4 +67,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/networkPolicyEnabled.spec.js b/plugins/google/kubernetes/networkPolicyEnabled.spec.js index dfb2124e9..cb59771b1 100644 --- a/plugins/google/kubernetes/networkPolicyEnabled.spec.js +++ b/plugins/google/kubernetes/networkPolicyEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./networkPolicyEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -37,7 +44,7 @@ describe('networkPolicyEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No clusters found'); + expect(results[0].message).to.include('No Kubernetes clusters found'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/nodeEncryption.js b/plugins/google/kubernetes/nodeEncryption.js new file mode 100644 index 000000000..0418d01af --- /dev/null +++ b/plugins/google/kubernetes/nodeEncryption.js @@ -0,0 +1,126 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Node Encryption Enabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'High', + description: 'Ensure that GKE cluster nodes are encrypted using desired encryption protection level. ', + more_info: 'Using Customer Managed Keys (CMKs) gives you better control over the encryption/decryption process of your cluster nodes.', + link: 'https://cloud.google.com/security/encryption/default-encryption', + recommended_action: 'Ensure that all node pools in GKE clusters have the desired encryption level.', + apis: ['kubernetes:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + kubernetes_node_encryption_level: { + name: 'Kubernetes Node Encryption Protection Level', + description: 'Desired protection level for GKE cluster nodes. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.kubernetes_node_encryption_level || this.settings.kubernetes_node_encryption_level.default; + + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.kubernetes, function(region, rcb) { + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let nonEncryptedNodes = []; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + if (cluster.nodePools && + cluster.nodePools.length) { + cluster.nodePools.forEach(nodePool => { + let currentEncryptionLevel; + + if (nodePool.config && nodePool.config.bootDiskKmsKey && nodePool.config.bootDiskKmsKey.length && keysObj[nodePool.config.bootDiskKmsKey]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[nodePool.config.bootDiskKmsKey], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + if (currentEncryptionLevel < desiredEncryptionLevel) { + nonEncryptedNodes.push(nodePool.name); + } + }); + + if (nonEncryptedNodes.length) { + helpers.addResult(results, 2, + `These node pools do not have the desired encryption level: ${nonEncryptedNodes.join(', ')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'All node pools have the desired encryption level', region, resource); + } + + } else { + helpers.addResult(results, 0, 'No node pools found', region, resource); + } + + }); + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/kubernetes/nodeEncryption.spec.js b/plugins/google/kubernetes/nodeEncryption.spec.js new file mode 100644 index 000000000..02ff094f1 --- /dev/null +++ b/plugins/google/kubernetes/nodeEncryption.spec.js @@ -0,0 +1,178 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./nodeEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const clusters = [ + { + "name": "standard-cluster-2", + "nodePools": [ + { + "name": "default-pool", + "config": { + }, + "initialNodeCount": 3, + "locations": [ + "us-central1-a" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "currentNodeCount": 2, + "location": "us-central1-a" + }, + { + "name": "standard-cluster-1", + "nodePools": [ + { + "name": "pool-1", + "config": { + "bootDiskKmsKey": 'projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + }, + "initialNodeCount": 3, + "locations": [ + "us-central1-a" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "currentNodeCount": 2, + "location": "us-central1-a" + } +]; + +const createCache = (clustersList, clusterError, keysList, keysErr) => { + return { + kubernetes: { + list: { + 'global': { + err: clusterError, + data: clustersList + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + + +describe('nodeEncryption', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'], + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([],null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if node encryption level is equal to or greater than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('All node pools have the'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache([clusters[1]], null, cryptoKeys, null); + plugin.run(cache, {}, callback); + }); + + it('should give failing result if node encryption level is less than desired level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('These node pools do not have the '); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([clusters[0]], null, cryptoKeys, null); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if node encryption level key is not found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('These node pools do not have the'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache([clusters[0]], null, [], null); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/podSecurityPolicyEnabled.js b/plugins/google/kubernetes/podSecurityPolicyEnabled.js index ed7eb1a5e..8ecd2d1e8 100644 --- a/plugins/google/kubernetes/podSecurityPolicyEnabled.js +++ b/plugins/google/kubernetes/podSecurityPolicyEnabled.js @@ -4,41 +4,61 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Pod Security Policy Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures pod security policy is enabled for all Kubernetes clusters', more_info: 'Kubernetes pod security policy is a resource that controls security sensitive aspects of the pod configuration.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/pod-security-policies', recommended_action: 'Ensure that all Kubernetes clusters have pod security policy enabled.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, (region, rcb) => { var clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { helpers.addResult(results, 3, - 'Unable to query for clusters: ' + helpers.addError(clusters), region); + 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } if (!clusters.data.length) { - helpers.addResult(results, 0, 'No clusters found', region); + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.podSecurityPolicyConfig && cluster.podSecurityPolicyConfig.enabled) { - helpers.addResult(results, 0, 'Pod security policy config is enabled', region, cluster.name); + helpers.addResult(results, 0, 'Pod security policy config is enabled', region, resource); } else { - helpers.addResult(results, 2, 'Pod security policy config is disabled', region, cluster.name); + helpers.addResult(results, 2, 'Pod security policy config is disabled', region, resource); } }); rcb(); diff --git a/plugins/google/kubernetes/podSecurityPolicyEnabled.spec.js b/plugins/google/kubernetes/podSecurityPolicyEnabled.spec.js index da91eab6b..daf6c9ea9 100644 --- a/plugins/google/kubernetes/podSecurityPolicyEnabled.spec.js +++ b/plugins/google/kubernetes/podSecurityPolicyEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./podSecurityPolicyEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -21,7 +28,7 @@ describe('podSecurityPolicyEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(3); - expect(results[0].message).to.include('Unable to query for clusters'); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); expect(results[0].region).to.equal('global'); done() }; @@ -38,7 +45,7 @@ describe('podSecurityPolicyEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No clusters found'); + expect(results[0].message).to.include('No Kubernetes clusters found'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/privateClusterEnabled.js b/plugins/google/kubernetes/privateClusterEnabled.js index 72fc4b9b5..2d0df1f27 100644 --- a/plugins/google/kubernetes/privateClusterEnabled.js +++ b/plugins/google/kubernetes/privateClusterEnabled.js @@ -4,40 +4,62 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Private Cluster Enabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'High', description: 'Ensures private cluster is enabled for all Kubernetes clusters', more_info: 'Kubernetes private clusters only have internal ip ranges, which ensures that their workloads are isolated from the public internet.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters', recommended_action: 'Ensure that all Kubernetes clusters have private cluster enabled.', - apis: ['clusters:list'], + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], run: function(cache, settings, callback) { - var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, (region, rcb) => { + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, (region, rcb) => { var clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { helpers.addResult(results, 3, - 'Unable to query for clusters: ' + helpers.addError(clusters), region); + 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } if (!clusters.data.length) { - helpers.addResult(results, 0, 'No clusters found', region); + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); } clusters.data.forEach(cluster => { - if (cluster.privateCluster) { - helpers.addResult(results, 0, 'Private cluster is enabled on the Kubernetes cluster', region, cluster.name); + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + + if (cluster.privateClusterConfig && + cluster.privateClusterConfig.privateEndpoint && + cluster.privateClusterConfig.privateEndpoint.length) { + helpers.addResult(results, 0, 'Private cluster is enabled on the Kubernetes cluster', region, resource); } else { - helpers.addResult(results, 2, 'Private cluster is disabled on the Kubernetes cluster', region, cluster.name); + helpers.addResult(results, 2, 'Private cluster is disabled on the Kubernetes cluster', region, resource); } }); diff --git a/plugins/google/kubernetes/privateClusterEnabled.spec.js b/plugins/google/kubernetes/privateClusterEnabled.spec.js index 06178e2b6..4cd823a6c 100644 --- a/plugins/google/kubernetes/privateClusterEnabled.spec.js +++ b/plugins/google/kubernetes/privateClusterEnabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./privateClusterEnabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } @@ -21,7 +28,7 @@ describe('privateClusterEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(3); - expect(results[0].message).to.include('Unable to query for clusters'); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); expect(results[0].region).to.equal('global'); done() }; @@ -38,7 +45,7 @@ describe('privateClusterEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No clusters found'); + expect(results[0].message).to.include('No Kubernetes clusters found'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/google/kubernetes/privateEndpoint.js b/plugins/google/kubernetes/privateEndpoint.js index 5a8ee04a1..0928d2ea2 100644 --- a/plugins/google/kubernetes/privateEndpoint.js +++ b/plugins/google/kubernetes/privateEndpoint.js @@ -4,38 +4,59 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Private Endpoint', category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', description: 'Ensures the private endpoint setting is enabled for kubernetes clusters', more_info: 'kubernetes private endpoints can be used to route all traffic between the Kubernetes worker and control plane nodes over a private VPC endpoint rather than across the public internet.', link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters', recommended_action: 'Enable the private endpoint setting for all GKE clusters when creating the cluster.', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); - }; + } if (!clusters.data.length) { helpers.addResult(results, 0, 'No Kubernetes clusters found', region); return rcb(); - }; + } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.privateClusterConfig && cluster.privateClusterConfig.privateEndpoint) { - helpers.addResult(results, 0, 'Kubernetes cluster has private endpoint enabled', region, cluster.name); + helpers.addResult(results, 0, 'Kubernetes cluster has private endpoint enabled', region, resource); } else { - helpers.addResult(results, 2, 'Kubernetes cluster does not have private endpoint enabled', region, cluster.name); + helpers.addResult(results, 2, 'Kubernetes cluster does not have private endpoint enabled', region, resource); } }); @@ -45,4 +66,4 @@ module.exports = { callback(null, results, source); }); } -} +}; diff --git a/plugins/google/kubernetes/privateEndpoint.spec.js b/plugins/google/kubernetes/privateEndpoint.spec.js index 0802aa8f1..09ea91d26 100644 --- a/plugins/google/kubernetes/privateEndpoint.spec.js +++ b/plugins/google/kubernetes/privateEndpoint.spec.js @@ -4,11 +4,18 @@ var plugin = require('./privateEndpoint'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/kubernetes/secureBootEnabled.js b/plugins/google/kubernetes/secureBootEnabled.js new file mode 100644 index 000000000..6c45e3e74 --- /dev/null +++ b/plugins/google/kubernetes/secureBootEnabled.js @@ -0,0 +1,78 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Secure Boot Enabled', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures all Kubernetes cluster nodes have secure boot feature enabled.', + more_info: 'Secure Boot feature protects your cluster nodes from malware and makes sure the system runs only authentic software.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/shielded-gke-nodes#secure_boot', + recommended_action: 'Ensure that Secure Boot feature is enabled for all node pools in your GKE clusters.', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster','container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster','container.ClusterManager.UpdateNodePool', 'container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let disabledSecureBootNodes = []; + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.nodePools && + cluster.nodePools.length) { + cluster.nodePools.forEach(nodePool => { + if (!nodePool.config || !nodePool.config.shieldedInstanceConfig || !nodePool.config.shieldedInstanceConfig.enableSecureBoot) disabledSecureBootNodes.push(nodePool.name); + }); + if (disabledSecureBootNodes.length) { + helpers.addResult(results, 2, + `Secure Boot is disabled for these node pools: ${disabledSecureBootNodes.join(', ')}`, region, resource); + } else { + helpers.addResult(results, 0, + 'Secure Boot is enabled for all node pools', region, resource); + } + } else { + helpers.addResult(results, 0, 'No node pools found', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/secureBootEnabled.spec.js b/plugins/google/kubernetes/secureBootEnabled.spec.js new file mode 100644 index 000000000..961d1c08d --- /dev/null +++ b/plugins/google/kubernetes/secureBootEnabled.spec.js @@ -0,0 +1,157 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./secureBootEnabled'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('secureBootEnabled', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if secure boot is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Secure Boot is enabled for all node pools'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "shieldedInstanceConfig": { + "enableSecureBoot": true + } + }, + "initialNodeCount": 3, + "locations": [ + "us-central1-a" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-central1-a" + ], + "zone": "us-central1-a", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "instanceGroupUrls": [ + "https://www.googleapis.com/compute/v1/projects/frost-forest-281330/zones/us-central1-a/instanceGroupManagers/gke-standard-cluster-2-default-pool-941e601d-grp" + ], + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if secure boot is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Secure Boot is disabled for these node pools'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-1", + "nodePools": [ + { + "name": "default-pool", + "config": { + "machineType": "n1-standard-1", + "diskSizeGb": 100, + "shieldedInstanceConfig": {} + }, + "initialNodeCount": 3, + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "status": "RUNNING" + } + ], + "locations": [ + "us-east1-b", + "us-east1-c", + "us-east1-d" + ], + "zone": "us-east1", + "status": "RUNNING", + "currentNodeCount": 5, + "location": "us-east1" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/shieldedNodes.js b/plugins/google/kubernetes/shieldedNodes.js new file mode 100644 index 000000000..88db291d3 --- /dev/null +++ b/plugins/google/kubernetes/shieldedNodes.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Shielded Nodes', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that shielded nodes setting is enabled for all Kubernetes clusters.', + more_info: 'Shielded GKE nodes give strong cryptographic identity. This prevents attackers from being able to impersonate a node in your GKE cluster even if the attacker can extract the node credentials.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/shielded-gke-nodes', + recommended_action: 'Ensure that shielded nodes setting is enabled in your GKE cluster', + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster','container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster','container.ClusterManager.UpdateNodePool', 'container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster && cluster.shieldedNodes && cluster.shieldedNodes.enabled) { + helpers.addResult(results, 0, + 'Shielded Nodes feature is enabled for the cluster', region, resource); + } else { + helpers.addResult(results, 2, + 'Shielded Nodes feature is not enabled for the cluster', region, resource); + } + }); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/kubernetes/shieldedNodes.spec.js b/plugins/google/kubernetes/shieldedNodes.spec.js new file mode 100644 index 000000000..72d623f73 --- /dev/null +++ b/plugins/google/kubernetes/shieldedNodes.spec.js @@ -0,0 +1,142 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./shieldedNodes'); + +const createCache = (err, data) => { + return { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('shielded Nodes', function () { + describe('run', function () { + it('should give unknown result if a clusters error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Kubernetes clusters'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }) + it('should PASS if no clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Kubernetes clusters found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }) + it('should PASS if shielded nodes is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Shielded Nodes feature is enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "subnetwork": "default", + "locations": [ + "us-central1-a" + ], + "privateCluster": true, + "masterIpv4CidrBlock": "10.127.0.0/28", + "defaultMaxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "shieldedNodes": {"enabled": true}, + "tierSettings": { + "tier": "STANDARD" + }, + "zone": "us-central1-a", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + it('should FAIL if shielded nodes is disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Shielded Nodes feature is not enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "standard-cluster-2", + "subnetwork": "default", + "locations": [ + "us-central1-a" + ], + "privateCluster": true, + "masterIpv4CidrBlock": "10.127.0.0/28", + "defaultMaxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "shieldedNodes": {}, + "tierSettings": { + "tier": "STANDARD" + }, + "zone": "us-central1-a", + "status": "RUNNING", + "servicesIpv4Cidr": "10.70.0.0/20", + "currentNodeCount": 3, + "location": "us-central1-a" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/kubernetes/webDashboardDisabled.js b/plugins/google/kubernetes/webDashboardDisabled.js index 0f3fd36ff..d04bdcd95 100644 --- a/plugins/google/kubernetes/webDashboardDisabled.js +++ b/plugins/google/kubernetes/webDashboardDisabled.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Web Dashboard Disabled', category: 'Kubernetes', + domain: 'Containers', + severity: 'High', description: 'Ensures all Kubernetes clusters have the web dashboard disabled.', more_info: 'It is recommended to disable the web dashboard because it is backed by a highly privileged service account.', link: 'https://cloud.google.com/kubernetes-engine/docs/concepts/dashboards', recommended_action: 'Ensure that no Kubernetes clusters have the web dashboard enabled', - apis: ['clusters:list'], - + apis: ['kubernetes:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster','container.ClusterManager.DeleteCluster'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.clusters, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.kubernetes, function(region, rcb){ let clusters = helpers.addSource(cache, source, - ['clusters', 'list', region]); + ['kubernetes', 'list', region]); if (!clusters) return rcb(); if (clusters.err || !clusters.data) { - helpers.addResult(results, 3, 'Unable to query Kubernetes clusters: ' + helpers.addError(clusters), region); + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); return rcb(); } @@ -32,14 +46,21 @@ module.exports = { } clusters.data.forEach(cluster => { + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + if (cluster.addonsConfig && cluster.addonsConfig.kubernetesDashboard && cluster.addonsConfig.kubernetesDashboard.disabled) { helpers.addResult(results, 0, - 'The web dashboard is disabled for the Kubernetes cluster', region, cluster.name); + 'The web dashboard is disabled for the Kubernetes cluster', region, resource); } else { helpers.addResult(results, 2, - 'The web dashboard is enabled for the Kubernetes cluster', region, cluster.name); + 'The web dashboard is enabled for the Kubernetes cluster', region, resource); } }); diff --git a/plugins/google/kubernetes/webDashboardDisabled.spec.js b/plugins/google/kubernetes/webDashboardDisabled.spec.js index ac1b3c4ed..3d33e6ab2 100644 --- a/plugins/google/kubernetes/webDashboardDisabled.spec.js +++ b/plugins/google/kubernetes/webDashboardDisabled.spec.js @@ -4,11 +4,18 @@ var plugin = require('./webDashboardDisabled'); const createCache = (err, data) => { return { - clusters: { - list: { + kubernetes: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { 'global': { - err: err, - data: data + data: [ { name: 'testproj' }] } } } diff --git a/plugins/google/logging/auditConfigurationLogging.js b/plugins/google/logging/auditConfigurationLogging.js index 78f944695..35fe78c66 100644 --- a/plugins/google/logging/auditConfigurationLogging.js +++ b/plugins/google/logging/auditConfigurationLogging.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Audit Configuration Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for audit configuration changes.', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in audit configuration should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', @@ -15,6 +17,7 @@ module.exports = { pci: 'PCI has a strict requirement to log all account activity ' + 'within environments containing cardholder data.', }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric'], run: function(cache, settings, callback) { var results = []; @@ -32,7 +35,7 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } @@ -43,18 +46,17 @@ module.exports = { } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'; @@ -67,7 +69,7 @@ module.exports = { metricExists = true; metricName = metric.metricDescriptor.type; } else { - return + return; } } }); @@ -89,7 +91,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for audit configuration changes is enabled', region, alertPolicy.name); } } - }) + }); } }); diff --git a/plugins/google/logging/auditConfigurationLogging.spec.js b/plugins/google/logging/auditConfigurationLogging.spec.js index 7b2334f2e..565a5b0d3 100644 --- a/plugins/google/logging/auditConfigurationLogging.spec.js +++ b/plugins/google/logging/auditConfigurationLogging.spec.js @@ -30,7 +30,7 @@ describe('auditConfigurationLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -48,7 +48,7 @@ describe('auditConfigurationLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -118,11 +118,11 @@ describe('auditConfigurationLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -207,11 +207,11 @@ describe('auditConfigurationLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -242,4 +242,4 @@ describe('auditConfigurationLogging', function () { plugin.run(cache, {}, callback); }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/auditLoggingEnabled.js b/plugins/google/logging/auditLoggingEnabled.js index d497ef38b..9f633984a 100644 --- a/plugins/google/logging/auditLoggingEnabled.js +++ b/plugins/google/logging/auditLoggingEnabled.js @@ -4,16 +4,82 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Audit Logging Enabled', category: 'Logging', - description: 'Ensures that default audit logging is enabled on the project.', - more_info: 'The default audit logs should be configured to log all admin activities and write and read access to data for all services. In addition, no exempted members should be added to the logs to ensure proper delivery of all audit logs.', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures that default audit logging is enabled on the organization or project.', + more_info: 'The default audit logs should be configured to log all admin activities and write and read access to data for all services. In addition, no exempted members should be added to the logs to ensure proper delivery of all audit logs.', link: 'https://cloud.google.com/logging/docs/audit/', recommended_action: 'Ensure that the default audit logs are enabled to log all admin activities and write and read access to data for all services.', - apis: ['projects:getIamPolicy'], + apis: ['projects:getIamPolicy', 'organizations:list', 'organizations:getIamPolicy'], + settings: { + check_org_audit_logs: { + name: 'Check Org Audit Logs', + description: 'If set to true, check if audit logging is enabled on organization level. If enabled on organization level, ' + + 'return PASS result otherwise check for project audit logging', + regex: '^(true|false)$', + default: 'false' + } + }, + realtime_triggers: ['iam.IAMPolicy.SetIamPolicy', 'compute.projects.insert', 'compute.projects.delete, SetOrgPolicy'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + var config = { + check_org_audit_logs: settings.check_org_audit_logs || this.settings.check_org_audit_logs.default + }; + + config.check_org_audit_logs = (config.check_org_audit_logs == 'true'); + var enabledOnOrg = false; + + if (config.check_org_audit_logs) { + let getIamPolicy = helpers.addSource(cache, source, + ['organizations', 'getIamPolicy', 'global']); + + if (!getIamPolicy) return callback(null, results, source); + + if (getIamPolicy.err || !getIamPolicy.data) { + helpers.addResult(results, 3, 'Unable to query for IAM policies for org', 'global', null, null, getIamPolicy.err); + return callback(null, results, source); + } + + let iamPolicy = getIamPolicy.data[0]; + if (iamPolicy && + iamPolicy.auditConfigs) { + let auditConfig = iamPolicy.auditConfigs.find(auditConfig => (auditConfig.service && auditConfig.service === 'allServices')); + if (auditConfig && + auditConfig.auditLogConfigs && + auditConfig.auditLogConfigs.length) { + + let auditLogConfigs = auditConfig.auditLogConfigs.filter(auditLogConfig => { + return (['ADMIN_READ', 'DATA_READ', 'DATA_WRITE'].indexOf(auditLogConfig.logType) > - 1); + }); + + let exemptedMembers = auditConfig.auditLogConfigs.filter(auditLogConfig => { + return (auditLogConfig.exemptedMembers && auditLogConfig.exemptedMembers.length); + }); + + if (auditLogConfigs.length == 3 && !exemptedMembers.length) { + enabledOnOrg = true; + } + } + if (!enabledOnOrg) { + let loggingEnabledServices = iamPolicy.auditConfigs.filter(config => { + return ((config.auditLogConfigs.filter(auditLogConfig => { + return (['ADMIN_READ', 'DATA_READ', 'DATA_WRITE'].indexOf(auditLogConfig.logType) > - 1); + }).length) == 3); + }); + if (loggingEnabledServices && loggingEnabledServices.length >= 100) { + enabledOnOrg = true; + } + } + if (enabledOnOrg) { + helpers.addResult(results, 0, 'Audit logging is enabled on the organization', 'global'); + } + } + } + if (enabledOnOrg) return callback(null, results, source); async.each(regions.projects, function(region, rcb){ let iamPolicies = helpers.addSource(cache, source, @@ -22,44 +88,87 @@ module.exports = { if (!iamPolicies) return rcb(); if (iamPolicies.err || !iamPolicies.data) { - helpers.addResult(results, 3, 'Unable to query for IAM policies: ' + helpers.addError(iamPolicies), region); + helpers.addResult(results, 3, 'Unable to query for IAM policies', region, null, null, iamPolicies.err); return rcb(); } if (!iamPolicies.data.length) { - helpers.addResult(results, 0, 'No IAM policies found.', region); + helpers.addResult(results, 2, 'No IAM policies found.', region); return rcb(); } var iamPolicy = iamPolicies.data[0]; + var foundLoggingConfig = false; + let status, message; if (iamPolicy && iamPolicy.auditConfigs) { - iamPolicy.auditConfigs.forEach(auditConfig => { - if (foundLoggingConfig) return; - if (auditConfig.service && - auditConfig.service === "allServices" && - auditConfig.auditLogConfigs && - auditConfig.auditLogConfigs.length) { - - var auditLogConfigs = auditConfig.auditLogConfigs.filter(auditLogConfig => { - return (['ADMIN_READ', 'DATA_READ', 'DATA_WRITE'].indexOf(auditLogConfig.logType) > - 1); - }); + let auditConfig = iamPolicy.auditConfigs.find(auditConfig => (auditConfig.service && auditConfig.service === 'allServices')); + if (auditConfig && + auditConfig.auditLogConfigs && + auditConfig.auditLogConfigs.length) { + + var auditLogConfigs = auditConfig.auditLogConfigs.filter(auditLogConfig => { + return (['ADMIN_READ', 'DATA_READ', 'DATA_WRITE'].indexOf(auditLogConfig.logType) > - 1); + }); - var exemptedMembers = auditConfig.auditLogConfigs.filter(auditLogConfig => { - return (auditLogConfig.exemptedMembers && auditLogConfig.exemptedMembers.length); + var exemptedMembers = auditConfig.auditLogConfigs.filter(auditLogConfig => { + return (auditLogConfig.exemptedMembers && auditLogConfig.exemptedMembers.length); + }); + + foundLoggingConfig = true; + if (auditLogConfigs.length < 3) { + status = 2; + message = 'Audit logging is not properly configured on the project'; + } else if (exemptedMembers.length) { + status = 2; + message = 'Default audit configuration has exempted members'; + } else { + status = 0; + message = 'Audit logging is enabled on the project'; + } + } + + if (typeof status == 'undefined' || status > 0) { + let loggingEnabledServices = 0; + let projectExemptedMembers = false; + + if (iamPolicy.auditConfigs && iamPolicy.auditConfigs.length) { + loggingEnabledServices = iamPolicy.auditConfigs.filter(config => { + return ((config.auditLogConfigs.filter(auditLogConfig => { + return (['ADMIN_READ', 'DATA_READ', 'DATA_WRITE'].indexOf(auditLogConfig.logType) > - 1); + }).length) == 3); }); - foundLoggingConfig = true; - if (auditLogConfigs.length < 3) { - helpers.addResult(results, 2, 'Audit logging is not properly configured on the project', region); - } else if (exemptedMembers.length) { - helpers.addResult(results, 2, 'Default audit configuration has exempted members', region); - } else { - helpers.addResult(results, 0, 'Audit logging is enabled on the project', region); + for (let config of iamPolicy.auditConfigs) { + if (config.auditLogConfigs && config.auditLogConfigs.length) { + for (let auditLogConfig of config.auditLogConfigs) { + if (auditLogConfig.exemptedMembers && auditLogConfig.exemptedMembers.length) { + projectExemptedMembers = true; + break; + } + } + } + if (projectExemptedMembers) break; } } - }); + + if (loggingEnabledServices && loggingEnabledServices.length >= 100 && !projectExemptedMembers) { + status = 0; + message = 'Audit logging is enabled on the project'; + } else if (loggingEnabledServices && loggingEnabledServices.length && projectExemptedMembers) { + status = 2; + message = 'Audit logging has exempted members for some services in the project'; + } else if (loggingEnabledServices && loggingEnabledServices.length) { + status = 2; + message = 'Audit logging is not properly configured on the project'; + } else if (typeof status == 'undefined' && !message) { + status = 2; + message = 'Audit logging is not enabled on the project'; + } + } + foundLoggingConfig = true; + helpers.addResult(results, status, message, region); } if (!foundLoggingConfig) { helpers.addResult(results, 2, 'Audit logging is not enabled on the project', region); @@ -71,4 +180,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/google/logging/auditLoggingEnabled.spec.js b/plugins/google/logging/auditLoggingEnabled.spec.js index 706ebfc36..456fc090c 100644 --- a/plugins/google/logging/auditLoggingEnabled.spec.js +++ b/plugins/google/logging/auditLoggingEnabled.spec.js @@ -12,1333 +12,68 @@ const createCache = (err, data) => { } }, }, - } -}; - -describe('auditLoggingEnabled', function () { - describe('run', function () { - it('should give passing result if no iam policies are found', function (done) { - const callback = (err, results) => { - expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No IAM policies found'); - expect(results[0].region).to.equal('global'); - done() - }; - - const cache = createCache( - null, - [], - ); - - plugin.run(cache, {}, callback); - }); - - it('should give passing result audit logging is enabled', function (done) { - const callback = (err, results) => { - expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Audit logging is enabled on the project'); - expect(results[0].region).to.equal('global'); - done() - }; - - const cache = createCache( - null, - [ - { - "version": 1, - "etag": "BwWXpk/552U=", - "auditConfigs": [ - { - "service": "allServices", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ", - }, - { - "logType": "DATA_READ", - }, - { - "logType": "DATA_WRITE", - } - ] - }, - { - "service": "compute.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "accessapproval.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudasset.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudbilling.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudbuild.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "composer.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dlp.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dataproc.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "datastore.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudfunctions.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "healthcare.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "iap.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudiot.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudkms.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "ml.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "managedidentities.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "redis.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "pubsub.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudresourcemanager.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "runtimeconfig.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "sourcerepo.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "spanner.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudsql.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudtasks.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "tpu.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "translate.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "customerusagedataprocessing.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dialogflow.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "firebase.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "gcmcontextualcampaign-pa.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "genomics.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "appengine.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "deploymentmanager.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dns.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "storage.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "iam.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "identitytoolkit.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "container.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "vpcaccess.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "servicebroker.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "serviceusage.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "clouddebugger.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "clouderrorreporting.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "logging.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "monitoring.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudprofiler.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudtrace.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - } - ] - } - ] - ); - - plugin.run(cache, {}, callback); - }); - - it('should give failing result if audit logs are not properly configured', function (done) { - const callback = (err, results) => { - expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Audit logging is not properly configured on the project'); - expect(results[0].region).to.equal('global'); - done() - }; - - const cache = createCache( - null, - [ - { - "version": 1, - "etag": "BwWXpk/552U=", - "auditConfigs": [ - { - "service": "allServices", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ", - "exemptedMembers": [ - "serviceAccount:giovanni@right-weather-281330.iam.gserviceaccount.com" - ] - }, - { - "logType": "DATA_WRITE", - "exemptedMembers": [ - "serviceAccount:giovanni@right-weather-281330.iam.gserviceaccount.com" - ] - } - ] - }, - { - "service": "compute.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "accessapproval.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudasset.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudbilling.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudbuild.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "composer.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dlp.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dataproc.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "datastore.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudfunctions.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "healthcare.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "iap.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudiot.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudkms.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "ml.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "managedidentities.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "redis.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "pubsub.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudresourcemanager.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "runtimeconfig.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "sourcerepo.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "spanner.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudsql.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "cloudtasks.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "tpu.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "translate.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "customerusagedataprocessing.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dialogflow.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "firebase.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "gcmcontextualcampaign-pa.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "genomics.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "appengine.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "deploymentmanager.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "dns.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "storage.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "iam.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "identitytoolkit.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "container.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "vpcaccess.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, - { - "service": "servicebroker.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - }, + organizations: { + getIamPolicy: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('auditLoggingEnabled', function () { + describe('run', function () { + it('should give passing result if no iam policies are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No IAM policies found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result audit logging is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Audit logging is enabled on the project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXpk/552U=", + "auditConfigs": [ { - "service": "serviceusage.googleapis.com", + "service": "allServices", "auditLogConfigs": [ { - "logType": "ADMIN_READ" + "logType": "ADMIN_READ", }, { - "logType": "DATA_READ" + "logType": "DATA_READ", }, { - "logType": "DATA_WRITE" + "logType": "DATA_WRITE", } ] }, { - "service": "clouddebugger.googleapis.com", + "service": "compute.googleapis.com", "auditLogConfigs": [ { "logType": "ADMIN_READ" @@ -1352,7 +87,7 @@ describe('auditLoggingEnabled', function () { ] }, { - "service": "clouderrorreporting.googleapis.com", + "service": "accessapproval.googleapis.com", "auditLogConfigs": [ { "logType": "ADMIN_READ" @@ -1365,22 +100,46 @@ describe('auditLoggingEnabled', function () { } ] }, + ] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result audit logging is enabled on organization and check_org_audit_logs setting in true', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Audit logging is enabled on the organization'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXpk/552U=", + "auditConfigs": [ { - "service": "logging.googleapis.com", + "service": "allServices", "auditLogConfigs": [ { - "logType": "ADMIN_READ" + "logType": "ADMIN_READ", }, { - "logType": "DATA_READ" + "logType": "DATA_READ", }, { - "logType": "DATA_WRITE" + "logType": "DATA_WRITE", } ] }, { - "service": "monitoring.googleapis.com", + "service": "compute.googleapis.com", "auditLogConfigs": [ { "logType": "ADMIN_READ" @@ -1394,7 +153,7 @@ describe('auditLoggingEnabled', function () { ] }, { - "service": "cloudprofiler.googleapis.com", + "service": "accessapproval.googleapis.com", "auditLogConfigs": [ { "logType": "ADMIN_READ" @@ -1407,33 +166,19 @@ describe('auditLoggingEnabled', function () { } ] }, - { - "service": "cloudtrace.googleapis.com", - "auditLogConfigs": [ - { - "logType": "ADMIN_READ" - }, - { - "logType": "DATA_READ" - }, - { - "logType": "DATA_WRITE" - } - ] - } ] } ] ); - plugin.run(cache, {}, callback); + plugin.run(cache, { check_org_audit_logs: 'true' }, callback); }); - it('should give failing result if audit logging is not configured', function (done) { + it('should give failing result if audit logs are not properly configured', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Audit logging is not enabled on the project'); + expect(results[0].message).to.include('Audit logging is not properly configured on the project'); expect(results[0].region).to.equal('global'); done() }; @@ -1445,6 +190,17 @@ describe('auditLoggingEnabled', function () { "version": 1, "etag": "BwWXpk/552U=", "auditConfigs": [ + { + "service": "allServices", + "auditLogConfigs": [ + { + "logType": "ADMIN_READ" + }, + { + "logType": "DATA_WRITE" + } + ] + }, { "service": "compute.googleapis.com", "auditLogConfigs": [ @@ -2111,11 +867,34 @@ describe('auditLoggingEnabled', function () { plugin.run(cache, {}, callback); }); + it('should give failing result if audit logging is not configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Audit logging is not enabled on the project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "version": 1, + "etag": "BwWXpk/552U=", + "auditConfigs": [] + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if exempted members exist on a project', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Default audit configuration has exempted members'); + expect(results[0].message).to.include('Audit logging has exempted members for some services in the project'); expect(results[0].region).to.equal('global'); done() }; @@ -2817,4 +1596,4 @@ describe('auditLoggingEnabled', function () { }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/customRoleLogging.js b/plugins/google/logging/customRoleLogging.js index 0f4caee7f..fb3e5b3f1 100644 --- a/plugins/google/logging/customRoleLogging.js +++ b/plugins/google/logging/customRoleLogging.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Custom Role Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for custom role creation and changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in custom role should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', @@ -13,6 +15,7 @@ module.exports = { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric'], run: function(cache, settings, callback) { var results = []; @@ -30,29 +33,28 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = [ 'resource.type="iam_role" AND protoPayload.methodName="google.iam.admin.v1.CreateRole"', @@ -75,8 +77,6 @@ module.exports = { if (missingMetrics.length > 2) { return; - } else if (missingMetrics.length > 0) { - missingMetricStr = missingMetrics.join(', '); } else if (missingMetrics.length === 0) { metricExists = true; metricName = metric.metricDescriptor.type; @@ -101,7 +101,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for custom role changes is enabled', region, alertPolicy.name); } } - }) + }); } }); diff --git a/plugins/google/logging/customRoleLogging.spec.js b/plugins/google/logging/customRoleLogging.spec.js index 6a425c0d4..31ffe4f77 100644 --- a/plugins/google/logging/customRoleLogging.spec.js +++ b/plugins/google/logging/customRoleLogging.spec.js @@ -30,7 +30,7 @@ describe('customRoleLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -48,7 +48,7 @@ describe('customRoleLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -118,11 +118,11 @@ describe('customRoleLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -207,11 +207,11 @@ describe('customRoleLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -242,4 +242,4 @@ describe('customRoleLogging', function () { plugin.run(cache, {}, callback); }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/logSinksEnabled.js b/plugins/google/logging/logSinksEnabled.js index 82a832f2b..f70fa74d3 100644 --- a/plugins/google/logging/logSinksEnabled.js +++ b/plugins/google/logging/logSinksEnabled.js @@ -4,17 +4,31 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Log Sinks Enabled', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures a log sink is enabled to export all logs', more_info: 'Log sinks send log data to a storage service for archival and compliance. A log sink with no filter is necessary to ensure that all logs are being properly sent. If logs are sent to a storage bucket, the bucket must exist and bucket versioning should exist.', link: 'https://cloud.google.com/logging/docs/export/', recommended_action: 'Ensure a log sink is configured properly with an empty filter and a destination.', apis: ['sinks:list', 'buckets:list'], + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'logging.ConfigServiceV2.CreateSink' , 'logging.ConfigServiceV2.DeleteSink', 'logging.ConfigServiceV2.UpdateSink'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.sinks, function(region, rcb){ let sinks = helpers.addSource(cache, source, ['sinks', 'list', region]); @@ -22,28 +36,27 @@ module.exports = { if (!sinks) return rcb(); if (sinks.err || !sinks.data) { - helpers.addResult(results, 3, 'Unable to query sinks: ' + helpers.addError(sinks), region); + helpers.addResult(results, 3, 'Unable to query sinks: ' + helpers.addError(sinks), region, null, null, sinks.err); return rcb(); } if (!sinks.data.length) { - helpers.addResult(results, 0, 'No sinks found', region); + helpers.addResult(results, 2, 'No sinks found', region); return rcb(); } var noSinks = true; var bucketName =''; var sinkName = ''; + var sinkResource; sinks.data.forEach(sink => { - if ((!sink.filter || - (sink.filter && - sink.filter === '')) && - sink.destination) { + if ((!sink.filter || (sink.filter && sink.filter === '')) && sink.destination) { var destinationType = sink.destination.split('.')[0]; if (destinationType === 'storage') { bucketName = sink.destination.split('/')[1]; } noSinks = false; - sinkName = sink.name + sinkName = sink.name; + sinkResource = helpers.createResourceName('sinks', sinkName, project); } }); if (bucketName.length) { @@ -51,7 +64,7 @@ module.exports = { ['buckets', 'list', region]); if (!buckets || buckets.err || !buckets.data) { - helpers.addResult(results, 3, 'Unable to query buckets: ' + helpers.addError(buckets), region); + helpers.addResult(results, 3, 'Unable to query buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); } else if (!buckets.data.length) { helpers.addResult(results, 2, 'No log bucket found', region); rcb(); @@ -60,21 +73,22 @@ module.exports = { return bucket.name === bucketName; }); if (logBucket) { - helpers.addResult(results, 0, 'The log sink is properly configured', region, sinkName); + let bucketResource = helpers.createResourceName('b', logBucket.name); + helpers.addResult(results, 0, 'The log sink is properly configured', region, sinkResource); if (logBucket.versioning && logBucket.versioning.enabled) { - helpers.addResult(results, 0, 'Log bucket versioning is enabled', region, logBucket.name); + helpers.addResult(results, 0, 'Log bucket versioning is enabled', region, bucketResource); } else { - helpers.addResult(results, 2, 'Log bucket versioning is disabled', region, logBucket.name); + helpers.addResult(results, 2, 'Log bucket versioning is disabled', region, bucketResource); } } else { - helpers.addResult(results, 2, `The log bucket: ${bucketName} does not exist`, region, sinkName); + helpers.addResult(results, 2, `The log bucket: ${bucketName} does not exist`, region, sinkResource); } } } else if (noSinks) { helpers.addResult(results, 2, 'No log sinks are enabled', region); } else { - helpers.addResult(results, 0, 'The log sink is properly configured', region, sinkName); + helpers.addResult(results, 0, 'The log sink is properly configured', region, sinkResource); } rcb(); @@ -83,4 +97,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/google/logging/logSinksEnabled.spec.js b/plugins/google/logging/logSinksEnabled.spec.js index 1d048f57e..f4b69d0a2 100644 --- a/plugins/google/logging/logSinksEnabled.spec.js +++ b/plugins/google/logging/logSinksEnabled.spec.js @@ -19,8 +19,14 @@ const createCache = (err, data, adata) => { data: adata } } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' } ] + } + } } - } }; @@ -30,7 +36,7 @@ describe('logSinksEnabled', function () { it('should give passing result if no sinks are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No sinks found'); expect(results[0].region).to.equal('global'); done() @@ -587,4 +593,4 @@ describe('logSinksEnabled', function () { plugin.run(cache, {}, callback); }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/projectOwnershipLogging.js b/plugins/google/logging/projectOwnershipLogging.js index 276ecd4c2..836850a6c 100644 --- a/plugins/google/logging/projectOwnershipLogging.js +++ b/plugins/google/logging/projectOwnershipLogging.js @@ -4,10 +4,12 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Project Ownership Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for project ownership assignments and changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in project ownership should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for project ownership assignments and changes.', + recommended_action: 'Ensure that log metric and alert exist for project ownership assignments and changes.', apis: ['metrics:list', 'alertPolicies:list'], compliance: { pci: 'PCI requires tracking and monitoring of all access to environments ' + @@ -16,6 +18,7 @@ module.exports = { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric'], run: function(cache, settings, callback) { var results = []; @@ -33,29 +36,28 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = [ '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership', @@ -64,10 +66,11 @@ module.exports = { '(protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' ]; - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; - var checkMetrics = metric.filter.trim().split(' OR '); + if (metricExists) break; + var checkMetrics = metric.filter.trim().replace(/\r|\n/g, ''); var missingMetrics = []; testMetrics.forEach(testMetric => { @@ -76,19 +79,20 @@ module.exports = { } }); - if (missingMetrics.length > 2) { - return; - } else if (missingMetrics.length > 0) { - metricExists = true; - missingMetricStr = missingMetrics.join(', '); - } else if (missingMetrics.length === 0) { - metricExists = true; - metricName = metric.metricDescriptor.type; + if (missingMetrics.length === 0) { + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for project ownership changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -105,7 +109,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for project ownership changes is enabled', region, alertPolicy.name); } } - }) + }); } }); diff --git a/plugins/google/logging/projectOwnershipLogging.spec.js b/plugins/google/logging/projectOwnershipLogging.spec.js index 7b4e2b0d1..96525c3d2 100644 --- a/plugins/google/logging/projectOwnershipLogging.spec.js +++ b/plugins/google/logging/projectOwnershipLogging.spec.js @@ -30,7 +30,7 @@ describe('projectOwnershipLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -48,7 +48,7 @@ describe('projectOwnershipLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -63,6 +63,96 @@ describe('projectOwnershipLogging', function () { plugin.run(cache, {}, callback); }); + it('should give failing result if log metric for project ownership changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for project ownership changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "ProjectOwnershipAssignmentsChanges", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "(protoPayload.serviceName=\"cloudresourcemanager.googleapis.com\") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=\"REMOVE\" AND protoPayload.serviceData.policyDelta.bindingDeltas.role=\"roles/owner\") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=\"ADD\" AND protoPayload.serviceData.policyDelta.bindingDeltas.role=\"roles/owner\")", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/ProjectOwnershipAssignmentsChanges", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/ProjectOwnershipAssignmentsChanges" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/ProjectOwnershipAssignmentsChanges\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/ProjectOwnershipAssignmentsChanges", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ] + ); + + plugin.run(cache, {}, callback); + }); it('should give passing result if log alert for project ownership changes are enabled', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); @@ -118,11 +208,11 @@ describe('projectOwnershipLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -207,11 +297,11 @@ describe('projectOwnershipLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -242,4 +332,4 @@ describe('projectOwnershipLogging', function () { plugin.run(cache, {}, callback); }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/sqlConfigurationLogging.js b/plugins/google/logging/sqlConfigurationLogging.js index 53571b5e3..37dcb8ab8 100644 --- a/plugins/google/logging/sqlConfigurationLogging.js +++ b/plugins/google/logging/sqlConfigurationLogging.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'SQL Configuration Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for SQL configuration changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in SQL configurations should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for SQL configuration changes.', - apis: ['metrics:list', 'alertPolicies:list'], + recommended_action: 'Ensure that log metric and alert exist for SQL configuration changes.', + apis: ['metrics:list', 'alertPolicies:list', 'sql:list'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric', 'cloudsql.instances.delete','cloudsql.instances.create'], run: function(cache, settings, callback) { var results = []; @@ -20,6 +23,20 @@ module.exports = { var regions = helpers.regions(); async.each(regions.alertPolicies, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } var metrics = helpers.addSource(cache, source, ['metrics', 'list', region]); @@ -30,47 +47,50 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = 'protoPayload.methodName="cloudsql.instances.update"'; - - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; - - if (metric.filter.trim() === testMetrics) { - metricExists = true; - metricName = metric.metricDescriptor.type; - } else { - return + if (metricExists) break; + + if (metric.filter.trim().indexOf(testMetrics) > -1) { + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for SQL configuration changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -87,7 +107,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for SQL configuration changes is enabled', region, alertPolicy.name); } } - }) + }); } }); diff --git a/plugins/google/logging/sqlConfigurationLogging.spec.js b/plugins/google/logging/sqlConfigurationLogging.spec.js index 7b39476bc..4e5525adc 100644 --- a/plugins/google/logging/sqlConfigurationLogging.spec.js +++ b/plugins/google/logging/sqlConfigurationLogging.spec.js @@ -1,8 +1,7 @@ -var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./sqlConfigurationLogging'); -const createCache = (err, data, adata) => { +const createCache = (err, data, adata, sdata) => { return { metrics: { list: { @@ -19,7 +18,15 @@ const createCache = (err, data, adata) => { data: adata } } - } + }, + sql: { + list: { + 'global': { + err: err, + data: sdata + } + } + }, } }; @@ -30,7 +37,7 @@ describe('sqlConfigurationLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -39,7 +46,16 @@ describe('sqlConfigurationLogging', function () { const cache = createCache( null, [], - [] + [], + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } + ] ); plugin.run(cache, {}, callback); @@ -48,7 +64,7 @@ describe('sqlConfigurationLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -57,7 +73,16 @@ describe('sqlConfigurationLogging', function () { const cache = createCache( null, ['data'], - [] + [], + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } + ] ); plugin.run(cache, {}, callback); @@ -118,11 +143,11 @@ describe('sqlConfigurationLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -147,11 +172,121 @@ describe('sqlConfigurationLogging', function () { ], "enabled": true } + ], + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } ] ); plugin.run(cache, {}, callback); }); + + it('should give failing result if log metric for SQL configuration changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for SQL configuration changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "sqlConfigLogging", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "protoPayload.methodName=\"cloudsql.instances.update\"", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/sqlConfigLogging", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/sqlConfigLogging" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/sqlConfigLogging\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/sqlConfigLogging", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ], + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if log alert for sql configuration changes are not enabled', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); @@ -207,11 +342,11 @@ describe('sqlConfigurationLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -236,10 +371,35 @@ describe('sqlConfigurationLogging', function () { ], "enabled": true } + ], + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } ] ); plugin.run(cache, {}, callback); - }) + }); + + it('should give passing result if no sql instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null,[],[],[] + ); + + plugin.run(cache, {}, callback); + }); }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/storagePermissionsLogging.js b/plugins/google/logging/storagePermissionsLogging.js index 3bc3722c3..4cc3cc525 100644 --- a/plugins/google/logging/storagePermissionsLogging.js +++ b/plugins/google/logging/storagePermissionsLogging.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Storage Permissions Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for storage permission changes', more_info: 'Storage permissions include access to the buckets that store the logs, any changes in storage permissions should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for storage permission changes.', - apis: ['metrics:list', 'alertPolicies:list'], + recommended_action: 'Ensure that log metric and alert for storage permission changes.', + apis: ['metrics:list', 'alertPolicies:list', 'buckets:list'], compliance: { pci: 'PCI requires tracking and monitoring of all access to environments ' + 'in which cardholder data is present. Storage permissions logging ' + @@ -17,6 +19,7 @@ module.exports = { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric', 'storage.buckets.create', 'storage.buckets.delete'], run: function(cache, settings, callback) { var results = []; @@ -24,6 +27,21 @@ module.exports = { var regions = helpers.regions(); async.each(regions.alertPolicies, function(region, rcb){ + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); + return rcb(); + } + + if (!helpers.hasBuckets(buckets.data)) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + var metrics = helpers.addSource(cache, source, ['metrics', 'list', region]); @@ -34,47 +52,50 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = 'resource.type=gcs_bucket AND protoPayload.methodName="storage.setIamPermissions"'; - - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; + if (metricExists) break; if (metric.filter.trim() === testMetrics) { - metricExists = true; - metricName = metric.metricDescriptor.type; - } else { - return + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for storage permission changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -91,7 +112,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for storage permission changes is enabled', region, alertPolicy.name); } } - }) + }); } }); diff --git a/plugins/google/logging/storagePermissionsLogging.spec.js b/plugins/google/logging/storagePermissionsLogging.spec.js index cb81d9252..4b05472c5 100644 --- a/plugins/google/logging/storagePermissionsLogging.spec.js +++ b/plugins/google/logging/storagePermissionsLogging.spec.js @@ -2,7 +2,7 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./storagePermissionsLogging'); -const createCache = (err, data, adata) => { +const createCache = (err, data, adata, sdata) => { return { metrics: { list: { @@ -19,6 +19,14 @@ const createCache = (err, data, adata) => { data: adata } } + }, + buckets: { + list: { + 'global': { + err: err, + data: sdata + } + } } } @@ -30,7 +38,7 @@ describe('storagePermissionsLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -39,7 +47,39 @@ describe('storagePermissionsLogging', function () { const cache = createCache( null, [], - [] + [], + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + ], ); plugin.run(cache, {}, callback); @@ -48,7 +88,7 @@ describe('storagePermissionsLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -57,7 +97,40 @@ describe('storagePermissionsLogging', function () { const cache = createCache( null, ['data'], - [] + [], + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + + ], ); plugin.run(cache, {}, callback); @@ -118,11 +191,11 @@ describe('storagePermissionsLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -147,7 +220,163 @@ describe('storagePermissionsLogging', function () { ], "enabled": true } - ] + ], + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + + ], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if log metric for storage permission changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for storage permission changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "storagePermissionsLogging", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "resource.type=gcs_bucket AND protoPayload.methodName=\"storage.setIamPermissions\"", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/storagePermissionsLogging", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/storagePermissionsLogging" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/storagePermissionsLogging\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/storagePermissionsLogging", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ], + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + + ], ); plugin.run(cache, {}, callback); @@ -207,11 +436,11 @@ describe('storagePermissionsLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -236,10 +465,60 @@ describe('storagePermissionsLogging', function () { ], "enabled": true } + ], + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + ] ); plugin.run(cache, {}, callback); - }) + }); + + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + [], + [] + ); + plugin.run(cache, {}, callback); + }); }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/vpcFirewallRuleLogging.js b/plugins/google/logging/vpcFirewallRuleLogging.js index fda344ddd..63b3af88d 100644 --- a/plugins/google/logging/vpcFirewallRuleLogging.js +++ b/plugins/google/logging/vpcFirewallRuleLogging.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'VPC Firewall Rule Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for firewall rule changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in firewall rule should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for firewall rule changes.', - apis: ['metrics:list', 'alertPolicies:list'], + recommended_action: 'Ensure that log metric and alert exist for firewall rule changes.', + apis: ['metrics:list', 'alertPolicies:list', 'firewalls:list'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric', 'compute.firewalls.insert', 'compute.firewalls.delete' ], run: function(cache, settings, callback) { var results = []; @@ -20,6 +23,20 @@ module.exports = { var regions = helpers.regions(); async.each(regions.alertPolicies, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } var metrics = helpers.addSource(cache, source, ['metrics', 'list', region]); @@ -30,59 +47,60 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = [ - 'resource.type="gce_firewall_rule" AND jsonPayload.event_subtype="compute.firewalls.patch"', - 'jsonPayload.event_subtype="compute.firewalls.insert"' + 'resource.type="gce_firewall_rule" AND protoPayload.methodName="v1.compute.firewalls.patch"', + 'protoPayload.methodName="v1.compute.firewalls.insert"' ]; - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; - var checkMetrics = metric.filter.trim().split(' OR '); + if (metricExists) continue; + var checkMetrics = metric.filter.trim().replace(/\r|\n/g, ''); var missingMetrics = []; - testMetrics.forEach(testMetric => { if (checkMetrics.indexOf(testMetric) === -1) { missingMetrics.push(testMetric); } }); - if (missingMetrics.length > 2) { - return; - } else if (missingMetrics.length > 0) { - missingMetricStr = missingMetrics.join(', '); - } else if (missingMetrics.length === 0) { - metricExists = true; - metricName = metric.metricDescriptor.type; + if (missingMetrics.length === 0) { + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for firewall rule changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -99,7 +117,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for firewall rule changes is enabled', region, alertPolicy.name); } } - }) + }); } }); @@ -116,8 +134,4 @@ module.exports = { callback(null, results, source); }); } -}; - - - - +}; \ No newline at end of file diff --git a/plugins/google/logging/vpcFirewallRuleLogging.spec.js b/plugins/google/logging/vpcFirewallRuleLogging.spec.js index 852821017..9c823c1ec 100644 --- a/plugins/google/logging/vpcFirewallRuleLogging.spec.js +++ b/plugins/google/logging/vpcFirewallRuleLogging.spec.js @@ -2,7 +2,7 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./vpcFirewallRuleLogging'); -const createCache = (err, data, adata) => { +const createCache = (err, data, adata, firewalldata) => { return { metrics: { list: { @@ -19,6 +19,14 @@ const createCache = (err, data, adata) => { data: adata } } + }, + firewalls: { + list: { + 'global': { + err: err, + data: firewalldata + } + } } } @@ -30,7 +38,7 @@ describe('vpcFirewallRuleLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -39,7 +47,22 @@ describe('vpcFirewallRuleLogging', function () { const cache = createCache( null, [], - [] + [], + [ + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + } + ] ); plugin.run(cache, {}, callback); @@ -48,7 +71,7 @@ describe('vpcFirewallRuleLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -57,7 +80,20 @@ describe('vpcFirewallRuleLogging', function () { const cache = createCache( null, ['data'], - [] + [], + [ { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + }] ); plugin.run(cache, {}, callback); @@ -78,7 +114,7 @@ describe('vpcFirewallRuleLogging', function () { { "name": "firewallRuleLogging", "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", - "filter": "resource.type=\"gce_firewall_rule\" AND jsonPayload.event_subtype=\"compute.firewalls.patch\" OR jsonPayload.event_subtype=\"compute.firewalls.insert\"", + "filter": "resource.type=\"gce_firewall_rule\" AND protoPayload.methodName=\"v1.compute.firewalls.patch\" OR protoPayload.methodName=\"v1.compute.firewalls.insert\"", "metricDescriptor": { "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/firewallRuleLogging", "metricKind": "DELTA", @@ -118,11 +154,11 @@ describe('vpcFirewallRuleLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -147,6 +183,126 @@ describe('vpcFirewallRuleLogging', function () { ], "enabled": true } + ], + [ + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if log metric for firewall rule changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for firewall rule changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "firewallRuleLogging", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "resource.type=\"gce_firewall_rule\" AND protoPayload.methodName=\"v1.compute.firewalls.patch\" OR protoPayload.methodName=\"v1.compute.firewalls.insert\"", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/firewallRuleLogging", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/firewallRuleLogging" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/firewallRuleLogging\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/firewallRuleLogging", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ], + [ + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + } ] ); @@ -207,11 +363,11 @@ describe('vpcFirewallRuleLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -236,10 +392,40 @@ describe('vpcFirewallRuleLogging', function () { ], "enabled": true } + ], + [ + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + } ] ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no firewall rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No firewall rules found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, [], [], [] + ); + plugin.run(cache, {}, callback); }) }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/vpcNetworkLogging.js b/plugins/google/logging/vpcNetworkLogging.js index 7b5a78add..e35d19303 100644 --- a/plugins/google/logging/vpcNetworkLogging.js +++ b/plugins/google/logging/vpcNetworkLogging.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'VPC Network Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for VPC network changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in VPC network should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for VPC network changes.', - apis: ['metrics:list', 'alertPolicies:list'], + recommended_action: 'Ensure that log metric and alert exist for VPC network changes.', + apis: ['metrics:list', 'alertPolicies:list', 'networks:list'], compliance: { pci: 'PCI requires tracking and monitoring of all access to environments ' + 'in which cardholder data is present. VPC network logging ' + @@ -16,6 +18,7 @@ module.exports = { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric', 'compute.networks.insert', 'compute.networks.delete' ], run: function(cache, settings, callback) { var results = []; @@ -23,6 +26,20 @@ module.exports = { var regions = helpers.regions(); async.each(regions.alertPolicies, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query VPC networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No VPC networks found', region); + return rcb(); + } var metrics = helpers.addSource(cache, source, ['metrics', 'list', region]); @@ -33,42 +50,43 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = [ - 'resource.type=gce_network AND jsonPayload.event_subtype="compute.networks.insert"', - 'jsonPayload.event_subtype="compute.networks.patch"', - 'jsonPayload.event_subtype="compute.networks.delete"', - 'jsonPayload.event_subtype="compute.networks.removePeering"', - 'jsonPayload.event_subtype="compute.networks.addPeering"' + 'resource.type=gce_network AND protoPayload.methodName="beta.compute.networks.insert"', + 'protoPayload.methodName="beta.compute.networks.patch"', + 'protoPayload.methodName="v1.compute.networks.delete"', + 'protoPayload.methodName="v1.compute.networks.removePeering"', + 'protoPayload.methodName="v1.compute.networks.addPeering"' + ]; - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; - var checkMetrics = metric.filter.trim().split(' OR '); + if (metricExists) continue; + var checkMetrics = metric.filter.trim().replace(/\r|\n/g, ''); var missingMetrics = []; testMetrics.forEach(testMetric => { @@ -77,19 +95,20 @@ module.exports = { } }); - if (missingMetrics.length > 2) { - return; - } else if (missingMetrics.length > 0) { - metricExists = true; - missingMetricStr = missingMetrics.join(', '); - } else if (missingMetrics.length === 0) { - metricExists = true; - metricName = metric.metricDescriptor.type; + if (missingMetrics.length === 0) { + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for VPC network changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -106,7 +125,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for VPC network changes is enabled', region, alertPolicy.name); } } - }) + }); } }); @@ -123,8 +142,4 @@ module.exports = { callback(null, results, source); }); } -}; - - - - +}; \ No newline at end of file diff --git a/plugins/google/logging/vpcNetworkLogging.spec.js b/plugins/google/logging/vpcNetworkLogging.spec.js index b26091aac..bf4687548 100644 --- a/plugins/google/logging/vpcNetworkLogging.spec.js +++ b/plugins/google/logging/vpcNetworkLogging.spec.js @@ -2,7 +2,7 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./vpcNetworkLogging'); -const createCache = (err, data, adata) => { +const createCache = (err, data, adata, ndata) => { return { metrics: { list: { @@ -19,6 +19,14 @@ const createCache = (err, data, adata) => { data: adata } } + }, + networks: { + list: { + 'global': { + err: err, + data: ndata + } + } } } @@ -30,7 +38,7 @@ describe('vpcNetworkLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -39,7 +47,24 @@ describe('vpcNetworkLogging', function () { const cache = createCache( null, [], - [] + [], + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], ); plugin.run(cache, {}, callback); @@ -48,7 +73,7 @@ describe('vpcNetworkLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -57,7 +82,24 @@ describe('vpcNetworkLogging', function () { const cache = createCache( null, ['data'], - [] + [], + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], ); plugin.run(cache, {}, callback); @@ -78,7 +120,7 @@ describe('vpcNetworkLogging', function () { { "name": "vpcNetworkLogging", "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", - "filter": "resource.type=gce_network AND jsonPayload.event_subtype=\"compute.networks.insert\" OR jsonPayload.event_subtype=\"compute.networks.patch\" OR jsonPayload.event_subtype=\"compute.networks.delete\" OR jsonPayload.event_subtype=\"compute.networks.removePeering\" OR jsonPayload.event_subtype=\"compute.networks.addPeering\"", + "filter": "resource.type=gce_network AND protoPayload.methodName=\"beta.compute.networks.insert\" OR protoPayload.methodName=\"beta.compute.networks.patch\" OR protoPayload.methodName=\"v1.compute.networks.delete\" OR protoPayload.methodName=\"v1.compute.networks.removePeering\" OR protoPayload.methodName=\"v1.compute.networks.addPeering\"", "metricDescriptor": { "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/vpcNetworkLogging", "metricKind": "DELTA", @@ -118,11 +160,11 @@ describe('vpcNetworkLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -147,7 +189,131 @@ describe('vpcNetworkLogging', function () { ], "enabled": true } - ] + ], + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if log metric for VPC network changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for VPC network changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "vpcNetworkLogging", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "resource.type=gce_network AND protoPayload.methodName=\"beta.compute.networks.insert\" OR protoPayload.methodName=\"beta.compute.networks.patch\" OR protoPayload.methodName=\"v1.compute.networks.delete\" OR protoPayload.methodName=\"v1.compute.networks.removePeering\" OR protoPayload.methodName=\"v1.compute.networks.addPeering\"", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/vpcNetworkLogging", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/vpcNetworkLogging" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/vpcNetworkLogging\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/vpcNetworkLogging", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ], + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], ); plugin.run(cache, {}, callback); @@ -207,11 +373,11 @@ describe('vpcNetworkLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -236,10 +402,44 @@ describe('vpcNetworkLogging', function () { ], "enabled": true } + ], + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } ] ); plugin.run(cache, {}, callback); - }) + }); + + it('should give passing result if no network records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPC networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + [], + [] + ); + plugin.run(cache, {}, callback); + }); }) -}); \ No newline at end of file +}); diff --git a/plugins/google/logging/vpcNetworkRouteLogging.js b/plugins/google/logging/vpcNetworkRouteLogging.js index 6500baee7..39b995296 100644 --- a/plugins/google/logging/vpcNetworkRouteLogging.js +++ b/plugins/google/logging/vpcNetworkRouteLogging.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'VPC Network Route Logging', category: 'Logging', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that logging and log alerts exist for VPC network route changes', more_info: 'Project Ownership is the highest level of privilege on a project, any changes in VPC network route should be heavily monitored to prevent unauthorized changes.', link: 'https://cloud.google.com/logging/docs/logs-based-metrics/', - recommended_action: 'Ensure that log alerts exist for VPC network route changes.', - apis: ['metrics:list', 'alertPolicies:list'], + recommended_action: 'Ensure that log metric and alert exist for VPC network route changes.', + apis: ['metrics:list', 'alertPolicies:list', 'networkRoutes:list'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['logging.MetricsServiceV2.CreateLogMetric', 'logging.MetricsServiceV2.DeleteLogMetric', 'compute.routes.insert', 'compute.routes.delete' ], run: function(cache, settings, callback) { var results = []; @@ -20,6 +23,21 @@ module.exports = { var regions = helpers.regions(); async.each(regions.alertPolicies, function(region, rcb){ + let routes = helpers.addSource( + cache, source, ['networkRoutes', 'list', region]); + + if (!routes) return rcb(); + + if (routes.err || !routes.data) { + helpers.addResult(results, 3, 'Unable to query VPC network routes', region, null, null, routes.err); + return rcb(); + } + + if (!routes.data.length) { + helpers.addResult(results, 0, 'No VPC network routes found', region); + return rcb(); + } + var metrics = helpers.addSource(cache, source, ['metrics', 'list', region]); @@ -30,39 +48,39 @@ module.exports = { if ((metrics.err && metrics.err.length > 0) || !metrics.data) { helpers.addResult(results, 3, - 'Unable to query for log metrics: ' + helpers.addError(metrics), region); + 'Unable to query for log metrics: ' + helpers.addError(metrics), region, null, null, metrics.err); return rcb(); } if ((alertPolicies.err && alertPolicies.err.length > 0) || !alertPolicies.data ) { helpers.addResult(results, 3, - 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region); + 'Unable to query for log alert policies: ' + helpers.addError(alertPolicies), region, null, null, alertPolicies.err); return rcb(); } if (!metrics.data.length > 0) { - helpers.addResult(results, 0, 'No log metrics found', region); + helpers.addResult(results, 2, 'No log metrics found', region); return rcb(); } if (!alertPolicies.data.length > 0) { - helpers.addResult(results, 0, 'No log alert policies found', region); + helpers.addResult(results, 2, 'No log alert policies found', region); return rcb(); } var metricExists = false; var metricName = ''; - var missingMetricStr; var testMetrics = [ - 'resource.type="gce_route" AND jsonPayload.event_subtype="compute.routes.delete"', - 'jsonPayload.event_subtype="compute.routes.insert"' + 'resource.type="gce_route" AND protoPayload.methodName="beta.compute.routes.patch"', + 'protoPayload.methodName="beta.compute.routes.insert"' ]; - metrics.data.forEach(metric => { + let disabled = false; + for (let metric of metrics.data) { if (metric.filter) { - if (metricExists) return; - var checkMetrics = metric.filter.trim().split(' OR '); + if (metricExists) continue; + var checkMetrics = metric.filter.trim().replace(/\r|\n/g, ''); var missingMetrics = []; testMetrics.forEach(testMetric => { @@ -71,19 +89,20 @@ module.exports = { } }); - if (missingMetrics.length > 2) { - return; - } else if (missingMetrics.length > 0) { - metricExists = true; - missingMetricStr = missingMetrics.join(', '); - } else if (missingMetrics.length === 0) { - metricExists = true; - metricName = metric.metricDescriptor.type; + if (missingMetrics.length === 0) { + if (metric.disabled) disabled = true; + else { + disabled = false; + metricExists = true; + metricName = metric.metricDescriptor.type; + } } } - }); + } - if (metricExists && metricName.length) { + if (disabled) { + helpers.addResult(results, 2, 'Log metric for VPC network route changes is disbled', region); + } else if (metricExists && metricName.length) { var conditionFound = false; alertPolicies.data.forEach(alertPolicy => { @@ -100,7 +119,7 @@ module.exports = { helpers.addResult(results, 0, 'Log alert for VPC network route changes is enabled', region, alertPolicy.name); } } - }) + }); } }); @@ -117,8 +136,4 @@ module.exports = { callback(null, results, source); }); } -}; - - - - +}; \ No newline at end of file diff --git a/plugins/google/logging/vpcNetworkRouteLogging.spec.js b/plugins/google/logging/vpcNetworkRouteLogging.spec.js index 258fc08a9..b2f7aee41 100644 --- a/plugins/google/logging/vpcNetworkRouteLogging.spec.js +++ b/plugins/google/logging/vpcNetworkRouteLogging.spec.js @@ -2,7 +2,7 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./vpcNetworkRouteLogging'); -const createCache = (err, data, adata) => { +const createCache = (err, data, adata, rdata) => { return { metrics: { list: { @@ -19,6 +19,14 @@ const createCache = (err, data, adata) => { data: adata } } + }, + networkRoutes: { + list: { + 'global': { + err: err, + data: rdata + } + } } } @@ -30,7 +38,7 @@ describe('vpcNetworkRouteLogging', function () { it('should give passing result if no metrics are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log metrics found'); expect(results[0].region).to.equal('global'); done() @@ -39,7 +47,21 @@ describe('vpcNetworkRouteLogging', function () { const cache = createCache( null, [], - [] + [], + [ + { + kind: 'compute#route', + id: '4674268836178834750', + creationTimestamp: '2023-01-02T17:58:41.659-08:00', + name: 'default-route-7e3066edbf13bdba', + description: 'Default local route to the subnetwork 10.182.0.0/20.', + network: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + destRange: '10.182.0.0/20', + priority: 0, + nextHopNetwork: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + selfLink: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/routes/default-route-7e3066edbf13bdba' + } + ] ); plugin.run(cache, {}, callback); @@ -48,7 +70,7 @@ describe('vpcNetworkRouteLogging', function () { it('should give passing result if no alert policies are found', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); - expect(results[0].status).to.equal(0); + expect(results[0].status).to.equal(2); expect(results[0].message).to.include('No log alert policies found'); expect(results[0].region).to.equal('global'); done() @@ -57,7 +79,21 @@ describe('vpcNetworkRouteLogging', function () { const cache = createCache( null, ['data'], - [] + [], + [ + { + kind: 'compute#route', + id: '4674268836178834750', + creationTimestamp: '2023-01-02T17:58:41.659-08:00', + name: 'default-route-7e3066edbf13bdba', + description: 'Default local route to the subnetwork 10.182.0.0/20.', + network: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + destRange: '10.182.0.0/20', + priority: 0, + nextHopNetwork: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + selfLink: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/routes/default-route-7e3066edbf13bdba' + } + ] ); plugin.run(cache, {}, callback); @@ -78,7 +114,7 @@ describe('vpcNetworkRouteLogging', function () { { "name": "vpcNetworkRouteLogging", "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", - "filter": "resource.type=\"gce_route\" AND jsonPayload.event_subtype=\"compute.routes.delete\" OR jsonPayload.event_subtype=\"compute.routes.insert\"", + "filter": "resource.type=\"gce_route\" AND protoPayload.methodName=\"beta.compute.routes.patch\" OR protoPayload.methodName=\"beta.compute.routes.insert\"", "metricDescriptor": { "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/vpcNetworkRouteLogging", "metricKind": "DELTA", @@ -118,11 +154,115 @@ describe('vpcNetworkRouteLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" + }, + "mutationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" + }, + "conditions": [ + { + "conditionThreshold": { + "filter": "metric.type=\"logging.googleapis.com/user/vpcNetworkRouteLogging\" resource.type=\"metric\"", + "comparison": "COMPARISON_GT", + "thresholdValue": 0.001, + "duration": "60s", + "trigger": { + "count": 1 + }, + "aggregations": [ + { + "alignmentPeriod": "60s", + "perSeriesAligner": "ALIGN_RATE" + } + ] + }, + "displayName": "logging/user/vpcNetworkRouteLogging", + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965/conditions/16634295467069924590" + } + ], + "enabled": true + } + ], + [ + { + kind: 'compute#route', + id: '4674268836178834750', + creationTimestamp: '2023-01-02T17:58:41.659-08:00', + name: 'default-route-7e3066edbf13bdba', + description: 'Default local route to the subnetwork 10.182.0.0/20.', + network: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + destRange: '10.182.0.0/20', + priority: 0, + nextHopNetwork: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + selfLink: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/routes/default-route-7e3066edbf13bdba' + } + ] + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if log metric for VPC network route changes is disbled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Log metric for VPC network route changes is disbled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "name": "vpcNetworkRouteLogging", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "filter": "resource.type=\"gce_route\" AND protoPayload.methodName=\"beta.compute.routes.patch\" OR protoPayload.methodName=\"beta.compute.routes.insert\"", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/vpcNetworkRouteLogging", + "metricKind": "DELTA", + "valueType": "INT64", + "unit": "1", + "description": "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + "type": "logging.googleapis.com/user/vpcNetworkRouteLogging" + }, + "createTime": "2019-11-07T02:11:39.940887528Z", + "updateTime": "2019-11-07T19:19:18.101740507Z", + "disabled": true + }, + { + "name": "test1", + "filter": "resource.type=\"audited_resource\"\n", + "metricDescriptor": { + "name": "projects/rosy-red-12345/metricDescriptors/logging.googleapis.com/user/test1", + "metricKind": "DELTA", + "valueType": "DISTRIBUTION", + "type": "logging.googleapis.com/user/test1" + }, + "valueExtractor": "EXTRACT(protoPayload.authorizationInfo.permission)", + "bucketOptions": { + "exponentialBuckets": { + "numFiniteBuckets": 64, + "growthFactor": 2, + "scale": 0.01 + } + }, + "createTime": "2019-11-07T01:58:47.997858699Z", + "updateTime": "2019-11-07T01:58:47.997858699Z" + } + ], + [ + { + "name": "projects/rosy-red-12345/alertPolicies/16634295467069924965", + "displayName": "Threshold = user/", + "combiner": "OR", + "creationRecord": { + "mutateTime": "2019-11-07T19:07:11.377731588Z", + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -147,6 +287,20 @@ describe('vpcNetworkRouteLogging', function () { ], "enabled": true } + ], + [ + { + kind: 'compute#route', + id: '4674268836178834750', + creationTimestamp: '2023-01-02T17:58:41.659-08:00', + name: 'default-route-7e3066edbf13bdba', + description: 'Default local route to the subnetwork 10.182.0.0/20.', + network: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + destRange: '10.182.0.0/20', + priority: 0, + nextHopNetwork: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + selfLink: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/routes/default-route-7e3066edbf13bdba' + } ] ); @@ -207,11 +361,11 @@ describe('vpcNetworkRouteLogging', function () { "combiner": "OR", "creationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "mutationRecord": { "mutateTime": "2019-11-07T19:07:11.377731588Z", - "mutatedBy": "giovanni@cloudsploit.com" + "mutatedBy": "giovanni@cloudexploit.com" }, "conditions": [ { @@ -236,10 +390,41 @@ describe('vpcNetworkRouteLogging', function () { ], "enabled": true } + ], + [ + { + kind: 'compute#route', + id: '4674268836178834750', + creationTimestamp: '2023-01-02T17:58:41.659-08:00', + name: 'default-route-7e3066edbf13bdba', + description: 'Default local route to the subnetwork 10.182.0.0/20.', + network: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + destRange: '10.182.0.0/20', + priority: 0, + nextHopNetwork: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/networks/my-net', + selfLink: 'https://www.googleapis.com/compute/v1/projects/akhtar-dev-khulnasoft/global/routes/default-route-7e3066edbf13bdba' + } ] ); plugin.run(cache, {}, callback); - }) + }); + + it('should give passing result if no network routes are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPC network routes found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + [], + [] + ); + plugin.run(cache, {}, callback); + }); }) -}); \ No newline at end of file +}); diff --git a/plugins/google/pubsub/deadLetteringEnabled.js b/plugins/google/pubsub/deadLetteringEnabled.js new file mode 100644 index 000000000..2428bde8e --- /dev/null +++ b/plugins/google/pubsub/deadLetteringEnabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Dead Lettering Enabled', + category: 'Pub/Sub', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that each Google Pub/Sub subscription is configured to use dead-letter topic.', + more_info: 'Enabling dead lettering will handle message failures by forwarding undelivered messages to a dead-letter topic that stores the message for later access.', + link: 'https://cloud.google.com/pubsub/docs/dead-letter-topics', + recommended_action: 'Ensure that dead letter topics are configured for all your Google Cloud Pub/Sub subscriptions.', + apis: ['subscriptions:list'], + realtime_triggers:['pubsub.Subscriber.CreateSubscription','pubsub.Subscriber.UpdateSubscription','pubsub.Subscriber.DeleteSubscription'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.subscriptions, function(region, rcb){ + var subscriptions = helpers.addSource(cache, source, + ['subscriptions', 'list', region]); + + if (!subscriptions) return rcb(); + + if (subscriptions.err || !subscriptions.data) { + helpers.addResult(results, 3, + 'Unable to query for Pub/Sub subscriptions: ' + helpers.addError(subscriptions), region, null, null, subscriptions.err); + return rcb(); + } + + if (!subscriptions.data.length) { + helpers.addResult(results, 0, 'No Pub/Sub subscriptions found', region); + return rcb(); + } + + subscriptions.data.forEach(subscription => { + if (!subscription.name) return; + + if (subscription.deadLetterPolicy && subscription.deadLetterPolicy.deadLetterTopic) { + helpers.addResult(results, 0, + 'Pub/Sub subscription has dead lettering enabled', region, subscription.name); + } else { + helpers.addResult(results, 2, + 'Pub/Sub subscription does not have dead lettering enabled', region, subscription.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/pubsub/deadLetteringEnabled.spec.js b/plugins/google/pubsub/deadLetteringEnabled.spec.js new file mode 100644 index 000000000..11c18de4f --- /dev/null +++ b/plugins/google/pubsub/deadLetteringEnabled.spec.js @@ -0,0 +1,112 @@ +var expect = require('chai').expect; +var plugin = require('./deadLetteringEnabled'); + +const subscriptions = [ + { + name: 'projects/my-test-project/subscriptions/sub-1', + topic: 'projects/my-test-project/topics/topic-1', + pushConfig: {}, + ackDeadlineSeconds: 10, + messageRetentionDuration: '604800s', + expirationPolicy: { ttl: '2678400s' } + }, + { + name: 'projects/my-test-project/subscriptions/sub-1', + topic: 'projects/my-test-project/topics/topic-1', + pushConfig: {}, + ackDeadlineSeconds: 10, + messageRetentionDuration: '604800s', + expirationPolicy: { ttl: '2678400s' }, + deadLetterPolicy: { + deadLetterTopic: 'projects/my-test-project/topics/topic-1', + maxDeliveryAttempts: 5 + } + } + +]; + +const createCache = (listSubscriptions, errSubscriptions) => { + return { + subscriptions: { + list: { + 'global': { + err: errSubscriptions, + data: listSubscriptions + } + } + } + } +}; + +describe('deadLetteringEnabled', function () { + describe('run', function () { + it('should give passing result if no Pub/Sub subscriptions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pub/Sub subscriptions found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Pub/Sub subscription has dead lettering enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has dead lettering enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [subscriptions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Pub/Sub subscription does not have dead lettering enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have dead lettering enabled'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache( + [subscriptions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Pub/Sub subscriptions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pub/Sub subscriptions'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + }) +}); + diff --git a/plugins/google/pubsub/topicAllUsersPolicy.js b/plugins/google/pubsub/topicAllUsersPolicy.js new file mode 100644 index 000000000..122bc6be7 --- /dev/null +++ b/plugins/google/pubsub/topicAllUsersPolicy.js @@ -0,0 +1,86 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Topic All Users Policy', + category: 'Pub/Sub', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure Pub/Sub Topics are not anonymously or publicly accessible', + more_info: 'Cloud IAM policy governs the access permissions to pub/sub topics. Granting anonymous or public access to pub/sub topics is risky if you are storing any sensitive messages. As a best practice, limit the access to specific authenticated users or groups or service accounts.', + link: 'https://cloud.google.com/pubsub/docs/access-control', + recommended_action: 'Ensure that each pub/sub topic is configured so that no member is set to allUsers or allAuthenticatedUsers.', + apis: ['topics:list','topics:getIamPolicy'], + realtime_triggers:['iam.IAMPolicy.SetIamPolicy','pubsub.Subscriber.CreateSubscription','pubsub.Subscriber.DeleteSubscription'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.topics, function(region, rcb){ + let topics = helpers.addSource( + cache, source, ['topics', 'list', region]); + + if (!topics) return rcb(); + + if (topics.err || !topics.data) { + helpers.addResult(results, 3, + 'Unable to query for Pub/Sub topics: ' + helpers.addError(topics), region, null, null, topics.err); + return rcb(); + } + + if (!topics.data.length) { + helpers.addResult(results, 0, 'No Pub/Sub topics found', region); + return rcb(); + } + + let topicPolicies = helpers.addSource(cache, source, + ['topics', 'getIamPolicy', region]); + + if (!topicPolicies) return rcb(); + + if (topicPolicies.err || !topicPolicies.data) { + helpers.addResult(results, 3, 'Unable to query topic policies: ' + helpers.addError(topicPolicies), region, null, null, topicPolicies.err); + return rcb(); + } + + if (!topicPolicies.data.length) { + helpers.addResult(results, 0, 'No Pub/Sub topics found', region); + return rcb(); + } + + topics.data.forEach(topic => { + let topicPolicy = topicPolicies.data.find(policy => policy.parent && policy.parent.name && policy.parent.name === topic.name); + let hasAllUsers = false; + if (topicPolicy.bindings && + topicPolicy.bindings.length) { + topicPolicy.bindings.forEach(binding => { + if (binding.members && + binding.members.length) { + binding.members.forEach(member => { + if (member === 'allUsers' || + member === 'allAuthenticatedUsers') { + hasAllUsers = true; + } + }); + } + }); + } + if (hasAllUsers) { + helpers.addResult(results, 2, + 'Pub/Sub topic has anonymous or public access', region, topic.name); + } else { + helpers.addResult(results, 0, + 'Pub/Sub topic does not have anonymous or public access', region, topic.name); + } + + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/pubsub/topicAllUsersPolicy.spec.js b/plugins/google/pubsub/topicAllUsersPolicy.spec.js new file mode 100644 index 000000000..31f3ef656 --- /dev/null +++ b/plugins/google/pubsub/topicAllUsersPolicy.spec.js @@ -0,0 +1,136 @@ +var expect = require('chai').expect; +var plugin = require('./topicAllUsersPolicy'); + +const createCache = (err, data, topicErr, topicData) => { + return { + topics: { + list: { + 'global': { + err: topicErr, + data: topicData + } + }, + getIamPolicy: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +const topics = [ + { + name: 'projects/testproj/topics/topic-1', + }, + { + name: 'projects/testproj/topics/topic-2' + } +]; + +describe('topicAllUsersPolicy', function () { + describe('run', function () { + it('should give unknown result if a topic error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pub/Sub topics'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + null, + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no topics are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pub/Sub topics found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + null, + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if topic has anonymous or public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Pub/Sub topic has anonymous or public access'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "bindings": [ + { "role": 'roles/editor', "members": ['allUsers'] }, + { + "role": 'roles/viewer', + "members": [ + 'allAuthenticatedUsers', + 'allUsers', + ] + } + ], + "parent": { + "name": "projects/testproj/topics/topic-1" + }, + "etag": "CAE=", + "version": 1 + } + ], + null, + [topics[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if topic has anonymous or public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Pub/Sub topic does not have anonymous or public access'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "parent": { + "name": "projects/testproj/topics/topic-2" + }, + "etag": "CAE=", + "version": 1 + } + ], + null, + [topics[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/pubsub/topicEncryption.js b/plugins/google/pubsub/topicEncryption.js new file mode 100644 index 000000000..72abbda84 --- /dev/null +++ b/plugins/google/pubsub/topicEncryption.js @@ -0,0 +1,107 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Topic Encryption Enabled', + category: 'Pub/Sub', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Google Pub/Sub topics are encrypted with desired encryption level.', + more_info: 'Google encrypts all messages in topics by default. By using CSEK, only the users with the key can access the disk. Anyone else, including Google, cannot access the disk data.', + link: 'https://cloud.google.com/pubsub/docs/encryption', + recommended_action: 'Ensure that Cloud Pub/Sub topics are encrypted using CSEK keys', + apis: ['topics:list', 'keyRings:list','cryptoKeys:list'], + compliance: { + hipaa: 'HIPAA requires that all data is encrypted, including data at rest. ' + + 'Enabling encryption for Pub/Sub topics helps to protect this data.', + pci: 'PCI requires proper encryption of cardholder data at rest. ' + + 'Encryption should be enabled for all topics storing this ' + + 'type of data.' + }, + settings: { + pubsub_topic_encryption_level: { + name: 'Pub/Sub Topic Encryption Protection Level', + description: 'Desired protection level for Pub/Sub topics. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM ecnryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers:['pubsub.Publisher.DeleteTopic','pubsub.Publisher.CreateTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + desiredEncryptionLevelStr: settings.pubsub_topic_encryption_level || this.settings.pubsub_topic_encryption_level.default + }; + + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(config.desiredEncryptionLevelStr); + + var keysObj = {}; + + async.series([ + function(cb){ + async.each(regions.cryptoKeys, function(region, rcb){ + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function(){ + cb(); + }); + }, + function(cb){ + async.each(regions.topics, function(tregion, trcb){ + var topics = helpers.addSource(cache, source, + ['topics', 'list', tregion]); + + if (!topics) return trcb(); + + if (topics.err || !topics.data) { + helpers.addResult(results, 3, + 'Unable to query for Pub/Sub topics: ' + helpers.addError(topics), tregion, null, null, topics.err); + return trcb(); + } + + if (!topics.data.length) { + helpers.addResult(results, 0, 'No Pub/Sub topics found', tregion); + return trcb(); + } + + async.each(topics.data, (topic, tcp) => { + let currentEncryptionLevel; + if (topic.kmsKeyName && topic.kmsKeyName.length && keysObj[topic.kmsKeyName]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[topic.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Pub/Sub topic is encrypted with ${currentEncryptionLevelStr} which is greater than or equal to ${config.desiredEncryptionLevelStr}`, + tregion, topic.name); + } else { + helpers.addResult(results, 2, + `Pub/Sub topic is encrypted with ${currentEncryptionLevelStr} which is less than ${config.desiredEncryptionLevelStr}`, + tregion, topic.name); + } + + tcp(); + }, function(){ + trcb(); + }); + }, function(){ + cb(); + }); + } + ], function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/pubsub/topicEncryption.spec.js b/plugins/google/pubsub/topicEncryption.spec.js new file mode 100644 index 000000000..bc3560787 --- /dev/null +++ b/plugins/google/pubsub/topicEncryption.spec.js @@ -0,0 +1,140 @@ +var expect = require('chai').expect; +var plugin = require('./topicEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; + +const topics = [ + { + name: "projects/test-dev-khulnasoft/topics/test-topic", + }, + { + name: "projects/test-dev-khulnasoft/topics/test-topic-2", + kmsKeyName: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + }, + { + name: "projects/test-dev-khulnasoft/topics/test-topic-2", + kmsKeyName: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1", + } +]; + +const createCache = (listTopics, errTopics, listKeys, errKeys) => { + return { + topics: { + list: { + 'global': { + err: errTopics, + data: listTopics + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: errKeys, + data: listKeys + } + } + } + } +}; + +describe('topicEncryption', function () { + describe('run', function () { + it('should give passing result if no Pub/Sub topics found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pub/Sub topics found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Pub/Sub topic is encrypted with desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [topics[1]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Pub/Sub topic is not encrypted with desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[1].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done(); + }; + + const cache = createCache( + [topics[0], topics[2]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Pub/Sub topics', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Pub/Sub topics'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + }) +}); + diff --git a/plugins/google/pubsub/topicLabelsAdded.js b/plugins/google/pubsub/topicLabelsAdded.js new file mode 100644 index 000000000..9ab7aed1d --- /dev/null +++ b/plugins/google/pubsub/topicLabelsAdded.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Topic Labels Added', + category: 'Pub/Sub', + domain: 'Application Integration', + severity: 'Low', + description: 'Ensure that all Pub/Sub topics have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/pubsub/docs/labels', + recommended_action: 'Ensure labels are added to all Pub/Sub topics.', + apis: ['topics:list'], + realtime_triggers:['pubsub.Publisher.DeleteTopic','pubsub.Publisher.CreateTopic','pubsub.Publisher.UpdateTopic'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.topics, function(region, rcb){ + let topics = helpers.addSource(cache, source, + ['topics', 'list', region]); + + if (!topics) return rcb(); + + if (topics.err || !topics.data) { + helpers.addResult(results, 3, 'Unable to query Pub/Sub topics', region, null, null, topics.err); + return rcb(); + } + + if (!topics.data.length) { + helpers.addResult(results, 0, 'No Pub/Sub topics found', region); + return rcb(); + } + + topics.data.forEach(topic => { + if (topic.labels && + Object.keys(topic.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(topic.labels).length} labels found for Pub/Sub topic`, region, topic.name); + } else { + helpers.addResult(results, 2, + 'Pub/Sub topic does not have any labels', region, topic.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/pubsub/topicLabelsAdded.spec.js b/plugins/google/pubsub/topicLabelsAdded.spec.js new file mode 100644 index 000000000..641555a9d --- /dev/null +++ b/plugins/google/pubsub/topicLabelsAdded.spec.js @@ -0,0 +1,97 @@ +var expect = require('chai').expect; +var plugin = require('./topicLabelsAdded'); + +const topics = [ + { + name: 'projects/testproj/topics/topic-1', + labels: { label1: 'topic' }, + }, + { + name: 'projects/testproj/topics/topic-2' + } +]; + +const createCache = (err, data) => { + return { + topics: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('topicLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a topic error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Pub/Sub topics'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no topics are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Pub/Sub topics found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the topic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Pub/Sub topic'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [topics[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the topic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Pub/Sub topic does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [topics[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) diff --git a/plugins/google/security/accessApprovalEnabled.js b/plugins/google/security/accessApprovalEnabled.js new file mode 100644 index 000000000..24f57f7da --- /dev/null +++ b/plugins/google/security/accessApprovalEnabled.js @@ -0,0 +1,54 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Access Approval Enabled', + category: 'Security', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Access Approval is enabled for the project.', + more_info: 'GCP Access Approval allows you to require the explicit approval of your organization whenever Google support try to access your projects. This adds an additional control and logging of who in your organization approved or denied the access requests.', + link: 'https://cloud.google.com/cloud-provider-access-management/access-approval/docs/overview', + recommended_action: 'Enable Access Approval for the GCP project.', + apis: ['accessApproval:settings'], + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + var project = projects.data[0].name; + + let accessApprovalSettings = helpers.addSource(cache, source, + ['accessApproval', 'settings', 'global']); + + if (!accessApprovalSettings || accessApprovalSettings.err || !accessApprovalSettings.data) { + if (accessApprovalSettings.err && accessApprovalSettings.err.code === 404) { + helpers.addResult(results, 2, + 'Access Approval is not enabled for the project', 'global', project); + } else { + helpers.addResult(results, 3, + 'Unable to query access approval settings for project: ' + helpers.addError(accessApprovalSettings), 'global', null, null, (accessApprovalSettings) ? accessApprovalSettings.err : null); + } + return callback(null, results, source); + } + + if (accessApprovalSettings.data && accessApprovalSettings.data.length && accessApprovalSettings.data[0].enrolledServices) { + helpers.addResult(results, 0, + 'Access Approval is enabled for the project', 'global', project); + } else { + helpers.addResult(results, 2, + 'Access Approval is not enabled for the project', 'global', project); + } + + return callback(null, results, source); + } +}; diff --git a/plugins/google/security/accessApprovalEnabled.spec.js b/plugins/google/security/accessApprovalEnabled.spec.js new file mode 100644 index 000000000..9a798d1d6 --- /dev/null +++ b/plugins/google/security/accessApprovalEnabled.spec.js @@ -0,0 +1,85 @@ +var expect = require('chai').expect; +var plugin = require('./accessApprovalEnabled'); + +const createCache = (list, err) => { + return { + accessApproval: { + settings: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + get: { + 'global': { + data: [ { + name: 'testproj' + } ] + } + } + } + } +}; + +describe('accessApprovalEnabled', function () { + describe('run', function () { + + it('should give unknown result if unable to query access approval settings', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query access approval settings for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if access approval is enabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ { + name: 'projects/mytest/accessApprovalSettings', + enrolledServices: [ { cloudProduct: 'all', enrollmentLevel: 'BLOCK_ALL' } ] + } + ], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if access approval is not enabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enabled'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/serviceusage/assetInventoryEnabled.js b/plugins/google/serviceusage/assetInventoryEnabled.js new file mode 100644 index 000000000..8293a0bed --- /dev/null +++ b/plugins/google/serviceusage/assetInventoryEnabled.js @@ -0,0 +1,50 @@ +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Asset Inventory Enabled', + category: 'Service Usage', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure that Asset Inventory service is enabled for the project.', + more_info: 'GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing for GCP resources and IAM policies.', + link: 'https://cloud.google.com/asset-inventory/docs', + recommended_action: 'Enable Asset Inventory service for the GCP project.', + apis: ['projects:getWithNumber', 'services:listEnabled'], + realtime_triggers: ['compute.projects.insert', 'compute.projects.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'getWithNumber', 'global']); + + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + let services = helpers.addSource(cache, source, + ['services', 'listEnabled', 'global']); + + if (!services || services.err || !services.data) { + helpers.addResult(results, 3, + 'Unable to query services for project: ' + helpers.addError(services), 'global', null, null, (services) ? services.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + if (services.data && services.data.length && services.data.find(service => service.name && service.name.includes('cloudasset.googleapis.com'))) { + helpers.addResult(results, 0, + 'Asset Inventory is enabled for the project', 'global', project); + } else { + helpers.addResult(results, 2, + 'Asset Inventory is not enabled for the project', 'global', project); + } + + return callback(null, results, source); + } +}; diff --git a/plugins/google/serviceusage/assetInventoryEnabled.spec.js b/plugins/google/serviceusage/assetInventoryEnabled.spec.js new file mode 100644 index 000000000..4e25fc1c8 --- /dev/null +++ b/plugins/google/serviceusage/assetInventoryEnabled.spec.js @@ -0,0 +1,86 @@ +var expect = require('chai').expect; +var plugin = require('./assetInventoryEnabled'); + +const createCache = (list, err) => { + return { + services: { + listEnabled: { + 'global': { + err: err, + data: list + } + }, + }, + projects: { + getWithNumber: { + 'global': { + data: [ { + name: 'testproj', + projectNumber: 123456 + } ] + } + } + } + } +}; + +describe('assetInventoryEnabled', function () { + describe('run', function () { + + it('should give unknown result if unable to query services', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query services for project'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if asset inventory is enabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + [ { + name: 'projects/12345/services/cloudasset.googleapis.com', + state: 'ENABLED' + } + ], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if asset inventory is not enabled for project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not enabled'); + expect(results[0].region).to.equal('global') + done(); + }; + + const cache = createCache( + [], + null); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/spanner/instanceNodeCount.js b/plugins/google/spanner/instanceNodeCount.js new file mode 100644 index 000000000..a0f5d5d2e --- /dev/null +++ b/plugins/google/spanner/instanceNodeCount.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Spanner Instance Node Count', + category: 'Spanner', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure than node count for Spanner instances is not above allowed count.', + more_info: 'The number of provisioned Cloud Spanner instance nodes must be under desired limit to avoid reaching the limit and exceeding the set budget.', + link: 'https://cloud.google.com/spanner/docs/instances', + recommended_action: 'Modify Spanner instances to decrease number of nodes', + apis: ['spanner:list'], + settings: { + spanner_allowed_instance_node_count: { + name: 'Spanner Allowed Instance Node Count', + description: 'The number of nodes allowed per one Spanner instance', + regex: '^.*$', + default: '20' + } + }, + realtime_triggers: ['spanner.admin.instance.InstanceAdmin.CreateInstance', 'spanner.admin.instance.InstanceAdmin.UpdateInstance', 'spanner.admin.instance.InstanceAdmin.DeleteInstance'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + spanner_allowed_instance_node_count: parseInt(settings.spanner_allowed_instance_node_count || this.settings.spanner_allowed_instance_node_count.default) + }; + + async.each(regions.spanner, function(region, rcb){ + let instances = helpers.addSource(cache, source, + ['spanner', 'list', region]); + + if (!instances) return rcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query Spanner instances: ' + helpers.addError(instances), region, null, null, instances.err); + return rcb(); + } + + if (!instances.data.length) { + helpers.addResult(results, 0, 'No Spanner instances found', region); + return rcb(); + } + + instances.data.forEach(spannerInstance => { + if (!spannerInstance.name) return; + + let nodeCount = spannerInstance.nodeCount; + if (!nodeCount && spannerInstance.processingUnits) { + nodeCount = Math.floor(spannerInstance.processingUnits/1000); + } + let resultStatus = (nodeCount <= config.spanner_allowed_instance_node_count) ? 0 : 2; + + helpers.addResult(results, resultStatus, + `Spanner instance has ${nodeCount} node of ${config.spanner_allowed_instance_node_count} limit`, + region, spannerInstance.name); + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/spanner/instanceNodeCount.spec.js b/plugins/google/spanner/instanceNodeCount.spec.js new file mode 100644 index 000000000..22e603927 --- /dev/null +++ b/plugins/google/spanner/instanceNodeCount.spec.js @@ -0,0 +1,94 @@ +var expect = require('chai').expect; +var plugin = require('./instanceNodeCount'); + +const createCache = (err, data) => { + return { + spanner: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('instanceNodeCount', function () { + describe('run', function () { + it('should give unknown result if error while querying Spanner instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Spanner instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no Spanner instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Spanner instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if instance has less nodes than allowed limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Spanner instance has 1 node of 20 limit'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + "name": "projects/test-khulnasoft/instances/test-ins", + "config": "projects/test-khulnasoft/instanceConfigs/regional-us-east1", + "displayName": "test-ins", + "nodeCount": 1, + "state": "READY", + "processingUnits": 1000 + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if instance has more nodes that allowed limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Spanner instance has 21 node of 20 limit'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + "name": "projects/test-khulnasoft/instances/test-ins", + "config": "projects/test-khulnasoft/instanceConfigs/regional-asia1", + "displayName": "test-ins", + "nodeCount": 21, + "state": "READY", + "processingUnits": 1000 + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/sql/anyHostRootAccess.js b/plugins/google/sql/anyHostRootAccess.js index 7f5916327..2c90fc4cb 100644 --- a/plugins/google/sql/anyHostRootAccess.js +++ b/plugins/google/sql/anyHostRootAccess.js @@ -4,26 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Any Host Root Access', category: 'SQL', + domain: 'Databases', + severity: 'High', description: 'Ensures SQL instances root user cannot be accessed from any host', more_info: 'Root access for SQL instance should only be allowed from whitelisted IPs to ensure secure access only from trusted entities.', link: 'https://cloud.google.com/sql/docs/mysql/create-manage-users', recommended_action: 'Ensure that root access for SQL instances are not allowed from any host.', - apis: ['instances:sql:list', 'users:list'], - + apis: ['sql:list', 'users:list'], + realtime_triggers: ['cloudsql.users.delete','cloudsql.users.create'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.users, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + let project = projects.data[0].name; + + async.each(regions.users, function(region, rcb){ let users = helpers.addSource(cache, source, ['users', 'list', region]); if (!users) return rcb(); if (users.err || !users.data) { - helpers.addResult(results, 3, 'Unable to query SQL users: ' + helpers.addError(users), region); + helpers.addResult(results, 3, 'Unable to query SQL users: ' + helpers.addError(users), region, null, null, users.err); return rcb(); } @@ -34,6 +47,7 @@ module.exports = { var foundRoot = false; users.data.forEach(user => { + let resource = helpers.createResourceName('instances', user.instance, project); if (user.name && user.name === 'root') { foundRoot = true; @@ -43,7 +57,7 @@ module.exports = { user.host === '/0')) { helpers.addResult(results, 2, - 'The root user has access to the instance from any host', region, user.instance); + 'The root user has access to the instance from any host', region, resource); } else { helpers.addResult(results, 0, 'The root user does not have access to the instance from any host', region); diff --git a/plugins/google/sql/anyHostRootAccess.spec.js b/plugins/google/sql/anyHostRootAccess.spec.js index cc33f4d4f..13391b000 100644 --- a/plugins/google/sql/anyHostRootAccess.spec.js +++ b/plugins/google/sql/anyHostRootAccess.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } } } }; diff --git a/plugins/google/sql/dbAutomatedBackups.js b/plugins/google/sql/dbAutomatedBackups.js index 28f8311a0..b5ce7530f 100644 --- a/plugins/google/sql/dbAutomatedBackups.js +++ b/plugins/google/sql/dbAutomatedBackups.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DB Automated Backups', category: 'SQL', + domain: 'Databases', + severity: 'Medium', description: 'Ensures automated backups are enabled for SQL instances', more_info: 'Google provides a simple method of backing up SQL instances at a regular interval. This should be enabled to provide an option for restoring data in the event of a database compromise or hardware failure.', link: 'https://cloud.google.com/sql/docs/mysql/instance-settings', recommended_action: 'Ensure that all database instances are configured with automatic backups enabled.', - apis: ['instances:sql:list'], + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.sql, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ let sqlInstances = helpers.addSource( - cache, source, ['instances', 'sql', 'list', region]); + cache, source, ['sql', 'list', region]); if (!sqlInstances) return rcb(); if (sqlInstances.err || !sqlInstances.data) { - helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); return rcb(); } @@ -32,16 +46,17 @@ module.exports = { } sqlInstances.data.forEach(sqlInstance => { - if (sqlInstance.instanceType != "READ_REPLICA_INSTANCE" && - sqlInstance.settings && + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.settings && sqlInstance.settings.backupConfiguration && sqlInstance.settings.backupConfiguration.enabled) { helpers.addResult(results, 0, - 'Automated backups are enabled', region, sqlInstance.name); - } else if (sqlInstance.instanceType == "READ_REPLICA_INSTANCE"){ + 'Automated backups are enabled', region, resource); } else { helpers.addResult(results, 2, - 'Automated backups are not enabled', region, sqlInstance.name); + 'Automated backups are not enabled', region, resource); } }); @@ -51,4 +66,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/sql/dbAutomatedBackups.spec.js b/plugins/google/sql/dbAutomatedBackups.spec.js new file mode 100644 index 000000000..7f944da2e --- /dev/null +++ b/plugins/google/sql/dbAutomatedBackups.spec.js @@ -0,0 +1,125 @@ +var expect = require('chai').expect; +var plugin = require('./dbAutomatedBackups'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('dbAutomatedBackups', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no sql instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if automated backups are enabled for sql instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Automated backups are enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + backupConfiguration: { + startTime: "17:00", + kind: "sql#backupConfiguration", + location: "us", + backupRetentionSettings: { + retentionUnit: "COUNT", + retainedBackups: 7, + }, + enabled: true, + binaryLogEnabled: true, + transactionLogRetentionDays: 7, + } + } + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if automated backups are not enabled for sql instance', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Automated backups are not enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + backupConfiguration: { + startTime: "17:00", + kind: "sql#backupConfiguration", + location: "us", + backupRetentionSettings: { + retentionUnit: "COUNT", + retainedBackups: 7, + }, + enabled: false, + binaryLogEnabled: true, + transactionLogRetentionDays: 7, + } + }, + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/sql/dbMultiAz.js b/plugins/google/sql/dbMultiAz.js index c75c24a72..6825e43df 100644 --- a/plugins/google/sql/dbMultiAz.js +++ b/plugins/google/sql/dbMultiAz.js @@ -4,25 +4,39 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DB Multiple AZ', category: 'SQL', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that SQL instances have a failover replica to be cross-AZ for high availability', more_info: 'Creating SQL instances in with a single AZ creates a single point of failure for all systems relying on that database. All SQL instances should be created in multiple AZs to ensure proper failover.', link: 'https://cloud.google.com/sql/docs/mysql/instance-settings', recommended_action: 'Ensure that all database instances have a DB replica enabled in a secondary AZ.', - apis: ['instances:sql:list'], + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.sql, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ let sqlInstances = helpers.addSource( - cache, source, ['instances', 'sql', 'list', region]); + cache, source, ['sql', 'list', region]); if (!sqlInstances) return rcb(); if (sqlInstances.err || !sqlInstances.data) { - helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); return rcb(); } @@ -32,17 +46,19 @@ module.exports = { } sqlInstances.data.forEach(sqlInstance => { - if (sqlInstance.instanceType != "READ_REPLICA_INSTANCE" && - sqlInstance.failoverReplica && + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.failoverReplica && sqlInstance.failoverReplica.available) { helpers.addResult(results, 0, - 'SQL instance has multi-AZ enabled', region, sqlInstance.name); - } else if (sqlInstance.instanceType == "READ_REPLICA_INSTANCE"){ + 'SQL instance has multi-AZ enabled', region, resource); } else { helpers.addResult(results, 2, - 'SQL instance does not have multi-AZ enabled', region, sqlInstance.name); + 'SQL instance does not have multi-AZ enabled', region, resource); } - }) + }); rcb(); }, function(){ @@ -50,4 +66,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/sql/dbMultiAz.spec.js b/plugins/google/sql/dbMultiAz.spec.js new file mode 100644 index 000000000..0cbab0e02 --- /dev/null +++ b/plugins/google/sql/dbMultiAz.spec.js @@ -0,0 +1,99 @@ +var expect = require('chai').expect; +var plugin = require('./dbMultiAz'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('dbMultiAz', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no sql instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has multi-AZ enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has multi-AZ enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: true, + } + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance does not have multi-AZ enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have multi-AZ enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + failoverReplica:{ + available: false, + } + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/sql/dbPubliclyAccessible.js b/plugins/google/sql/dbPubliclyAccessible.js index 253b010af..8aa126e4c 100644 --- a/plugins/google/sql/dbPubliclyAccessible.js +++ b/plugins/google/sql/dbPubliclyAccessible.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DB Publicly Accessible', category: 'SQL', - description: 'Ensures that SQL instances have a failover replica to be cross-AZ for high availability.', - more_info: 'Creating SQL instances in with a single AZ creates a single point of failure for all systems relying on that database. All SQL instances should be created in multiple AZs to ensure proper failover.', - link: 'https://cloud.google.com/sql/docs/mysql/instance-settings', - recommended_action: '1. Enter the SQL category of the Google Console. 2. Select the instance. 3. Select the Replicas tab. 4. Select Create Failover Replica and follow the prompts.', - apis: ['instances:sql:list'], + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that SQL instances do not allow public access', + more_info: 'Unless there is a specific business requirement, SQL instances should not have a public endpoint and should only be accessed from within a VPC.', + link: 'https://cloud.google.com/sql/docs/mysql/authorize-networks', + recommended_action: 'Ensure that SQL instances are configured to prohibit traffic from the public 0.0.0.0 global IP address.', + apis: ['sql:list'], compliance: { hipaa: 'SQL instances should only be launched in VPC environments and ' + 'accessed through private endpoints. Exposing SQL instances to ' + @@ -19,21 +21,32 @@ module.exports = { 'Ensure SQL instances are not accessible from the Internet ' + 'and use proper jump box access mechanisms.' }, - + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.sql, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ let sqlInstances = helpers.addSource( - cache, source, ['instances', 'sql', 'list', region]); + cache, source, ['sql', 'list', region]); if (!sqlInstances) return rcb(); if (sqlInstances.err || !sqlInstances.data) { - helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); return rcb(); } @@ -43,35 +56,37 @@ module.exports = { } var myIpConfig = {}; sqlInstances.data.forEach(sqlInstance => { - if (sqlInstance.instanceType != "READ_REPLICA_INSTANCE" && - sqlInstance.settings && + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.settings && sqlInstance.settings.ipConfiguration) { - myIpConfig = sqlInstance.settings.ipConfiguration + myIpConfig = sqlInstance.settings.ipConfiguration; if (myIpConfig.privateNetwork && !myIpConfig.ipv4Enabled) { - helpers.addResult(results, 0, - 'SQL Instance is not publicly accessible', region, sqlInstance.name); + helpers.addResult(results, 0, + 'SQL Instance is not publicly accessible', region, resource); } else if (myIpConfig.ipv4Enabled && - myIpConfig.authorizedNetworks) { - var openNetwork = false; - myIpConfig.authorizedNetworks.forEach(network => { - if (network.value == '0.0.0.0/0') { - openNetwork = true; - } - }) - if (openNetwork) { - helpers.addResult(results, 2, - 'SQL Instance is publicly accessible by all IP addresses', region, sqlInstance.name); - } else if (myIpConfig.authorizedNetworks.length){ - helpers.addResult(results, 1, - 'SQL Instance is publicly accessible by specific IP addresses', region, sqlInstance.name); - } else { - helpers.addResult(results, 0, - 'SQL Instance is not publicly accessible', region, sqlInstance.name); - } - } - }else if (sqlInstance.instanceType == "READ_REPLICA_INSTANCE"){ + myIpConfig.authorizedNetworks) { + var openNetwork = false; + myIpConfig.authorizedNetworks.forEach(network => { + if (network.value == '0.0.0.0/0') { + openNetwork = true; + } + }); + if (openNetwork) { + helpers.addResult(results, 2, + 'SQL Instance is publicly accessible by all IP addresses', region, resource); + } else if (myIpConfig.authorizedNetworks.length){ + helpers.addResult(results, 1, + 'SQL Instance is publicly accessible by specific IP addresses', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL Instance is not publicly accessible', region, resource); + } + } } - }) + }); rcb(); }, function(){ @@ -79,4 +94,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; diff --git a/plugins/google/sql/dbPubliclyAccessible.spec.js b/plugins/google/sql/dbPubliclyAccessible.spec.js new file mode 100644 index 000000000..7d5c9177a --- /dev/null +++ b/plugins/google/sql/dbPubliclyAccessible.spec.js @@ -0,0 +1,187 @@ +var expect = require('chai').expect; +var plugin = require('./dbPubliclyAccessible'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('dbPubliclyAccessible', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no sql instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no sql instances is publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Instance is not publicly accessible'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + ipConfiguration: { + privateNetwork:true, + authorizedNetworks: [ + ], + ipv4Enabled: false, + }, + }, + ipAddresses: [ + { + type: "PRIMARY", + ipAddress: "34.82.209.99", + }, + ], + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if any sql instances is publicly accessible by all IP addresses', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL Instance is publicly accessible by all IP addresses'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + ipConfiguration: { + privateNetwork:false, + authorizedNetworks: [ + { + value: "0.0.0.0/0", + name: "my", + kind: "sql#aclEntry", + } + ], + ipv4Enabled: true, + }, + }, + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give warn result if any sql instances is publicly accessible by specific IP addresses', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('SQL Instance is publicly accessible by specific IP addresses'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + ipConfiguration: { + privateNetwork:false, + authorizedNetworks: [ + { + value: "10.0.0.0/0", + name: "my", + kind: "sql#aclEntry", + } + ], + ipv4Enabled: true, + }, + } + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance is not publicly accessible', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Instance is not publicly accessible'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: 'testing-instance1', + instanceType: 'CLOUD_SQL_INSTANCE', + settings: { + tier: "db-custom-4-26624", + kind: "sql#settings", + ipConfiguration: { + privateNetwork:false, + authorizedNetworks: [ + ], + ipv4Enabled: true, + }, + } + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/sql/dbRestorable.js b/plugins/google/sql/dbRestorable.js index 78a99aaad..79b8f3f35 100644 --- a/plugins/google/sql/dbRestorable.js +++ b/plugins/google/sql/dbRestorable.js @@ -4,30 +4,46 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'DB Restorable', category: 'SQL', + domain: 'Databases', + severity: 'Medium', description: 'Ensures SQL instances can be restored to a recent point', more_info: 'Google will maintain a point to which the database can be restored. This point should not drift too far into the past, or else the risk of irrecoverable data loss may occur.', link: 'https://cloud.google.com/sql/docs/mysql/instance-settings', recommended_action: 'Ensure all database instances are configured with automatic backups and can be restored to a recent point with binary logging enabled.', - apis: ['instances:sql:list'], + apis: ['sql:list', 'backupRuns:list'], compliance: { pci: 'PCI requires that security procedures, including restoration of ' + 'compromised services, be tested frequently. RDS restorable time ' + 'indicates the last known time to which the instance can be restored.' }, - + realtime_triggers:['cloudsql.backupRuns.delete','cloudsql.backupRuns.create'], + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.sql, function(region, rcb){ - let sqlInstances = helpers.addSource( - cache, source, ['instances', 'sql', 'list', region]); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + let backupRuns = helpers.addSource( + cache, source, ['backupRuns', 'list', region]); + if (!sqlInstances) return rcb(); if (sqlInstances.err || !sqlInstances.data) { - helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); return rcb(); } @@ -36,15 +52,25 @@ module.exports = { return rcb(); } + if (!backupRuns || backupRuns.err || !backupRuns.data) { + helpers.addResult(results, 3, + `Unable to query SQL backup runs: ${helpers.addError(backupRuns)}`, region); + return rcb(); + } + sqlInstances.data.forEach(sqlInstance => { - if (sqlInstance.settings && - sqlInstance.settings.backupConfiguration && - sqlInstance.settings.backupConfiguration.binaryLogEnabled) { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + let found = backupRuns.data.find(backup => backup.instance && sqlInstance.name && backup.instance == sqlInstance.name); + + if (found) { helpers.addResult(results, 0, - 'SQL instance has point-in-time recovery enabled', region, sqlInstance.name); + 'SQL instance has backup available', region, resource); } else { helpers.addResult(results, 2, - 'SQL instance does not have point-in-time recovery enabled', region, sqlInstance.name); + 'SQL instance does not have backups available', region, resource); } }); @@ -54,4 +80,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; diff --git a/plugins/google/sql/dbRestorable.spec.js b/plugins/google/sql/dbRestorable.spec.js new file mode 100644 index 000000000..d4e57cf39 --- /dev/null +++ b/plugins/google/sql/dbRestorable.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var plugin = require('./dbRestorable'); + +const createCache = (err, sqlInstances, backupRuns) => { + return { + sql: { + list: { + 'global': { + err: err, + data: sqlInstances + } + } + }, + backupRuns: { + list: { + 'global': { + err: err, + data: backupRuns + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('dbRestorable', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no sql instances found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has backup available', function (done) { + const callback = (err, results) => { + console.log(); + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has backup available'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: "backup-testing" + } + ], + [ + { + instance: "backup-testing" + } + ] + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance does not have backups available', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have backups available'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + name: "backup-testing" + } + ], + [ + ] + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/sql/dbSSLEnabled.js b/plugins/google/sql/dbSSLEnabled.js index 01770e5db..57e59d928 100644 --- a/plugins/google/sql/dbSSLEnabled.js +++ b/plugins/google/sql/dbSSLEnabled.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Database SSL Enabled', category: 'SQL', + domain: 'Databases', + severity: 'Medium', description: 'Ensures SQL databases have SSL enabled', more_info: 'Enabling SSL ensures that the sensitive data being transferred from the database is encrypted.', link: 'https://cloud.google.com/sql/docs/mysql/instance-settings', recommended_action: 'Ensure that SSL is enabled on all SQL databases.', - apis: ['instances:sql:list'], + apis: ['sql:list'], compliance: { pci: 'PCI requires strong cryptographic and security protocols ' + 'when transmitting user data, this includes using SSL.', @@ -16,20 +18,32 @@ module.exports = { 'Database SSL should be used to ensure SQL databases ' + 'are always connecting through secure encryption.', }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.instances.sql, function(region, rcb){ + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ let sqlInstances = helpers.addSource(cache, source, - ['instances', 'sql', 'list', region]); + ['sql', 'list', region]); if (!sqlInstances) return rcb(); if (sqlInstances.err || !sqlInstances.data) { - helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region, null, null, sqlInstances.err); return rcb(); } @@ -39,14 +53,18 @@ module.exports = { } sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + if (sqlInstance.settings && sqlInstance.settings.ipConfiguration && sqlInstance.settings.ipConfiguration.requireSsl) { helpers.addResult(results, 0, - 'SQL database has SSL enabled', region, sqlInstance.name); + 'SQL database has SSL enabled', region, resource); } else { helpers.addResult(results, 2, - 'SQL database has SSL disabled', region, sqlInstance.name); + 'SQL database has SSL disabled', region, resource); } }); @@ -56,4 +74,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/sql/dbSSLEnabled.spec.js b/plugins/google/sql/dbSSLEnabled.spec.js index 7dbb6feae..08d110ba5 100644 --- a/plugins/google/sql/dbSSLEnabled.spec.js +++ b/plugins/google/sql/dbSSLEnabled.spec.js @@ -4,7 +4,6 @@ var plugin = require('./dbSSLEnabled'); const createCache = (err, data) => { return { - instances: { sql: { list: { 'global': { @@ -12,6 +11,12 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } } } } diff --git a/plugins/google/sql/mysqlLatestVersion.js b/plugins/google/sql/mysqlLatestVersion.js new file mode 100644 index 000000000..c69f040da --- /dev/null +++ b/plugins/google/sql/mysqlLatestVersion.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'MySQL Latest Version', + category: 'SQL', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that MySQL database servers are using the latest major version of MySQL database.', + more_info: 'To make use of the latest database features and benefit from enhanced performance and security, make sure that your MySQL database instances are using the latest major version of MySQL.', + link: 'https://cloud.google.com/sql/docs/mysql/db-versions', + recommended_action: 'Ensure that all your MySQL database instances are using the latest MYSQL database version.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + const latestMySQLVersion = 8.0; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('mysql')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of MySQL type', region, resource); + return; + } + + if (sqlInstance.databaseVersion && parseFloat(sqlInstance.databaseVersion.split('MYSQL_')[1].replace('_', '.')) >= latestMySQLVersion) { + helpers.addResult(results, 0, + `SQL instance is using MySQL major version ${sqlInstance.databaseVersion} which is the latest version`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance is using MySQL major version ${sqlInstance.databaseVersion} which is not the latest version`, region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/mysqlLatestVersion.spec.js b/plugins/google/sql/mysqlLatestVersion.spec.js new file mode 100644 index 000000000..7ae488043 --- /dev/null +++ b/plugins/google/sql/mysqlLatestVersion.spec.js @@ -0,0 +1,121 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./mysqlLatestVersion'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('mysqlLatestVersion', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of MySQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of MySQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "POSTGRES_13", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has the latest MySQL major version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is the latest'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_8_2" + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance does not have the latest MySQL major version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is not the latest version'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/mysqlLocalInfile.js b/plugins/google/sql/mysqlLocalInfile.js new file mode 100644 index 000000000..1850ad8e7 --- /dev/null +++ b/plugins/google/sql/mysqlLocalInfile.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'MySQL Local Infile Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for MySQL type does not have local infile flag enabled.', + more_info: 'SQL instances for MySQL type database provides local_infile flag, which can be used to load data from client or server systems. It controls the load data statements for database. Anyone using this server can access any file on the client system. For security reasons it should be disabled.', + link: 'https://cloud.google.com/sql/docs/mysql/flags', + recommended_action: 'Ensure that local infile flag is disabled for all MySQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('mysql')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of MySQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'local_infile' && + flag.value && flag.value == 'off'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance does not have local_infile flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance has local_infile flag enabled', region, resource); + + } + } else { + helpers.addResult(results, 2, + 'SQL instance has local_infile flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/mysqlLocalInfile.spec.js b/plugins/google/sql/mysqlLocalInfile.spec.js new file mode 100644 index 000000000..7ae7deded --- /dev/null +++ b/plugins/google/sql/mysqlLocalInfile.spec.js @@ -0,0 +1,135 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./mysqlLocalInfile'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('mysqlLocalInfile', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of MySQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of MySQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "POSTGRES_13", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does have local_infile flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have local_infile flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "local_infile", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances have local_infile flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has local_infile flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "local_infile", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/mysqlSkipShowDatabase.js b/plugins/google/sql/mysqlSkipShowDatabase.js new file mode 100644 index 000000000..1489f0154 --- /dev/null +++ b/plugins/google/sql/mysqlSkipShowDatabase.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'MySQL Skip Show Database Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for MySQL type have skip show database flag enabled.', + more_info: 'SQL instances for MySQL type database provides skip_show_database flag, revents people from using the SHOW DATABASES statement if they do not have the SHOW DATABASES privilege. This can improve security if you have concerns about users being able to see databases belonging to other users.', + link: 'https://cloud.google.com/sql/docs/mysql/flags', + recommended_action: 'Ensure that skip show database flag is enabled for all MySQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('mysql')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of MySQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'skip_show_database' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has skip_show_database flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have skip_show_database flag enabled', region, resource); + + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have skip_show_database flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/mysqlSkipShowDatabase.spec.js b/plugins/google/sql/mysqlSkipShowDatabase.spec.js new file mode 100644 index 000000000..a56647b9d --- /dev/null +++ b/plugins/google/sql/mysqlSkipShowDatabase.spec.js @@ -0,0 +1,135 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./mysqlSkipShowDatabase'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('mysqlSkipShowDatabase', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of MySQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of MySQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "POSTGRES_13", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has skip_show_database flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has skip_show_database flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "skip_show_database", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have skip_show_database flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have skip_show_database flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "skip_show_database", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/mysqlSlowQueryLog.js b/plugins/google/sql/mysqlSlowQueryLog.js new file mode 100644 index 000000000..dead6e0eb --- /dev/null +++ b/plugins/google/sql/mysqlSlowQueryLog.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'MySQL Slow Query Log Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that MySQL instances have slow query log flag enabled.', + more_info: 'MySQL instance flag that helps find inefficient or time-consuming SQL queries for MySQL databases.', + link: 'https://cloud.google.com/sql/docs/mysql/flags', + recommended_action: 'Ensure that slow query log flag is enabled for all MySQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() == 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('mysql')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of MySQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'slow_query_log' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has slow query log flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance has slow query log flag disabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance has slow query log flag disabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/mysqlSlowQueryLog.spec.js b/plugins/google/sql/mysqlSlowQueryLog.spec.js new file mode 100644 index 000000000..0f4360495 --- /dev/null +++ b/plugins/google/sql/mysqlSlowQueryLog.spec.js @@ -0,0 +1,136 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./mysqlSlowQueryLog'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('mysqlSlowQueryLog', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of MySQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of MySQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "POSTGRES_13", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has slow query log flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has slow query log flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "slow_query_log", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance has slow query log flag disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has slow query log flag disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + settings: { + databaseFlags: [ + { + name: "slow_query_log", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLatestVersion.js b/plugins/google/sql/postgresqlLatestVersion.js new file mode 100644 index 000000000..0a5ce712f --- /dev/null +++ b/plugins/google/sql/postgresqlLatestVersion.js @@ -0,0 +1,77 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Latest Version', + category: 'SQL', + domain: 'Databases', + severity: 'Low', + description: 'Ensure that PostgreSQL database servers are using the latest major version of PostgreSQL database.', + more_info: 'To make use of the latest database features and benefit from enhanced performance and security, make sure that your PostgreSQL database instances are using the latest major version of PostgreSQL.', + link: 'https://cloud.google.com/sql/docs/postgres/db-versions', + recommended_action: 'Ensure that all your PostgreSQL database instances are using the latest PostgreSQL database version.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + const latestPostgreSQLVersion = 15; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('POSTGRES')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of POSTGRES type', region, resource); + return; + } + + if (sqlInstance.databaseVersion && parseInt(sqlInstance.databaseVersion.toUpperCase().split('POSTGRES_')[1]) >= latestPostgreSQLVersion) { + helpers.addResult(results, 0, + `SQL instance is using Postgres major version ${sqlInstance.databaseVersion} which is the latest version`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance is using Postgres major version ${sqlInstance.databaseVersion} which is not the latest version`, region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/postgresqlLatestVersion.spec.js b/plugins/google/sql/postgresqlLatestVersion.spec.js new file mode 100644 index 000000000..343ad9da9 --- /dev/null +++ b/plugins/google/sql/postgresqlLatestVersion.spec.js @@ -0,0 +1,121 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLatestVersion'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLatestVersion', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of POSTGRES type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of POSTGRES type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has the latest postgreSQL major version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is the latest'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_15" + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance does not have the latest PostgreSQL major version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is not the latest version'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_11", + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/postgresqlLogCheckpoints.js b/plugins/google/sql/postgresqlLogCheckpoints.js new file mode 100644 index 000000000..31de9557a --- /dev/null +++ b/plugins/google/sql/postgresqlLogCheckpoints.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Checkpoints Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that log_checkpoints flag is enabled for PostgreSQL instances.', + more_info: 'When log_checkpoints flag is enabled, instance checkpoints and restart points are logged in the server log.', + link: 'https://cloud.google.com/sql/docs/postgres/flags#setting_a_database_flag', + recommended_action: 'Ensure that all PostgreSQL database instances have log_checkpoints flag and it value is set to on.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('POSTGRES')) { + helpers.addResult(results, 0, 'SQL instance database version is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_checkpoints' && + flag.value && flag.value == 'on'); + if (found) { + helpers.addResult(results, 0, + 'PostgreSQL instance has log_checkpoints flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'PostgreSQL instance does not have log_checkpoints flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'PostgreSQL instance does not have log_checkpoints flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogCheckpoints.spec.js b/plugins/google/sql/postgresqlLogCheckpoints.spec.js new file mode 100644 index 000000000..7f9cdc184 --- /dev/null +++ b/plugins/google/sql/postgresqlLogCheckpoints.spec.js @@ -0,0 +1,223 @@ +var expect = require('chai').expect; +var plugin = require('./postgresqlLogCheckpoints'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogCheckpoints', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if instance has log_checkpoints flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL instance has log_checkpoints flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "kind": "sql#instance", + "state": "RUNNABLE", + "databaseVersion": "POSTGRES_13", + "settings": { + "authorizedGaeApplications": [], + "tier": "db-custom-1-3840", + "kind": "sql#settings", + "availabilityType": "ZONAL", + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "activationPolicy": "ALWAYS", + "locationPreference": { + "zone": "us-central1-f", + "kind": "sql#locationPreference" + }, + "databaseFlags": [ + { + "name": "log_checkpoints", + "value": "on" + } + ], + "dataDiskType": "PD_HDD", + "maintenanceWindow": { + "kind": "sql#maintenanceWindow", + "hour": 0, + "day": 0 + }, + "settingsVersion": "3", + "storageAutoResizeLimit": "0", + "storageAutoResize": false, + "dataDiskSizeGb": "10" + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "name": "khulnasoft-instance", + "region": "us-central1", + "gceZone": "us-central1-f" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if instance does not have log_checkpoints flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL instance does not have log_checkpoints flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "kind": "sql#instance", + "state": "RUNNABLE", + "databaseVersion": "POSTGRES_13", + "settings": { + "authorizedGaeApplications": [], + "tier": "db-custom-1-3840", + "kind": "sql#settings", + "availabilityType": "ZONAL", + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "activationPolicy": "ALWAYS", + "locationPreference": { + "zone": "us-central1-f", + "kind": "sql#locationPreference" + }, + "databaseFlags": [ + { + "name": "log_checkpoints", + "value": "off" + } + ], + "dataDiskType": "PD_HDD", + "maintenanceWindow": { + "kind": "sql#maintenanceWindow", + "hour": 0, + "day": 0 + }, + "settingsVersion": "3", + "storageAutoResizeLimit": "0", + "storageAutoResize": false, + "dataDiskSizeGb": "10" + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "name": "khulnasoft-instance", + "region": "us-central1", + "gceZone": "us-central1-f" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if instance does not support log_checkpoints flag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database version is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "kind": "sql#instance", + "state": "RUNNABLE", + "databaseVersion": "MYSQL_5_7", + "settings": { + "authorizedGaeApplications": [], + "tier": "db-custom-1-3840", + "kind": "sql#settings", + "availabilityType": "ZONAL", + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "activationPolicy": "ALWAYS", + "locationPreference": { + "zone": "us-central1-f", + "kind": "sql#locationPreference" + }, + "databaseFlags": [], + "dataDiskType": "PD_HDD", + "maintenanceWindow": { + "kind": "sql#maintenanceWindow", + "hour": 0, + "day": 0 + }, + "settingsVersion": "3", + "storageAutoResizeLimit": "0", + "storageAutoResize": false, + "dataDiskSizeGb": "10" + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "name": "khulnasoft-instance", + "region": "us-central1", + "gceZone": "us-central1-f" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) diff --git a/plugins/google/sql/postgresqlLogConnections.js b/plugins/google/sql/postgresqlLogConnections.js new file mode 100644 index 000000000..200658905 --- /dev/null +++ b/plugins/google/sql/postgresqlLogConnections.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Connections Flag Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log connections flag enabled.', + more_info: 'SQL instance for PostgreSQL databases provides log_connections flag. It is used to log every attempt to connect to the db server. It is not enabled by default. Enabling it will make sure to log all connection tries', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log connections flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_connections' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance have log_connections flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_connections flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_connections flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogConnections.spec.js b/plugins/google/sql/postgresqlLogConnections.spec.js new file mode 100644 index 000000000..f88f4fcc5 --- /dev/null +++ b/plugins/google/sql/postgresqlLogConnections.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogConnections'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogConnections', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_connections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_connections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_connections", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_connections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_connections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_connections", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_connections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_connections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_connections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_connections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogDisconnections.js b/plugins/google/sql/postgresqlLogDisconnections.js new file mode 100644 index 000000000..b0bb7153e --- /dev/null +++ b/plugins/google/sql/postgresqlLogDisconnections.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Disconnections Flag Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log disconnections flag enabled.', + more_info: 'SQL instance for PostgreSQL databases provides log_disconnections flag. It is used to log every attempt to connect to the DB server. It is not enabled by default. Enabling it will make sure to log anyone who disconnects from the instance.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log disconnections flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_disconnections' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance have log_disconnections flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_disconnections flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_disconnections flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogDisconnections.spec.js b/plugins/google/sql/postgresqlLogDisconnections.spec.js new file mode 100644 index 000000000..eafcaff98 --- /dev/null +++ b/plugins/google/sql/postgresqlLogDisconnections.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogDisconnections'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogDisconnections', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_disconnections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_disconnections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_disconnections", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_disconnections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_disconnections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_disconnections", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_disconnections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_disconnections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_disconnections flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_disconnections flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogErrorVerbosity.js b/plugins/google/sql/postgresqlLogErrorVerbosity.js new file mode 100644 index 000000000..1b6af6f26 --- /dev/null +++ b/plugins/google/sql/postgresqlLogErrorVerbosity.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Error Verbosity', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure SQL instances for PostgreSQL type have log error verbosity flag set to default or stricter.', + more_info: 'SQL instance for PostgreSQL databases provides log_error_verbosity flag to control the verbosity/details of the messages logged. if this flag is not set correctly too many or too few statements can be logged which can cause problems while troubleshooting.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_error_verbosity flag is set to at least default for all PostgreSQL instances.', + apis: ['sql:list'], + settings: { + log_error_verbosity: { + name: 'Log Error verbosity', + description: 'Return a passing result if the flag value is used from the setting list.', + regex: '^(default|verbose)$', + default: 'default' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + const LOG_LEVELS = ['default', 'verbose']; + + var log_error_verbosity = settings.log_error_verbosity || this.settings.log_error_verbosity.default; + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + let found; + log_error_verbosity = log_error_verbosity.toLowerCase(); + let currentLevelValue = 'default'; + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_error_verbosity' && + flag.value); + } + if (found) { + currentLevelValue = found.value; + } + let currentLevel = LOG_LEVELS.indexOf(currentLevelValue.toLowerCase()); + let expectedLevel = LOG_LEVELS.indexOf(log_error_verbosity); + + if (currentLevel >= expectedLevel) { + helpers.addResult(results, 0, + `SQL instance has log_error_verbosity flag set to "${currentLevelValue}" which is greater than or equal to "${log_error_verbosity}"`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance has log_error_verbosity flag set to "${currentLevelValue}" which is less than "${log_error_verbosity}"`, region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogErrorVerbosity.spec.js b/plugins/google/sql/postgresqlLogErrorVerbosity.spec.js new file mode 100644 index 000000000..a9c242d55 --- /dev/null +++ b/plugins/google/sql/postgresqlLogErrorVerbosity.spec.js @@ -0,0 +1,214 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogErrorVerbosity'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogErrorVerbosity', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has log_error_verbosity flag set to default', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_error_verbosity", + value: 'default', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance has log_error_verbosity flag set to default or stricter', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_error_verbosity", + value: 'VERBOSE', + }, + ]} + }], + ); + + plugin.run(cache, { log_error_verbosity: 'FATAL' }, callback); + }); + + it('should give failing result if sql instance does not have log_error_verbosity flag set to default or stricter', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_error_verbosity", + value: 'terse', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has log_error_verbosity flag set to default', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has log_error_verbosity flag set to default', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogExecutorStats.js b/plugins/google/sql/postgresqlLogExecutorStats.js new file mode 100644 index 000000000..020b14e7c --- /dev/null +++ b/plugins/google/sql/postgresqlLogExecutorStats.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Executor Stats Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log_executor_stats flag set to "off".', + more_info: 'To avoid any performance issues caused by an increased volume of logs, it is recommended that the "log_executor_stats" flag is set to off.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_executor_stats flag is disabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + let found; + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_executor_stats' && + flag.value && flag.value == 'on'); + } + + if (found) { + helpers.addResult(results, 2, + 'SQL instance has log_executor_stats flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have log_executor_stats flag enabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/postgresqlLogExecutorStats.spec.js b/plugins/google/sql/postgresqlLogExecutorStats.spec.js new file mode 100644 index 000000000..93756337f --- /dev/null +++ b/plugins/google/sql/postgresqlLogExecutorStats.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogExecutorStats'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogExecutorStats', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_executor_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_executor_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_executor_stats", + value: 'off', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has log_executor_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has log_executor_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_executor_stats", + value: 'on', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have log_executor_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_executor_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_executor_stats flag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_executor_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/postgresqlLogHostname.js b/plugins/google/sql/postgresqlLogHostname.js new file mode 100644 index 000000000..21e71aaab --- /dev/null +++ b/plugins/google/sql/postgresqlLogHostname.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Hostname Flag Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log hostname flag enabled.', + more_info: 'SQL instance for PostgreSQL databases provides log_hostname flag. It controls the logging of hostnames in addition to the IP addresses logged which helps in incident response efforts particularly in an environment that utilized dynamic IP addresses.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log hostname flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_hostname' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has log_hostname flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_hostname flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_hostname flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogHostname.spec.js b/plugins/google/sql/postgresqlLogHostname.spec.js new file mode 100644 index 000000000..12bc2b000 --- /dev/null +++ b/plugins/google/sql/postgresqlLogHostname.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogHostname'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogHostname', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_hostname flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has log_hostname flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_hostname", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_hostname flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_hostname flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_hostname", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_hostname flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_hostname flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_hostname flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_hostname flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogLockWaits.js b/plugins/google/sql/postgresqlLogLockWaits.js new file mode 100644 index 000000000..6dea75b72 --- /dev/null +++ b/plugins/google/sql/postgresqlLogLockWaits.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Lock Waits Flag Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log_lock_waits flag enabled.', + more_info: 'SQL instance for PostgreSQL database provides log_lock_waits flag. It is not enabled by default. Enabling it will make sure that log messages are generated whenever a session waits longer than deadlock_timeout to acquire a lock.', + link: 'https://cloud.google.com/sql/docs/postgres/flags#config', + recommended_action: 'Ensure that log_lock_waits flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of postgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_lock_waits' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance have log_lock_waits flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_lock_waits flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_lock_waits flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogLockWaits.spec.js b/plugins/google/sql/postgresqlLogLockWaits.spec.js new file mode 100644 index 000000000..53c3888a0 --- /dev/null +++ b/plugins/google/sql/postgresqlLogLockWaits.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogLockWaits'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogLockWaits', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of postgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_lock_waits flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_lock_waits flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_lock_waits", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_lock_waits flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_lock_waits flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_lock_waits", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_lock_waits flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_lock_waits flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_lock_waits flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_lock_waits flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogMinDuration.js b/plugins/google/sql/postgresqlLogMinDuration.js new file mode 100644 index 000000000..d503b9222 --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinDuration.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Min Duration Statement', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log min duration statement flag disabled.', + more_info: 'SQL instance for PostgreSQL databases provides log_min_duration_statement flag. It is used to log the duration of every completed statement. This should always be disabled as there can be sensitive information as well that should not be recorded in the logs.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_min_duration_statement flag is disabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_min_duration_statement' && + flag.value && flag.value == '-1'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance does not have log_min_duration_statement flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance have log_min_duration_statement flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have log_min_duration_statement flag enabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogMinDuration.spec.js b/plugins/google/sql/postgresqlLogMinDuration.spec.js new file mode 100644 index 000000000..592346dd9 --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinDuration.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogMinDuration'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogMinDuration', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_min_duration_statement flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_min_duration_statement flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_duration_statement", + value: '-1', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances have log_min_duration_statement flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance have log_min_duration_statement flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_duration_statement", + value: '0', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances have log_min_duration_statement flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance have log_min_duration_statement flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_min_duration_statement flag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_min_duration_statement flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogMinError.js b/plugins/google/sql/postgresqlLogMinError.js new file mode 100644 index 000000000..28914ae21 --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinError.js @@ -0,0 +1,93 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Min Error Statement', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log min error statement flag set to Error.', + more_info: 'SQL instance for PostgreSQL databases provides log_min_error_statement flag. It is used to mention/tag that the error messages. Setting it to Error value will help to find the error messages appropriately.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_min_error_statement flag is set to Error for all PostgreSQL instances.', + apis: ['sql:list'], + settings: { + log_min_error_statement: { + name: 'Log Min Error Statement', + description: 'Return a passing result if the flag value is used from the setting list.', + regex: '^(LOG|FATAL|PANIC)$', + default: 'ERROR' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + var log_min_error_statement = settings.log_min_error_statement || this.settings.log_min_error_statement.default; + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_min_error_statement' && + flag.value && flag.value == log_min_error_statement); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance have log_min_error_statement flag set to Error', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_min_error_statement flag set to Error', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_min_error_statement flag set to Error', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogMinError.spec.js b/plugins/google/sql/postgresqlLogMinError.spec.js new file mode 100644 index 000000000..78d80ad82 --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinError.spec.js @@ -0,0 +1,214 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogMinError'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogMinError', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_min_error_statement flag set to Error', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_min_error_statement flag set to Error'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_error_statement", + value: 'ERROR', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instances have log_min_error_statement flag set to Error or Stricter', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_min_error_statement flag set to Error'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_error_statement", + value: 'FATAL', + }, + ]} + }], + ); + + plugin.run(cache, { log_min_error_statement: 'FATAL' }, callback); + }); + + it('should give failing result if sql instances does not have log_min_error_statement flag set to Error', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_min_error_statement flag set to Error'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_error_statement", + value: 'WARNING', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_min_error_statement flag set to Error', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_min_error_statement flag set to Error'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_min_error_statement flag set to Error', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_min_error_statement flag set to Error'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogMinMessages.js b/plugins/google/sql/postgresqlLogMinMessages.js new file mode 100644 index 000000000..7e89edeea --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinMessages.js @@ -0,0 +1,102 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Min Messages', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log min messages flag set to Warning or stricter.', + more_info: 'SQL instance for PostgreSQL databases provides log_min_messages flag. It is used to define the minimum message severity level that is considered as an error statement.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_min_messages flag is set to at least Warning for all PostgreSQL instances.', + apis: ['sql:list'], + settings: { + log_min_messages: { + name: 'Log Min Messages', + description: 'Return a passing result if the flag value is used from the setting list.', + regex: '^(error|log|fatal|panic)$', + default: 'warning' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + const LOG_LEVELS = ['warning', 'error', 'log', 'fatal', 'panic']; + + var log_min_messages = settings.log_min_messages || this.settings.log_min_messages.default; + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + let found; + log_min_messages = log_min_messages.toLowerCase(); + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_min_messages' && + flag.value); + if (found) { + let currentLevel = LOG_LEVELS.indexOf(found.value.toLowerCase()); + let expectedLevel = LOG_LEVELS.indexOf(log_min_messages); + + if (currentLevel >= expectedLevel) { + helpers.addResult(results, 0, + `SQL instance has log_min_messages flag set to "${found.value}" which is greater than or equal to "${log_min_messages}"`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance has log_min_messages flag set to "${found.value}" which is less than "${log_min_messages}"`, region, resource); + } + } + } + + if (!found) { + helpers.addResult(results, 2, + 'SQL instance does not have log_min_messages flag set to at least Warning', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogMinMessages.spec.js b/plugins/google/sql/postgresqlLogMinMessages.spec.js new file mode 100644 index 000000000..f8a870804 --- /dev/null +++ b/plugins/google/sql/postgresqlLogMinMessages.spec.js @@ -0,0 +1,214 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogMinMessages'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogMinMessages', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_min_messages flag is set to warning', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_messages", + value: 'WARNING', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instances has log_min_messages flag set to Warning or Stricter', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_messages", + value: 'FATAL', + }, + ]} + }], + ); + + plugin.run(cache, { log_min_messages: 'FATAL' }, callback); + }); + + it('should give failing result if sql instances does not have log_min_messages flag set to Warning or stricter', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_min_messages", + value: 'debug5', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_min_messages flag set to at least Warning', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_min_messages flag set to at least Warning'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_min_messages flag set to at least Warning', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_min_messages flag set to at least Warning'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogParserStats.js b/plugins/google/sql/postgresqlLogParserStats.js new file mode 100644 index 000000000..26189c883 --- /dev/null +++ b/plugins/google/sql/postgresqlLogParserStats.js @@ -0,0 +1,83 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Parser Stats Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log_parser_stats flag set to "off".', + more_info: 'Logging parser performance stats can substantially raise the volume of logs and result in performance overhead. To prevent any performance issues caused by excessive logging, it is recommended that the "log_parser_stats" flag is set to off.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_parser_stats flag is disabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + let found; + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_parser_stats' && + flag.value && flag.value == 'on'); + } + + if (found) { + helpers.addResult(results, 2, + 'SQL instance has log_parser_stats flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have log_parser_stats flag enabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogParserStats.spec.js b/plugins/google/sql/postgresqlLogParserStats.spec.js new file mode 100644 index 000000000..adb1db9da --- /dev/null +++ b/plugins/google/sql/postgresqlLogParserStats.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogParserStats'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogParserStats', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_parser_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_parser_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_parser_stats", + value: 'off', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has log_parser_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has log_parser_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_parser_stats", + value: 'on', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have log_parser_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_parser_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_parser_stats flag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_parser_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogPlannerStats.js b/plugins/google/sql/postgresqlLogPlannerStats.js new file mode 100644 index 000000000..38aa99bb2 --- /dev/null +++ b/plugins/google/sql/postgresqlLogPlannerStats.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Planner Stats Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log_planner_stats flag set to "off".', + more_info: 'Logging planner performance statistics can lead to an increased amount of logs and can impact performance. It is recommended that the "log_planner_stats" flag is set to off to avoid performance issues caused by excessive logging.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_planner_stats flag is disabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + let found; + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_planner_stats' && + flag.value && flag.value == 'on'); + } + + if (found) { + helpers.addResult(results, 2, + 'SQL instance has log_planner_stats flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have log_planner_stats flag enabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/postgresqlLogPlannerStats.spec.js b/plugins/google/sql/postgresqlLogPlannerStats.spec.js new file mode 100644 index 000000000..bc5c887b7 --- /dev/null +++ b/plugins/google/sql/postgresqlLogPlannerStats.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogPlannerStats'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogPlannerStats', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_planner_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_planner_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_planner_stats", + value: 'off', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has log_planner_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has log_planner_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_planner_stats", + value: 'on', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have log_planner_stats flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_planner_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have log_planner_stats flag', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have log_planner_stats flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/postgresqlLogStatement.js b/plugins/google/sql/postgresqlLogStatement.js new file mode 100644 index 000000000..fca3db307 --- /dev/null +++ b/plugins/google/sql/postgresqlLogStatement.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Statement', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log statement flag set to desired value.', + more_info: 'SQL instance for PostgreSQL databases provides log_statement flag which can be set to align with your organization security and logging policies facilitates later auditing and review of database activities. Not having it set to the appropriate value can cause too many or too few statements to be logged.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_statement flag is set to desired value.', + apis: ['sql:list'], + settings: { + log_statement: { + name: 'Log Statement', + description: 'Return a passing result if the flag value is used from the setting list.', + regex: '^(ddl|mod|all)$', + default: 'ddl' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + var log_statement = settings.log_statement || this.settings.log_statement.default; + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + let found; + log_statement = log_statement.toLowerCase(); + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_statement' && + flag.value); + if (found) { + if (found.value == log_statement) { + helpers.addResult(results, 0, + `SQL instance has log_statement flag set to "${found.value}"`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance has log_statement flag set to "${found.value}" instead of "${log_statement}"`, region, resource); + } + } + } + + if (!found) { + helpers.addResult(results, 2, + `SQL instance does not have log_statement flag set to "${log_statement}"`, region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlLogStatement.spec.js b/plugins/google/sql/postgresqlLogStatement.spec.js new file mode 100644 index 000000000..64f24a680 --- /dev/null +++ b/plugins/google/sql/postgresqlLogStatement.spec.js @@ -0,0 +1,187 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogStatement'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogStatement', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_statement flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has log_statement flag set to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_statement", + value: 'ddl', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance does not have log_statement flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('instead of'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_statement", + value: 'mod', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instances does not have log_statement flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_statement flag set to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_statement flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_statement flag set'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogTempFiles.js b/plugins/google/sql/postgresqlLogTempFiles.js new file mode 100644 index 000000000..71fccc83b --- /dev/null +++ b/plugins/google/sql/postgresqlLogTempFiles.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Log Temp Files', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have log temp files flag enabled.', + more_info: 'SQL instance for PostgreSQL databases provides log_temp_files flag. It is used to log the temporary files name and size. It is not enabled by default. Enabling it will make sure to log names and sizes of all the temporary files that were created during any operation(sort, hashes, query_results etc).', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that log_temp_files flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'log_temp_files' && + flag.value && flag.value == '0'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance have log_temp_files flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_temp_files flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have log_temp_files flag enabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/sql/postgresqlLogTempFiles.spec.js b/plugins/google/sql/postgresqlLogTempFiles.spec.js new file mode 100644 index 000000000..dc28e2bbf --- /dev/null +++ b/plugins/google/sql/postgresqlLogTempFiles.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlLogTempFiles'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlLogTempFiles', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have log_temp_files flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance have log_temp_files flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_temp_files", + value: '0', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_temp_files flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_temp_files flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_temp_files", + value: '-1', + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_temp_files flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_temp_files flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have log_temp_files flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have log_temp_files flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlMaxConnections.js b/plugins/google/sql/postgresqlMaxConnections.js new file mode 100644 index 000000000..c1965691d --- /dev/null +++ b/plugins/google/sql/postgresqlMaxConnections.js @@ -0,0 +1,113 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Max Connections', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that max_connections is configured with optimal value for PostgreSQL instances.', + more_info: 'An optimal value should be set for max_connections (maximum number of client connections) to meet the database workload requirements. ' + + 'If this no value is set for max_connections flag, instance assumes default value which is calculated per instance memory size.', + link: 'https://cloud.google.com/sql/docs/postgres/flags#setting_a_database_flag', + recommended_action: 'Ensure that all PostgreSQL database instances have log_checkpoints flag and it value is set to on.', + apis: ['sql:list'], + settings: { + min_postgres_max_connections: { + name: 'Minimum PostgreSQL Max Connections', + description: 'Minimum value set for max_connections flag', + regex: '^.*$', + default: '' + }, + allow_default_max_connections_value: { + name: 'Allow Default Max Connections Value', + description: 'True or false, whether to allow PostgreSQL instances to use default value for max_connections flag. '+ + 'This setting is checked only when max_connections flag value is not set', + regex: '^(true|false)$', + default: 'true' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + var config = { + maxConnections: settings.min_postgres_max_connections || this.settings.min_postgres_max_connections.default, + allow_default: settings.allow_default_max_connections_value || this.settings.allow_default_max_connections_value.default + }; + + if (!config.maxConnections.length) return callback(null, results, source); + + config.maxConnections = parseInt(config.maxConnections); + config.allow_default = (config.allow_default == 'true'); + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() == 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('POSTGRES')) { + helpers.addResult(results, 0, 'SQL instance database version is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'max_connections')) { + let maxConnectionsFlag = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'max_connections') || {}; + let maxConnections = parseInt(maxConnectionsFlag.value || 0); + if (maxConnections >= config.maxConnections) { + helpers.addResult(results, 0, + `PostgreSQL instance max_connection value is ${maxConnections} which is greater than or equal to ${config.maxConnections}`, region, resource); + } else { + helpers.addResult(results, 2, + `PostgreSQL instance max_connection value is ${maxConnections} which is les than ${config.maxConnections}`, region, resource); + } + } else { + if (config.allow_default) { + helpers.addResult(results, 0, + 'PostgreSQL instance does not have max_connections value set and is using default value', region, resource); + } else { + helpers.addResult(results, 2, + 'PostgreSQL instance does not have max_connections value set and is using default value', region, resource); + } + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlMaxConnections.spec.js b/plugins/google/sql/postgresqlMaxConnections.spec.js new file mode 100644 index 000000000..9a9b077f1 --- /dev/null +++ b/plugins/google/sql/postgresqlMaxConnections.spec.js @@ -0,0 +1,184 @@ +var expect = require('chai').expect; +var plugin = require('./postgresqlMaxConnections'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlMaxConnections', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, { min_postgres_max_connections: '15' }, callback); + }); + + it('should give passing result if sql instance database type is not of PostgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database version is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, { min_postgres_max_connections: '15' }, callback); + }); + + it('should give passing result if PostgreSQL instance max_connection value is is greater than or equal to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "max_connections", + value: "15", + }, + ]} + }], + ); + + plugin.run(cache, { min_postgres_max_connections: '15' }, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, { min_postgres_max_connections: '20' }, callback); + }); + + it('should give failing result if PostgreSQL instance max_connection value is les than desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is les than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "max_connections", + value: "15", + }, + ]} + }], + ); + + plugin.run(cache, { min_postgres_max_connections: '20' }, callback); + }); + + it('should give passing result if allow default max connections value setting is true and mex_connections flag value is not set', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('PostgreSQL instance does not have max_connections value set and is using default value'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + ]} + }], + ); + + plugin.run(cache, { min_postgres_max_connections: '20', allow_default_max_connections_value: 'true' }, callback); + }); + + it('should give failing result if allow default max connections value setting is false and mex_connections flag value is not set', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('PostgreSQL instance does not have max_connections value set and is using default value'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + ]} + }], + ); + + plugin.run(cache, { min_postgres_max_connections: '20', allow_default_max_connections_value: 'false' }, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/postgresqlPgAuditEnabled.js b/plugins/google/sql/postgresqlPgAuditEnabled.js new file mode 100644 index 000000000..e4827817e --- /dev/null +++ b/plugins/google/sql/postgresqlPgAuditEnabled.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'PostgreSQL Pg Audit Flag Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances for PostgreSQL type have cloudsql.enable_pgaudit flag enabled for centralized logging.', + more_info: 'SQL instance for PostgreSQL databases provides cloudsql.enable_pgaudit flag which provides detailed session and object logging to comply with government, financial, & ISO standards and provides auditing capabilities to mitigate threats by monitoring security events on the instance.', + link: 'https://cloud.google.com/sql/docs/postgres/flags', + recommended_action: 'Ensure that cloudsql.enable_pgaudit flag is enabled for all PostgreSQL instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('postgres')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of PostgreSQL type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'cloudsql.enable_pgaudit' && + flag.value && flag.value == 'on'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has cloudsql.enable_pgaudit flag enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance does not have cloudsql.enable_pgaudit flag enabled', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have cloudsql.enable_pgaudit flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/postgresqlPgAuditEnabled.spec.js b/plugins/google/sql/postgresqlPgAuditEnabled.spec.js new file mode 100644 index 000000000..de61ad3a9 --- /dev/null +++ b/plugins/google/sql/postgresqlPgAuditEnabled.spec.js @@ -0,0 +1,186 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./postgresqlPgAuditEnabled'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('postgresqlPgAuditEnabled', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of postgreSQL type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of PostgreSQL type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances have cloudsql.enable_pgaudit flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has cloudsql.enable_pgaudit flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "cloudsql.enable_pgaudit", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have cloudsql.enable_pgaudit flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have cloudsql.enable_pgaudit flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "cloudsql.enable_pgaudit", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instances does not have cloudsql.enable_pgaudit flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have cloudsql.enable_pgaudit flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have cloudsql.enable_pgaudit flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance does not have cloudsql.enable_pgaudit flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/serverCertificateRotation.js b/plugins/google/sql/serverCertificateRotation.js new file mode 100644 index 000000000..387e08517 --- /dev/null +++ b/plugins/google/sql/serverCertificateRotation.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SSL Certificate Rotation', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that server certificates configured for Cloud SQL are rotated before they expire.', + more_info: 'Server certificates configured for Cloud SQL DB instances should be rotated before they expire to ensure ' + + 'that incoming connections for database instance remain secure.', + link: 'https://cloud.google.com/sql/docs/postgres/configure-ssl-instance?authuser=1#server-certs', + recommended_action: 'Edit Cloud SQL DB instances and rotate server certificates under Connections->MANAGE CERTIFICATES', + apis: ['sql:list'], + settings: { + server_certicate_expiration_threshold: { + name: 'SQL Server Certificate Expiration Threshold', + description: 'Number of days in future before which SSL certiciates should be rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers:['cloudsql.instances.rotateServerCa'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + expiryThreshold: parseInt(settings.server_certicate_expiration_threshold || this.settings.server_certicate_expiration_threshold.default) + }; + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() == 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.serverCaCert && + sqlInstance.serverCaCert.expirationTime) { + let certExpiry = sqlInstance.serverCaCert.expirationTime; + let difference = Math.round((new Date(certExpiry).getTime() - new Date().getTime())/(24*60*60*1000)); + if (difference >= config.expiryThreshold) { + helpers.addResult(results, 0, + `SQL instance SSL certificate will expire after ${difference} days`, region, resource); + } else if (difference < 0) { + helpers.addResult(results, 2, + 'SQL instance SSL certificate has already expired', region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance SSL certificate will expire after ${difference} days`, region, resource); + } + } else { + helpers.addResult(results, 3, + 'Unable to find certicite info for instance', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/serverCertificateRotation.spec.js b/plugins/google/sql/serverCertificateRotation.spec.js new file mode 100644 index 000000000..c0af6bd4c --- /dev/null +++ b/plugins/google/sql/serverCertificateRotation.spec.js @@ -0,0 +1,115 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./serverCertificateRotation'); + +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() + 7); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() + 1); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('serverCertificateExpiry', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if SQL instance SSL certificate will expire after more than threshold expiry days', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance SSL certificate will expire after'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + serverCaCert: { + expirationTime: passDate + } + }], + ); + + plugin.run(cache, { server_certicate_expiration_threshold: '50' }, callback); + }); + + it('should give failing result if SQL instance SSL certificate will expire less than threshold expiry days', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance SSL certificate will expire after'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + serverCaCert: { + expirationTime: failDate + } + }], + ); + + plugin.run(cache, { server_certicate_expiration_threshold: '50' }, callback); + }); + + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlCMKEncryption.js b/plugins/google/sql/sqlCMKEncryption.js new file mode 100644 index 000000000..a193f9417 --- /dev/null +++ b/plugins/google/sql/sqlCMKEncryption.js @@ -0,0 +1,112 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL CMK Encryption', + category: 'SQL', + domain: 'Databases', + severity: 'High', + description: 'Ensure that Cloud SQL instances are encrypted using Customer Managed Keys (CMKs).', + more_info: 'By default, your Google Cloud SQL instances are encrypted using Google-managed keys. To have a better control over the encryption process of your Cloud SQL instances you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/sql/docs/sqlserver/cmek', + recommended_action: 'Ensure that all Google Cloud SQL instances have desired encryption level.', + apis: ['sql:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + sql_encryption_protection_level: { + name: 'SQL Encryption Protection Level', + description: 'Desired protection level for Google Cloud SQL instances. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.sql_encryption_protection_level || this.settings.sql_encryption_protection_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + let currentEncryptionLevel; + + if (sqlInstance.diskEncryptionConfiguration && sqlInstance.diskEncryptionConfiguration.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[sqlInstance.diskEncryptionConfiguration.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `SQL instance has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + }); + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/google/sql/sqlCMKEncryption.spec.js b/plugins/google/sql/sqlCMKEncryption.spec.js new file mode 100644 index 000000000..8f1d35f7c --- /dev/null +++ b/plugins/google/sql/sqlCMKEncryption.spec.js @@ -0,0 +1,148 @@ +var expect = require('chai').expect; +var plugin = require('./sqlCMKEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const createCache = (err, data, keysList, keysErr) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlCMKEncryption', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + storageAutoResizeLimit: '100', + storageAutoResize: false, + } + }], + cryptoKeys, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if SQL instance has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + storageAutoResizeLimit: '100', + storageAutoResize: true, + }, + diskEncryptionConfiguration: { + kmsKeyName: 'projects/test-dev-khulnasoft/locations/global/keyRings/test-kr/cryptoKeys/test-key-2', + kind: 'sql#diskEncryptionConfiguration' + }, + }], + cryptoKeys, + null + ); + + plugin.run(cache, { sql_storage_auto_increase_limit: '150'}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlContainedDatabaseAuth.js b/plugins/google/sql/sqlContainedDatabaseAuth.js new file mode 100644 index 000000000..faa03a203 --- /dev/null +++ b/plugins/google/sql/sqlContainedDatabaseAuth.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Contained Database Authentication', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL instances of SQL Server type have Contained Database Authentication flag disabled.', + more_info: 'Enabling Contained Database Authentication flag allows users to connect to the database without authenticating ' + + 'a login at the Database Engine level along with other security threats.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that Contained Database Authentication flag is disabled for all SQL Server instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() == 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('sqlserver')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && + flag.name == 'contained database authentication' && flag.value && flag.value == 'off'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has contained database authentication flag disabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance has contained database authentication flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have any flags', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlContainedDatabaseAuth.spec.js b/plugins/google/sql/sqlContainedDatabaseAuth.spec.js new file mode 100644 index 000000000..f3dc543e7 --- /dev/null +++ b/plugins/google/sql/sqlContainedDatabaseAuth.spec.js @@ -0,0 +1,159 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./sqlContainedDatabaseAuth'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlContainedDatabaseAuth', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if SQL instance has contained database authentication flag disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has contained database authentication flag disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [ + { + name: "contained database authentication", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances has contained database authentication flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has contained database authentication flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [ + { + name: "contained database authentication", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instances does not have any flags', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have any flags'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlCrossDbOwnership.js b/plugins/google/sql/sqlCrossDbOwnership.js new file mode 100644 index 000000000..86a70ff76 --- /dev/null +++ b/plugins/google/sql/sqlCrossDbOwnership.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Cross DB Ownership Chaining', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures SQL database instances of SQL Server type have cross db ownership chaining flag disabled.', + more_info: 'SQL databases of SQL Server provide cross DB ownership chaining flag. It is used to configure cross-database ownership chaining ' + + 'for all databases. It is enabled by default and should be disabled for security unless all required.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that cross DB ownership chaining flag is disabled for all SQLServer instances.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() == 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toLowerCase().includes('sqlserver')) { + helpers.addResult(results, 0, + 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags && + sqlInstance.settings.databaseFlags.length) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && + flag.name == 'cross db ownership chaining' && flag.value && flag.value == 'off'); + + if (found) { + helpers.addResult(results, 0, + 'SQL instance has cross DB ownership chaining flag disabled', region, resource); + } else { + helpers.addResult(results, 2, + 'SQL instance has cross DB ownership chaining flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have any flags', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlCrossDbOwnership.spec.js b/plugins/google/sql/sqlCrossDbOwnership.spec.js new file mode 100644 index 000000000..2b844b8a0 --- /dev/null +++ b/plugins/google/sql/sqlCrossDbOwnership.spec.js @@ -0,0 +1,185 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./sqlCrossDbOwnership'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlCrossDbOwnership', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if SQL instance has cross DB ownership chaining flag disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has cross DB ownership chaining flag disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [ + { + name: "cross db ownership chaining", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances has cross DB ownership chaining flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has cross DB ownership chaining flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [ + { + name: "cross db ownership chaining", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances has cross DB ownership chaining flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has cross DB ownership chaining flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [ + { + name: "log_checkpoints", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instances does not have any flags', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have any flags'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_13", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlInstanceLabelsAdded.js b/plugins/google/sql/sqlInstanceLabelsAdded.js new file mode 100644 index 000000000..ea44903e5 --- /dev/null +++ b/plugins/google/sql/sqlInstanceLabelsAdded.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Instance Labels Added', + category: 'SQL', + domain: 'Databases', + severity: 'Low', + description: 'Ensures SQL database instances have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/sql/docs/mysql/label-instance', + recommended_action: 'Ensure labels are added for all SQL databases.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.settings && sqlInstance.settings.userLabels && + Object.keys(sqlInstance.settings.userLabels).length) { + helpers.addResult(results, 0, + `${Object.keys(sqlInstance.settings.userLabels).length} labels found for the SQL database`, region, resource); + } else { + helpers.addResult(results, 2, + 'SQL database does not have any labels', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlInstanceLabelsAdded.spec.js b/plugins/google/sql/sqlInstanceLabelsAdded.spec.js new file mode 100644 index 000000000..7a250ba2f --- /dev/null +++ b/plugins/google/sql/sqlInstanceLabelsAdded.spec.js @@ -0,0 +1,167 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./sqlInstanceLabelsAdded'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlInstanceLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if SQL instance has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for the SQL database'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "kind": "sql#instance", + "state": "RUNNABLE", + "databaseVersion": "MYSQL_5_7", + "settings": { + "authorizedGaeApplications": [], + "tier": "db-f1-micro", + "kind": "sql#settings", + "userLabels": { 'test-2': 'data', 'tes-1': 'label' }, + "availabilityType": "ZONAL", + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "activationPolicy": "ALWAYS", + "ipConfiguration": { + "authorizedNetworks": [], + "ipv4Enabled": true, + "requireSsl": true + } + }, + "etag": "5b3b8471ede5b88da8e005062775991862c410f94b6630248538a5fc1bfa41d5", + "ipAddresses": [ + { + "type": "PRIMARY", + "ipAddress": "35.243.130.180" + } + ], + "instanceType": "CLOUD_SQL_INSTANCE", + "project": "rosy-booth-253119", + "serviceAccountEmailAddress": "p293348421062-t9yegr@gcp-sa-cloud-sql.iam.gserviceaccount.com", + "backendType": "SECOND_GEN", + "selfLink": "https://www.googleapis.com/sql/v1beta4/projects/rosy-booth-253119/instances/mysqltest1", + "connectionName": "rosy-booth-253119:us-east1:mysqltest1", + "name": "mysqltest1", + "region": "us-east1", + "gceZone": "us-east1-c" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [ + { + "kind": "sql#instance", + "state": "RUNNABLE", + "databaseVersion": "MYSQL_5_7", + "settings": { + "authorizedGaeApplications": [], + "tier": "db-f1-micro", + "kind": "sql#settings", + "availabilityType": "ZONAL", + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "activationPolicy": "ALWAYS", + "ipConfiguration": { + "authorizedNetworks": [], + "ipv4Enabled": true, + } + }, + "etag": "5b3b8471ede5b88da8e005062775991862c410f94b6630248538a5fc1bfa41d5", + "ipAddresses": [ + { + "type": "PRIMARY", + "ipAddress": "35.243.130.180" + } + ], + "instanceType": "CLOUD_SQL_INSTANCE", + "project": "rosy-booth-253119", + "serviceAccountEmailAddress": "p293348421062-t9yegr@gcp-sa-cloud-sql.iam.gserviceaccount.com", + "backendType": "SECOND_GEN", + "selfLink": "https://www.googleapis.com/sql/v1beta4/projects/rosy-booth-253119/instances/mysqltest1", + "connectionName": "rosy-booth-253119:us-east1:mysqltest1", + "name": "mysqltest1", + "region": "us-east1", + "gceZone": "us-east1-c" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlNoPublicIps.js b/plugins/google/sql/sqlNoPublicIps.js new file mode 100644 index 000000000..ac839d287 --- /dev/null +++ b/plugins/google/sql/sqlNoPublicIps.js @@ -0,0 +1,76 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL No Public IPs', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that SQL instances are using private IPs instead of public IPs.', + more_info: 'Cloud SQL databases should always use private IP addresses which provide improved network security and lower latency.', + link: 'https://cloud.google.com/sql/docs/mysql/configure-private-ip', + recommended_action: 'Make sure that SQL databases IP addresses setting does not have IP address of PRIMARY type', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.delete','cloudsql.instances.create','cloudsql.instances.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.ipAddresses && + sqlInstance.ipAddresses.length) { + let found = sqlInstance.ipAddresses.find(address => address.type.toUpperCase() == 'PRIMARY'); + + if (found) { + helpers.addResult(results, 2, + 'SQL instance has public IPs', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have public IPs', region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance does not have public IPs', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlNoPublicIps.spec.js b/plugins/google/sql/sqlNoPublicIps.spec.js new file mode 100644 index 000000000..94fcef4b0 --- /dev/null +++ b/plugins/google/sql/sqlNoPublicIps.spec.js @@ -0,0 +1,120 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./sqlNoPublicIps'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlPublicIps', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instances does not have public IPs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have public IPs'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + ipAddresses: [ + { + type: "PRIVATE", + ipAddress: "10.148.80.3", + }, + ], + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instances have public IPs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has public IPs'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + ipAddresses: [ + { + type: "PRIMARY", + ipAddress: "35.194.54.65", + }, + { + type: "PRIVATE", + ipAddress: "10.148.80.3", + }, + ], + }], + ); + + plugin.run(cache, {}, callback); + }); + + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlServerContainedDBAuth.js b/plugins/google/sql/sqlServerContainedDBAuth.js new file mode 100644 index 000000000..4ef13ab70 --- /dev/null +++ b/plugins/google/sql/sqlServerContainedDBAuth.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server Contained Database Authentication Flag Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that contained database authentication flag is disabled for SQL Server instances.', + more_info: 'The Contained Database Authentication allows user to connect to the database without authenticating at the Database Engine level by moving the authentication boundary from the Database Engine level to the database level, hence it is recommended to disable this flag.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances have contained database authentication flag disabled.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'contained database authentication' && + flag.value && flag.value == 'on'); + if (found) { + helpers.addResult(results, 2, + 'SQL instance has "contained database authentication" flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "contained database authentication" flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "contained database authentication" flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/sqlServerContainedDBAuth.spec.js b/plugins/google/sql/sqlServerContainedDBAuth.spec.js new file mode 100644 index 000000000..ced89197f --- /dev/null +++ b/plugins/google/sql/sqlServerContainedDBAuth.spec.js @@ -0,0 +1,185 @@ +var expect = require('chai').expect; +var plugin = require('./sqlServerContainedDBAuth'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerContainedDBAuth', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have contained database authentication flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "contained database authentication" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "contained database authentication", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance does not have contained database authentication flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "contained database authentication" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "nested triggers", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have contained database authentication flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "contained database authentication" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has contained database authentication flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has "contained database authentication" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "contained database authentication", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/sqlServerExternalScripts.js b/plugins/google/sql/sqlServerExternalScripts.js new file mode 100644 index 000000000..eb964583c --- /dev/null +++ b/plugins/google/sql/sqlServerExternalScripts.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server External Scripts Flag Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that external scripts enabled flag is disabled for SQL Server instances.', + more_info: 'external scripts enabled allows scripts external to SQL such as files located in an R library to be executed, which could adversely affect the security of the system.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances have external scripts enabled flag set to disabled.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'external scripts enabled' && + flag.value && flag.value == 'on'); + if (found) { + helpers.addResult(results, 2, + 'SQL instance has "external scripts enabled" flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "external scripts enabled" flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "external scripts enabled" flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlServerExternalScripts.spec.js b/plugins/google/sql/sqlServerExternalScripts.spec.js new file mode 100644 index 000000000..eaee8b109 --- /dev/null +++ b/plugins/google/sql/sqlServerExternalScripts.spec.js @@ -0,0 +1,186 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./sqlServerExternalScripts'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerExternalScripts', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have external scripts enabled flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "external scripts enabled" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "external scripts enabled", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance does not have external scripts enabled flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "external scripts enabled" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "nested triggers", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instances does not have external scripts enabled flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "external scripts enabled" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has external scripts enabled flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has "external scripts enabled" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "external scripts enabled", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlServerRemoteAccessDisabled.js b/plugins/google/sql/sqlServerRemoteAccessDisabled.js new file mode 100644 index 000000000..331e57465 --- /dev/null +++ b/plugins/google/sql/sqlServerRemoteAccessDisabled.js @@ -0,0 +1,81 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server Remote Access Flag Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that remote access flag is disabled for SQL Server instances.', + more_info: ' The Remote Access option controls the execution of local stored procedures on remote servers or remote stored procedures on local server. Remote access functionality can be abused to launch a Denial-of-Service (DoS) attack on remote servers by off-loading query processing to a target, hence this should be disabled.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances have remote access flag set to disabled.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'remote access' && + flag.value && flag.value == 'on'); + if (found) { + helpers.addResult(results, 2, + 'SQL instance has "remote access" flag enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "remote access" flag enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "remote access" flag enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/sqlServerRemoteAccessDisabled.spec.js b/plugins/google/sql/sqlServerRemoteAccessDisabled.spec.js new file mode 100644 index 000000000..f3200aa7f --- /dev/null +++ b/plugins/google/sql/sqlServerRemoteAccessDisabled.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var plugin = require('./sqlServerRemoteAccessDisabled'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerRemoteAccessDisabled', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance does not have remote access flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "remote access" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "remote access", + value: "off", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance has remote access flag enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has "remote access" flag enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "remote access", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/sql/sqlServerTraceFlagDisabled.js b/plugins/google/sql/sqlServerTraceFlagDisabled.js new file mode 100644 index 000000000..6cb24cb9a --- /dev/null +++ b/plugins/google/sql/sqlServerTraceFlagDisabled.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server Trace Flag Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that 3625 (trace flag) database flag is disabled for SQL Server instances.', + more_info: ' The 3625 (trace flag) limits the amount of information returned to users who are not system admins, by masking the parameters of some error messages using ******, preventing the disclosure of sensitive information, hence this is recommended to set this flag globally to off to prevent the flag having been left on, or turned on by bad actors.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances have 3625 (trace flag) set to disabled.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == '3625' && + flag.value && flag.value == 'on'); + if (found) { + helpers.addResult(results, 2, + 'SQL instance has "3625 (trace flag)" enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "3625 (trace flag)" enabled', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "3625 (trace flag)" enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/sqlServerTraceFlagDisabled.spec.js b/plugins/google/sql/sqlServerTraceFlagDisabled.spec.js new file mode 100644 index 000000000..034bd12cc --- /dev/null +++ b/plugins/google/sql/sqlServerTraceFlagDisabled.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./sqlServerTraceFlagDisabled'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerTraceFlagDisabled', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance does not have 3625 (trace flag) enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "3625 (trace flag)" enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has 3625 (trace flag) enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has "3625 (trace flag)" enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "3625", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/sqlServerUserConnections.js b/plugins/google/sql/sqlServerUserConnections.js new file mode 100644 index 000000000..e4419fbae --- /dev/null +++ b/plugins/google/sql/sqlServerUserConnections.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server User Connections Flag', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that user connections database flag for Cloud SQL Server Instances is set to desired value.', + more_info: 'The user connection flag represents the maximum number of simultaneous connections that are allowed on an SQL Server instance. By default, it is set to 0 which means maximum connections are allowed. If the user connections flag is set to a limiting value, SQL Server will not allow any connections above the limit. If the connections are at the limit and will drop any new requests causing potential data loss or outages.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances have user connections flag set to your organization recommended value.', + apis: ['sql:list'], + settings: { + min_user_connections: { + name: 'Minimum User Connections', + description: 'Return a passing result if the user connections value is greater than or equal to this value. 0 means maximum.', + // eslint-disable-next-line + regex: '^(3276[0-7]|327[0-5]\d|32[0-6]\d{2}|3[01]\d{3}|[12]\d{4}|[1-9]\d{3}|[1-9]\d{2}|[1-9]\d|\d)$', + default: '0' + } + }, + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + let min_user_connections = parseInt(settings.min_user_connections || this.settings.min_user_connections.default); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + let hasResult = false; + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'user connections' && + flag.value); + if (found) { + let maxConnections = parseInt(found.value); + if (!([0, 32767].includes(maxConnections))) { + hasResult = true; + if (maxConnections >= min_user_connections && min_user_connections !== 0) { + helpers.addResult(results, 0, + `SQL instance has "user connections" flag set to ${maxConnections} which is greater than or equal to ${min_user_connections === 0 ? 'maximum' : min_user_connections}`, region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance has "user connections" flag set to ${maxConnections} which is less than ${min_user_connections === 0 ? 'maximum' : min_user_connections}`, region, resource); + } + } + } + } + if (!hasResult) { + helpers.addResult(results, 0, + 'SQL instance has "user connections" flag set to allow maximum number of connections', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/sqlServerUserConnections.spec.js b/plugins/google/sql/sqlServerUserConnections.spec.js new file mode 100644 index 000000000..0e0ef5722 --- /dev/null +++ b/plugins/google/sql/sqlServerUserConnections.spec.js @@ -0,0 +1,273 @@ +var expect = require('chai').expect; +var plugin = require('./sqlServerUserConnections'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerUserConnections', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has "user connections" flag set to allow maximum number of connections'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user connections", + value: "0", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has "user connections" flag set to allow maximum number of connections'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "nested triggers", + value: "on", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has "user connections" flag set to allow maximum number of connections'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user connections", + value: "100", + } + ] + } + }], + ); + + plugin.run(cache, {min_user_connections: 50}, callback); + }); + + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has "user connections" flag set to allow maximum number of connections'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user connections", + value: "32767", + } + ] + } + }], + ); + + plugin.run(cache, {min_user_connections: 50}, callback); + }); + + it('should give passing result if sql instance has user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance has "user connections" flag set to allow maximum number of connections'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user connections", + value: "0", + } + ] + } + }], + ); + + plugin.run(cache, {min_user_connections: 50}, callback); + }); + + it('should give failing result if sql instance does not have user connections flag set to desired value', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user connections", + value: "10", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/sqlServerUserOptionsDisabled.js b/plugins/google/sql/sqlServerUserOptionsDisabled.js new file mode 100644 index 000000000..b68200550 --- /dev/null +++ b/plugins/google/sql/sqlServerUserOptionsDisabled.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'SQL Server User Options Flag Disabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure user options database flag for Cloud SQL Server instances is not configured.', + more_info: 'A list of default query processing options is established for the duration of the work session for a user. The user options flag, if set, the users can change the defaults by using the SET statement so it is recommended to have this flag disabled.', + link: 'https://cloud.google.com/sql/docs/sqlserver/flags', + recommended_action: 'Ensure that all SQL Server database instances do not have user options flag configured.', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb){ + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.databaseVersion && !sqlInstance.databaseVersion.toUpperCase().startsWith('SQLSERVER')) { + helpers.addResult(results, 0, 'SQL instance database type is not of SQL Server type', region, resource); + return; + } + + if (sqlInstance.settings && + sqlInstance.settings.databaseFlags) { + let found = sqlInstance.settings.databaseFlags.find(flag => flag.name && flag.name == 'user options' && + flag.value); + if (found) { + helpers.addResult(results, 2, + 'SQL instance has "user options" flag configured', region, resource); + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "user options" flag configured', region, resource); + } + } else { + helpers.addResult(results, 0, + 'SQL instance does not have "user options" flag configured', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/sql/sqlServerUserOptionsDisabled.spec.js b/plugins/google/sql/sqlServerUserOptionsDisabled.spec.js new file mode 100644 index 000000000..4ac2a307e --- /dev/null +++ b/plugins/google/sql/sqlServerUserOptionsDisabled.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var plugin = require('./sqlServerUserOptionsDisabled'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('sqlServerUserOptionsDisabled', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance database type is not of SQL Server type', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance database type is not of SQL Server type'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + name: "testing-instance", + databaseVersion: "MYSQL_5_7", + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if sql instance does not have user options flag configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL instance does not have "user options" flag configured'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [] + } + }], + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if sql instance has user options flag configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has "user options" flag configured'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "SQLSERVER_2019_STANDARD", + settings: { + databaseFlags: [ + { + name: "user options", + value: "32", + }, + ]} + }], + ); + + plugin.run(cache, {}, callback); + }); + }) +}) diff --git a/plugins/google/sql/storageAutoIncreaseEnabled.js b/plugins/google/sql/storageAutoIncreaseEnabled.js new file mode 100644 index 000000000..23f04c5fe --- /dev/null +++ b/plugins/google/sql/storageAutoIncreaseEnabled.js @@ -0,0 +1,92 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Storage Auto Increase Enabled', + category: 'SQL', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that Cloud SQL DB instances have Automatic Storage Increase feature enabled and desired limit is set for storage increases.', + more_info: 'When this feature is enabled, Cloud SQL checks your available storage every 30 seconds. If the available storage falls below a threshold size, ' + + 'Cloud SQL automatically and permanently adds additional storage capacity. Setting a limit for automatic storage increase can prevent your instance size from growing too large.', + link: 'https://cloud.google.com/sql/docs/mysql/instance-settings?authuser=1#automatic-storage-increase-2ndgen', + recommended_action: 'Edit Cloud SQL instances and enable automatic storage increases feature under storage', + apis: ['sql:list'], + realtime_triggers:['cloudsql.instances.update','cloudsql.instances.delete','cloudsql.instances.create'], + + settings: { + sql_storage_auto_increase_limit: { + name: 'SQL Storage Auto Increase Limit', + description: 'Maximum limit (GBs) of automatic storage increase for SQL instances. Should be between 100 and 30720. ' + + 'Setting this value zero, the default value, means that there is no limit', + regex: '^.*$', + default: '0' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + maxLimit: parseInt(settings.sql_storage_auto_increase_limit || this.settings.sql_storage_auto_increase_limit.default) + }; + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, projects.err); + return callback(null, results, source); + } + + let project = projects.data[0].name; + + async.each(regions.sql, function(region, rcb) { + let sqlInstances = helpers.addSource( + cache, source, ['sql', 'list', region]); + + if (!sqlInstances) return rcb(); + + if (sqlInstances.err || !sqlInstances.data) { + helpers.addResult(results, 3, 'Unable to query SQL instances: ' + helpers.addError(sqlInstances), region); + return rcb(); + } + + if (!sqlInstances.data.length) { + helpers.addResult(results, 0, 'No SQL instances found', region); + return rcb(); + } + + sqlInstances.data.forEach(sqlInstance => { + if (sqlInstance.instanceType && sqlInstance.instanceType.toUpperCase() === 'READ_REPLICA_INSTANCE') return; + + let resource = helpers.createResourceName('instances', sqlInstance.name, project); + + if (sqlInstance.settings && + sqlInstance.settings.storageAutoResize) { + let resizeLimit = (sqlInstance.settings.storageAutoResizeLimit) ? parseInt(sqlInstance.settings.storageAutoResizeLimit) : 0; + if (resizeLimit <= config.maxLimit) { + helpers.addResult(results, 0, + `SQL instance automatic storage increase limit is ${resizeLimit} which is less than or equal to ${config.maxLimit}`, + region, resource); + } else { + helpers.addResult(results, 2, + `SQL instance automatic storage increase limit is ${resizeLimit} which is greater than ${config.maxLimit}`, + region, resource); + } + } else { + helpers.addResult(results, 2, + 'SQL instance has automatic storage increase disabled', region, resource); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/sql/storageAutoIncreaseEnabled.spec.js b/plugins/google/sql/storageAutoIncreaseEnabled.spec.js new file mode 100644 index 000000000..37986deac --- /dev/null +++ b/plugins/google/sql/storageAutoIncreaseEnabled.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var plugin = require('./storageAutoIncreaseEnabled'); + +const createCache = (err, data) => { + return { + sql: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'test-project' }] + } + } + } + } +}; + +describe('storageAutoIncreaseEnabled', function () { + describe('run', function () { + it('should give unknown result if a sql instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query SQL instances'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no sql instances are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No SQL instances found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if sql instance has storage auto increase disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SQL instance has automatic storage increase disabled'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + storageAutoResizeLimit: '100', + storageAutoResize: false, + } + }], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if SQL instance automatic storage increase limit is less than or equal to set limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('is less than or equal'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + storageAutoResizeLimit: '100', + storageAutoResize: true, + } + }], + ); + + plugin.run(cache, { sql_storage_auto_increase_limit: '150'}, callback); + }); + + it('should give failing result if SQL instance automatic storage increase limit is greater than set limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is greater than'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [{ + instanceType: "CLOUD_SQL_INSTANCE", + name: "testing-instance", + databaseVersion: "POSTGRES_13", + settings: { + storageAutoResizeLimit: '200', + storageAutoResize: true, + } + }], + ); + + plugin.run(cache, { sql_storage_auto_increase_limit: '150'}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/google/storage/bucketAllUsersPolicy.js b/plugins/google/storage/bucketAllUsersPolicy.js index 58f3adc3c..726f0dc6a 100644 --- a/plugins/google/storage/bucketAllUsersPolicy.js +++ b/plugins/google/storage/bucketAllUsersPolicy.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Storage Bucket All Users Policy', category: 'Storage', + domain: 'Storage', + severity: 'High', description: 'Ensures Storage bucket policies do not allow global write, delete, or read permissions', more_info: 'Storage buckets can be configured to allow the global principal to access the bucket via the bucket policy. This policy should be restricted only to known users or accounts.', link: 'https://cloud.google.com/storage/docs/access-control/iam', recommended_action: 'Ensure that each storage bucket is configured so that no member is set to allUsers or allAuthenticatedUsers.', apis: ['buckets:list','buckets:getIamPolicy'], + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], run: function(cache, settings, callback) { var results = []; @@ -16,13 +19,28 @@ module.exports = { var regions = helpers.regions(); async.each(regions.buckets, function(region, rcb){ + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); + return rcb(); + } + + if (!helpers.hasBuckets(buckets.data)) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + let bucketPolicyPolicies = helpers.addSource(cache, source, ['buckets', 'getIamPolicy', region]); if (!bucketPolicyPolicies) return rcb(); if (bucketPolicyPolicies.err || !bucketPolicyPolicies.data) { - helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(bucketPolicyPolicies), region); + helpers.addResult(results, 3, 'Unable to query bucket policies: ' + helpers.addError(bucketPolicyPolicies), region, null, null, bucketPolicyPolicies.err); return rcb(); } @@ -31,33 +49,49 @@ module.exports = { return rcb(); } var badBuckets = []; + var goodBuckets = []; bucketPolicyPolicies.data.forEach(bucketPolicy => { + var hasAllUsers = false; + var resourceIdArr = bucketPolicy.resourceId.split('/'); + var bucketName = resourceIdArr[resourceIdArr.length - 1]; if (bucketPolicy.bindings && bucketPolicy.bindings.length) { bucketPolicy.bindings.forEach(binding => { - if (binding.members && + if (binding.members && binding.members.length) { - binding.members.forEach(member => { - if (member === "allUsers" || - member === "allAuthenticatedUsers") { - var resourceIdArr = bucketPolicy.resourceId.split('/'); - var bucketName = resourceIdArr[resourceIdArr.length - 1]; + binding.members.forEach(member => { + if (member === 'allUsers' || + member === 'allAuthenticatedUsers') { if (badBuckets.indexOf(bucketName) === -1) { badBuckets.push(bucketName); + hasAllUsers = true; } - } - }) - } - }) + } + }); + } + }); + } + if (!hasAllUsers && badBuckets.indexOf(bucketName) === -1) { + goodBuckets.push(bucketName); } }); if (badBuckets.length) { - var badBucketsStr = badBuckets.join(', '); - helpers.addResult(results, 2, - `The following buckets have anonymous or public access: ${badBucketsStr}`, region); - } else { - helpers.addResult(results, 0, 'No buckets have anonymous or public access.', region); + badBuckets.forEach(bucket => { + let resource = helpers.createResourceName('b', bucket); + helpers.addResult(results, 2, + 'Bucket has anonymous or public access', region, resource); + }); + } + if (goodBuckets.length) { + goodBuckets.forEach(bucket => { + let resource = helpers.createResourceName('b', bucket); + helpers.addResult(results, 0, + 'Bucket does not have anonymous or public access', region, resource); + }); + } + if (!goodBuckets.length && !badBuckets.length) { + helpers.addResult(results, 0, 'No buckets found.', region); } rcb(); @@ -66,4 +100,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/storage/bucketAllUsersPolicy.spec.js b/plugins/google/storage/bucketAllUsersPolicy.spec.js index 28d24d6fc..372476b25 100644 --- a/plugins/google/storage/bucketAllUsersPolicy.spec.js +++ b/plugins/google/storage/bucketAllUsersPolicy.spec.js @@ -2,9 +2,15 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./bucketAllUsersPolicy'); -const createCache = (err, data) => { +const createCache = (err, data, bucketErr, bucketData) => { return { buckets: { + list: { + 'global': { + err: bucketErr, + data: bucketData + } + }, getIamPolicy: { 'global': { err: err, @@ -29,6 +35,8 @@ describe('bucketAllUsersPolicy', function () { const cache = createCache( ['error'], null, + ['error'], + null, ); plugin.run(cache, {}, callback); @@ -44,16 +52,22 @@ describe('bucketAllUsersPolicy', function () { const cache = createCache( null, - [], + null, + null, + [ + { + "kind": "storage#buckets" + } + ] ); plugin.run(cache, {}, callback); }); - it('should give passing result if no bucks have anonymous or public access', function (done) { + it('should give passing result if no buckets have anonymous or public access', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No buckets have anonymous or public access.'); + expect(results[0].message).to.include('Bucket does not have anonymous or public access'); expect(results[0].region).to.equal('global'); done() }; @@ -62,49 +76,113 @@ describe('bucketAllUsersPolicy', function () { null, [ { - "name": "giotestdnszone1", - "dnsName": "cloudsploit.com.", - "description": "", - "id": "4534388710135378441", - "nameServers": [ - "ns-cloud-e1.googledomains.com.", - "ns-cloud-e2.googledomains.com.", - "ns-cloud-e3.googledomains.com.", - "ns-cloud-e4.googledomains.com." + "kind": "storage#policy", + "resourceId": "projects/_/buckets/us.artifacts.rosy-booth-253119.appspot.com", + "bindings": [ + { + "role": "roles/storage.legacyBucketOwner", + "members": [ + "projectEditor:rosy-booth-253119", + "projectOwner:rosy-booth-253119" + ] + }, + { + "role": "roles/storage.legacyBucketReader", + "members": [ + "projectViewer:rosy-booth-253119" + ] + } + ], + "etag": "CAE=", + "version": 1 + }, + { + "kind": "storage#policy", + "resourceId": "projects/_/buckets/staging.rosy-booth-253119.appspot.com", + "bindings": [ + { + "role": "roles/storage.legacyBucketOwner", + "members": [ + "projectEditor:rosy-booth-253119", + "projectOwner:rosy-booth-253119" + ] + }, + { + "role": "roles/storage.legacyBucketReader", + "members": [ + "projectViewer:rosy-booth-253119" + ] + } + ], + "etag": "CAE=", + "version": 1 + }, + { + "kind": "storage#policy", + "resourceId": "projects/_/buckets/rosy-booth-253119.appspot.com", + "version": 1, + "bindings": [ + { + "role": "roles/iam.securityReviewer", + "members": [ + "projectEditor:rosy-booth-253119", + ] + }, + { + "role": "roles/storage.legacyBucketOwner", + "members": [ + "projectEditor:rosy-booth-253119", + "projectOwner:rosy-booth-253119" + ] + }, + { + "role": "roles/storage.legacyBucketReader", + "members": [ + "projectViewer:rosy-booth-253119" + ] + } ], - "creationTime": "2019-10-03T21:11:18.894Z", - "dnssecConfig": { - "state": "on", - "defaultKeySpecs": [ - { - "keyType": "keySigning", - "algorithm": "rsasha256", - "keyLength": 2048, - "kind": "dns#dnsKeySpec" - }, - { - "keyType": "zoneSigning", - "algorithm": "rsasha256", - "keyLength": 1024, - "kind": "dns#dnsKeySpec" - } - ], - "nonExistence": "nsec3", - "kind": "dns#managedZoneDnsSecConfig" + "etag": "CAs=" + } + ], + null, + [ + { + "kind": "storage#bucket", + "selfLink": "https://www.googleapis.com/storage/v1/b/testio", + "id": "testio", + "name": "testio", + "projectNumber": "664367550207", + "metageneration": "1", + "location": "US", + "storageClass": "STANDARD", + "etag": "CAE=", + "defaultEventBasedHold": false, + "timeCreated": "2021-04-06T16:06:14.799Z", + "updated": "2021-04-06T16:06:14.799Z", + "iamConfiguration": { + "bucketPolicyOnly": { + "enabled": true, + "lockedTime": "2021-07-05T16:06:14.799Z" + }, + "uniformBucketLevelAccess": { + "enabled": true, + "lockedTime": "2021-07-05T16:06:14.799Z" + } }, - "visibility": "public", - "kind": "dns#managedZone" + "locationType": "multi-region", + "satisfiesPZS": false } ] ); plugin.run(cache, {}, callback); }); - it('should give failing result if the managed zone does not have dns sec enabled', function (done) { + it('should give failing result if bucket has anonymous or public access', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('The following buckets have anonymous or public access'); + expect(results[0].message).to.include('Bucket has anonymous or public access'); expect(results[0].region).to.equal('global'); done() }; @@ -182,6 +260,35 @@ describe('bucketAllUsersPolicy', function () { ], "etag": "CAs=" } + ], + null, + [ + { + "kind": "storage#bucket", + "selfLink": "https://www.googleapis.com/storage/v1/b/testio", + "id": "testio", + "name": "testio", + "projectNumber": "664367550207", + "metageneration": "1", + "location": "US", + "storageClass": "STANDARD", + "etag": "CAE=", + "defaultEventBasedHold": false, + "timeCreated": "2021-04-06T16:06:14.799Z", + "updated": "2021-04-06T16:06:14.799Z", + "iamConfiguration": { + "bucketPolicyOnly": { + "enabled": true, + "lockedTime": "2021-07-05T16:06:14.799Z" + }, + "uniformBucketLevelAccess": { + "enabled": true, + "lockedTime": "2021-07-05T16:06:14.799Z" + } + }, + "locationType": "multi-region", + "satisfiesPZS": false + } ] ); diff --git a/plugins/google/storage/bucketEncryption.js b/plugins/google/storage/bucketEncryption.js new file mode 100644 index 000000000..0501abbd9 --- /dev/null +++ b/plugins/google/storage/bucketEncryption.js @@ -0,0 +1,116 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Bucket Encryption', + category: 'Storage', + domain: 'Storage', + severity: 'High', + description: 'Ensure that Cloud Storage buckets have encryption enabled using desired protection level.', + more_info: 'By default, all storage buckets are encrypted using Google-managed keys. To have better control over how your storage bucktes are encrypted, you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/storage/docs/encryption/customer-managed-keys', + recommended_action: 'Ensure that all storage buckets have desired encryption level.', + apis: ['buckets:list', 'keyRings:list', 'cryptoKeys:list'], + settings: { + bucket_encryption_level: { + name: 'Storage Bucket Encryption Level', + description: 'Desired protection level for Storage buckets. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.bucket_encryption_level || this.settings.bucket_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.buckets, function(region, rcb) { + + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); + return rcb(); + } + + if (!helpers.hasBuckets(buckets.data)) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + + var bucketFound = false; + if (buckets && buckets.data) { + buckets.data.forEach(bucket => { + if (!bucket.name) return; + + bucketFound = true; + let resource = helpers.createResourceName('b', bucket.name); + let currentEncryptionLevel; + + if (bucket && bucket.encryption && bucket.encryption.defaultKmsKeyName + && keysObj[bucket.encryption.defaultKmsKeyName]) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[bucket.encryption.defaultKmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Bucket has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Bucket has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, resource); + } + }); + } + if (!bucketFound) { + helpers.addResult(results, 0, 'No storage buckets found', region); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/storage/bucketEncryption.spec.js b/plugins/google/storage/bucketEncryption.spec.js new file mode 100644 index 000000000..836fd7882 --- /dev/null +++ b/plugins/google/storage/bucketEncryption.spec.js @@ -0,0 +1,149 @@ +var expect = require('chai').expect; +var plugin = require('./bucketEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const createCache = (err, data, keysList, keysErr) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'global': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [ { name: 'testproj' }] + } + } + } + } +}; + +describe('bucketEncryption', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if bucket has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + locationType: "multi-region", + satisfiesPZS: false, + encryption: { + defaultKmsKeyName: 'projects/test-dev/locations/global/keyRings/test-kr/cryptoKeys/test-key-2' + } + }, + + ], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if bucket does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + locationType: "multi-region", + satisfiesPZS: false, + }, + ], + cryptoKeys, + null + ); + plugin.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/google/storage/bucketLabelsAdded.js b/plugins/google/storage/bucketLabelsAdded.js new file mode 100644 index 000000000..2f03ed1c3 --- /dev/null +++ b/plugins/google/storage/bucketLabelsAdded.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Bucket Labels Added', + category: 'Storage', + domain: 'Storage', + severity: 'Low', + description: 'Ensure that all Cloud Storage buckets have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/storage/docs/using-bucket-labels', + recommended_action: 'Ensure labels are added to all storage buckets.', + apis: ['buckets:list'], + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.buckets, function(region, rcb){ + let buckets = helpers.addSource(cache, source, + ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets', region, null, null, buckets.err); + return rcb(); + } + + if (!buckets.data.length) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + + buckets.data.forEach(bucket => { + let resource = helpers.createResourceName('b', bucket.name); + + if (bucket.labels && + Object.keys(bucket.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(bucket.labels).length} labels found for storage bucket`, region, resource); + } else { + helpers.addResult(results, 2, + 'Storage bucket does not have any labels', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/storage/bucketLabelsAdded.spec.js b/plugins/google/storage/bucketLabelsAdded.spec.js new file mode 100644 index 000000000..d0b07a3d3 --- /dev/null +++ b/plugins/google/storage/bucketLabelsAdded.spec.js @@ -0,0 +1,103 @@ +var expect = require('chai').expect; +var plugin = require('./bucketLabelsAdded'); + +const buckets = [ + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/testproj-bucket-1', + id: 'testproj-bucket-1', + name: 'testproj-bucket-1', + labels: { bucket: 'label' }, + }, + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/testproj-bucket-2', + id: 'testproj-bucket-2', + name: 'testproj-bucket-2' + } +]; + +const createCache = (err, data) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no buckets are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the bucket', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for storage bucket'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [buckets[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the bucket', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage bucket does not have any labels'); + expect(results[0].region).to.equal('global'); + done() + }; + + const cache = createCache( + null, + [buckets[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) diff --git a/plugins/google/storage/bucketLifecycleConfigured.js b/plugins/google/storage/bucketLifecycleConfigured.js new file mode 100644 index 000000000..686d6663a --- /dev/null +++ b/plugins/google/storage/bucketLifecycleConfigured.js @@ -0,0 +1,61 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Bucket Lifecycle Configured', + category: 'Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that Cloud Storage buckets are using lifecycle management rules to transition objects between storage classes.', + more_info: 'Lifecycle management rules allow you to delete buckets at the end of their lifecycle and help optimize your data for storage costs.', + link: 'https://cloud.google.com/storage/docs/managing-lifecycles', + recommended_action: 'Modify storage buckets and configure lifecycle rules.', + apis: ['buckets:list'], + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.buckets, function(region, rcb) { + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); + return rcb(); + } + + if (!helpers.hasBuckets(buckets.data)) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + + var bucketFound = false; + buckets.data.forEach(bucket => { + if (bucket.name) { + let resource = helpers.createResourceName('b', bucket.name); + bucketFound = true; + + if (bucket.lifecycle && bucket.lifecycle.rule && bucket.lifecycle.rule.length) { + helpers.addResult(results, 0, 'Bucket has lifecycle management configured', region, resource); + } else { + helpers.addResult(results, 2, 'Bucket does not have lifecycle management configured', region, resource); + } + } + }); + + if (!bucketFound) { + helpers.addResult(results, 0, 'No storage buckets found', region); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/storage/bucketLifecycleConfigured.spec.js b/plugins/google/storage/bucketLifecycleConfigured.spec.js new file mode 100644 index 000000000..d704e4ceb --- /dev/null +++ b/plugins/google/storage/bucketLifecycleConfigured.spec.js @@ -0,0 +1,98 @@ +var expect = require('chai').expect; +var plugin = require('./bucketLifecycleConfigured'); + +const createCache = (err, data) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketLifecycleConfigured', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if lifecycle management rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has lifecycle management configured'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + lifecycle: { + rule: [{ action: { type: 'Delete' }, condition: { age: 15 } }] + } + }, + + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if bucket has no lifecycle management configuration', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have lifecycle management configured'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1" + }, + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/google/storage/bucketLogging.js b/plugins/google/storage/bucketLogging.js index f6e974f9e..b45fbaef9 100644 --- a/plugins/google/storage/bucketLogging.js +++ b/plugins/google/storage/bucketLogging.js @@ -4,15 +4,18 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Bucket Logging', category: 'Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures object logging is enabled on storage buckets', more_info: 'Storage bucket logging helps maintain an audit trail of access that can be used in the event of a security incident.', link: 'https://cloud.google.com/storage/docs/access-logs', - recommended_action: 'Bucket Logging can only be enabled by using the Command Line Interface and the log bucket must already be created. Use this command to enable Logging: gsutil logging set on -b gs://[LOG_BUCKET_NAME] -o AccessLog \ gs://[BUCKET_NAME]', + recommended_action: 'Bucket Logging can only be enabled by using the Command Line Interface and the log bucket must already be created. Use this command to enable Logging: gsutil logging set on -b gs://[LOG_BUCKET_NAME] -o AccessLog \ gs://[BUCKET_NAME]', // eslint-disable-line no-useless-escape apis: ['buckets:list'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + 'including access and all actions taken.' }, + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], run: function(cache, settings, callback) { var results = []; @@ -26,32 +29,36 @@ module.exports = { if (!buckets) return rcb(); if (buckets.err || !buckets.data) { - helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region); + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); return rcb(); } - if (!buckets.data.length) { + if (!helpers.hasBuckets(buckets.data)) { helpers.addResult(results, 0, 'No storage buckets found', region); return rcb(); } + + var bucketFound = false; buckets.data.forEach(bucket => { - if (bucket.id) { - if (bucket.logging && - bucket.logging.logObjectPrefix == 'AccessLog') { - helpers.addResult(results, 0, 'Bucket Logging Enabled', region, bucket.id); + if (bucket.name) { + let resource = helpers.createResourceName('b', bucket.name); + bucketFound = true; + if (bucket.logging && bucket.logging.logObjectPrefix && bucket.logging.logObjectPrefix.length) { + helpers.addResult(results, 0, 'Bucket Logging Enabled', region, resource); } else { - helpers.addResult(results, 2, 'Bucket Logging not Enabled', region, bucket.id); + helpers.addResult(results, 2, 'Bucket Logging not Enabled', region, resource); } - } else { - helpers.addResult(results, 0, 'No storage buckets found', region); - return; } }); + if (!bucketFound) { + helpers.addResult(results, 0, 'No storage buckets found', region); + } + rcb(); }, function(){ // Global checking goes here callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/storage/bucketLogging.spec.js b/plugins/google/storage/bucketLogging.spec.js new file mode 100644 index 000000000..8ab618f8b --- /dev/null +++ b/plugins/google/storage/bucketLogging.spec.js @@ -0,0 +1,135 @@ +var expect = require('chai').expect; +var plugin = require('./bucketLogging'); + +const createCache = (err, data) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketLogging', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if bucket logging is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket Logging Enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if bucket logging is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket Logging not Enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + }, + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); diff --git a/plugins/google/storage/bucketRetentionPolicy.js b/plugins/google/storage/bucketRetentionPolicy.js new file mode 100644 index 000000000..dd4e73e49 --- /dev/null +++ b/plugins/google/storage/bucketRetentionPolicy.js @@ -0,0 +1,85 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Storage Bucket Retention Policy', + category: 'Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures bucket retention policy is set and locked to prevent deleting or updating of bucket objects or retention policy.', + more_info: 'Configuring retention policy for bucket prevents accidental deletion as well as modification of bucket objects. This retention policy should also be locked to prevent policy deletion.', + link: 'https://cloud.google.com/storage/docs/bucket-lock?_ga=2.221806616.-1645770163.1613190642', + recommended_action: 'Modify bucket to configure retention policy and lock retention policy.', + apis: ['buckets:list'], + settings: { + bucket_retention_days: { + name: 'Bucket Retention Days', + description: 'Return a passing result when bucket retention expiration date exceeds this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '0' + } + }, + realtime_triggers: ['storage.buckets.create', 'storage.buckets.delete', 'storage.buckets.update'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + bucket_retention_days: parseInt(settings.bucket_retention_days || this.settings.bucket_retention_days.default) + }; + + async.each(regions.buckets, function(region, rcb){ + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); + return rcb(); + } + + if (!helpers.hasBuckets(buckets.data)) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + + var bucketFound = false; + buckets.data.forEach(bucket => { + if (bucket.name) { + let resource = helpers.createResourceName('b', bucket.name); + bucketFound = true; + if (bucket.retentionPolicy && bucket.retentionPolicy.retentionPeriod && bucket.retentionPolicy.effectiveTime) { + var retentionDays = Math.round(parseInt(bucket.retentionPolicy.retentionPeriod)/(24*60*60)); + var now = new Date(); + var then = new Date(bucket.retentionPolicy.effectiveTime); + var difference = helpers.daysBetween(then, now); + var effectiveDifference = retentionDays - difference; + + if (effectiveDifference < 0) { + helpers.addResult(results, 2, 'Storage bucket retention has already expired', region, resource); + } else if (effectiveDifference < config.bucket_retention_days) { + helpers.addResult(results, 2, `Storage bucket retention will expire in ${effectiveDifference} days`, region, resource); + } else if (!bucket.retentionPolicy.isLocked) { + helpers.addResult(results, 2, 'Storage bucket retention policy is not locked', region, resource); + } else { + helpers.addResult(results, 0, `Storage bucket retention will expire in ${effectiveDifference} days`, region, resource); + } + } else { + helpers.addResult(results, 2, 'Storage bucket does not have a retention policy', region, resource); + } + } + }); + + if (!bucketFound) { + helpers.addResult(results, 0, 'No storage buckets found', region); + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/storage/bucketRetentionPolicy.spec.js b/plugins/google/storage/bucketRetentionPolicy.spec.js new file mode 100644 index 000000000..321db74f5 --- /dev/null +++ b/plugins/google/storage/bucketRetentionPolicy.spec.js @@ -0,0 +1,214 @@ +const expect = require('chai').expect; +var bucketRetentionPolicy = require('./bucketRetentionPolicy'); + +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 1); + +const bucket = [ + { + 'kind': 'storage#bucket', + 'selfLink': 'https://www.googleapis.com/storage/v1/b/bucket-1', + 'id': 'bucket-1', + 'name': 'bucket-1', + 'projectNumber': '768447683925', + 'metageneration': '1', + 'location': 'US-EAST1', + 'storageClass': 'STANDARD', + 'etag': 'CAE=', + 'defaultEventBasedHold': false, + 'retentionPolicy': { + 'retentionPeriod': '864000', + 'effectiveTime': new Date(), + 'isLocked': true + }, + 'timeCreated': '2021-02-13T05:44:29.181Z', + 'updated': '2021-02-13T05:44:29.181Z', + 'locationType': 'region', + 'satisfiesPZS': false + }, + { + 'kind': 'storage#bucket', + 'selfLink': 'https://www.googleapis.com/storage/v1/b/bucket-2', + 'id': 'bucket-2', + 'name': 'bucket-2', + 'projectNumber': '768447683925', + 'metageneration': '1', + 'location': 'US-EAST1', + 'storageClass': 'STANDARD', + 'etag': 'CAE=', + 'defaultEventBasedHold': false, + 'retentionPolicy': { + 'retentionPeriod': '86400', + 'effectiveTime': '2021-02-10T05:53:02.998Z', + 'isLocked': false + }, + 'timeCreated': '2021-02-13T05:44:29.181Z', + 'updated': '2021-02-13T05:44:29.181Z', + 'locationType': 'region', + 'satisfiesPZS': false + }, + { + 'kind': 'storage#bucket', + 'selfLink': 'https://www.googleapis.com/storage/v1/b/bucket-3', + 'id': 'bucket-3', + 'name': 'bucket-3', + 'projectNumber': '768447683925', + 'metageneration': '1', + 'location': 'US-EAST1', + 'storageClass': 'STANDARD', + 'etag': 'CAE=', + 'defaultEventBasedHold': false, + 'retentionPolicy': { + 'retentionPeriod': '864000', + 'effectiveTime': new Date(), + 'isLocked': false + }, + 'timeCreated': '2021-02-13T05:44:29.181Z', + 'updated': '2021-02-13T05:44:29.181Z', + 'locationType': 'region', + 'satisfiesPZS': false + }, + { + 'kind': 'storage#bucket', + 'selfLink': 'https://www.googleapis.com/storage/v1/b/bucket-4', + 'id': 'bucket-4', + 'name': 'bucket-4', + 'projectNumber': '768447683925', + 'metageneration': '1', + 'location': 'US-EAST1', + 'storageClass': 'STANDARD', + 'etag': 'CAE=', + 'defaultEventBasedHold': false, + 'timeCreated': '2021-02-13T05:44:29.181Z', + 'updated': '2021-02-13T05:44:29.181Z', + 'locationType': 'region', + 'satisfiesPZS': false + }, + { + 'kind': 'storage#bucket', + 'selfLink': 'https://www.googleapis.com/storage/v1/b/bucket-5', + 'id': 'bucket-5', + 'name': 'bucket-5', + 'projectNumber': '768447683925', + 'metageneration': '1', + 'location': 'US-EAST1', + 'storageClass': 'STANDARD', + 'etag': 'CAE=', + 'defaultEventBasedHold': false, + 'retentionPolicy': { + 'retentionPeriod': '8640000', + 'effectiveTime': failDate, + 'isLocked': false + }, + 'timeCreated': '2021-02-13T05:44:29.181Z', + 'updated': '2021-02-13T05:44:29.181Z', + 'locationType': 'region', + 'satisfiesPZS': false + } +]; + + + +const createCache = (bucketData, bucketErr) => { + return { + buckets: { + list: { + 'global': { + err: bucketErr, + data: bucketData + } + }, + } + }; +}; + +const createNullCache = () => { + return { + buckets: { + list: { + 'global': null + } + } + } +} + +describe('bucketRetentionPolicy', function () { + describe('run', function () { + it('should PASS if Storage bucket retention expiration is in more than set days', function (done) { + const cache = createCache([bucket[0]]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '2' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Storage bucket retention expiration is in less than set days', function (done) { + const cache = createCache([bucket[4]]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '90' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Storage bucket retention has already expired', function (done) { + const cache = createCache([bucket[1]]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '5' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Storage bucket retention policy is not locked', function (done) { + const cache = createCache([bucket[2]]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if Storage bucket does not have aretention policy', function (done) { + const cache = createCache([bucket[3]]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '3' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no storage buckets found', function (done) { + const cache = createCache([]); + bucketRetentionPolicy.run(cache, { bucket_retention_days: '10' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNOWN if unable to query storage buckets', function (done) { + const cache = createCache([], { message: 'Uanble to query storage buckets'}); + bucketRetentionPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should not return anything if list storage buckets response not found', function (done) { + const cache = createNullCache(); + bucketRetentionPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + }); +}); diff --git a/plugins/google/storage/bucketUniformAccess.js b/plugins/google/storage/bucketUniformAccess.js new file mode 100644 index 000000000..d2087784b --- /dev/null +++ b/plugins/google/storage/bucketUniformAccess.js @@ -0,0 +1,102 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Bucket Uniform Level Access', + category: 'Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that uniform level access is enabled on storage buckets.', + more_info: 'Uniform level access for buckets can be used for managing access in a simple way. It enables us to use other security features like IAM conditions.', + link: 'https://cloud.google.com/storage/docs/uniform-bucket-level-access#should-you-use', + recommended_action: 'Make sure that storage buckets have uniform level access enabled', + apis: ['buckets:list'], + remediation_min_version: '202207281836', + remediation_description: 'Unfiorm Level Access will be enabled on all storage buckets', + apis_remediate: ['buckets:list'], + actions: {remediate:['storage.buckets.update'], rollback:['storage.buckets.update']}, + permissions: {remediate: ['storage.buckets.setIamPolicy', 'storage.buckets.update'], rollback: ['storage.buckets.setIamPolicy','storage.buckets.update']}, + realtime_triggers: ['storage.buckets.update', 'storage.buckets.create','storage.buckets.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.buckets, function(region, rcb){ + let buckets = helpers.addSource( + cache, source, ['buckets', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region); + return rcb(); + } + + if (!buckets.data.length) { + helpers.addResult(results, 0, 'No storage buckets found', region); + return rcb(); + } + + var bucketFound = false; + buckets.data.forEach(bucket => { + if (bucket.name) { + let resource = helpers.createResourceName('b', bucket.name); + bucketFound = true; + if (bucket.iamConfiguration && + bucket.iamConfiguration.uniformBucketLevelAccess && + bucket.iamConfiguration.uniformBucketLevelAccess.enabled) { + helpers.addResult(results, 0, 'Bucket has uniform bucket level access enabled', region, resource); + } else { + helpers.addResult(results, 2, 'Bucket does not have uniform bucket level access enabled', region, resource); + } + } + + if (!bucketFound) { + helpers.addResult(results, 0, 'No storage buckets found', region); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'bucketUniformAccess'; + var baseUrl = 'https://storage.googleapis.com/storage/v1/{resource}'; + var method = 'PUT'; + var putCall = this.actions.remediate; + + // create the params necessary for the remediation + var body = { + iamConfiguration: { + uniformBucketLevelAccess: { + enabled: true + } + } + }; + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'UniformBucketLevelAccess': 'Disabled' + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); + } +}; diff --git a/plugins/google/storage/bucketUniformAccess.spec.js b/plugins/google/storage/bucketUniformAccess.spec.js new file mode 100644 index 000000000..639f01c1c --- /dev/null +++ b/plugins/google/storage/bucketUniformAccess.spec.js @@ -0,0 +1,96 @@ +var expect = require('chai').expect; +var plugin = require('./bucketUniformAccess'); + +const createCache = (err, data) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketUniformAccess', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if bucket has uniform bucket level access enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket has uniform bucket level access enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "uniform-access", + name: "uniform-access", + iamConfiguration: { + uniformBucketLevelAccess: { + enabled: true, + }, + }, + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if bucket does not have uniform bucket level access enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket does not have uniform bucket level access enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "uniform-access-2", + name: "uniform-access-2", + iamConfiguration: { + uniformBucketLevelAccess: { + enabled: false, + }, + }, + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/storage/bucketVersioning.js b/plugins/google/storage/bucketVersioning.js index 65747743f..11c211947 100644 --- a/plugins/google/storage/bucketVersioning.js +++ b/plugins/google/storage/bucketVersioning.js @@ -4,12 +4,19 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Bucket Versioning', category: 'Storage', + domain: 'Storage', + severity: 'Low', description: 'Ensures object versioning is enabled on storage buckets', more_info: 'Object versioning can help protect against the overwriting of objects or data loss in the event of a compromise.', link: 'https://cloud.google.com/storage/docs/using-object-versioning', recommended_action: 'Bucket Versioning can only be enabled by using the Command Line Interface, use this command to enable Versioning: gsutil versioning set on gs://[BUCKET_NAME]', apis: ['buckets:list'], - + remediation_min_version: '202207281836', + remediation_description: 'Bucket versioning will be enabled on storage buckets', + apis_remediate: ['buckets:list'], + actions: {remediate:['storage.buckets.update'], rollback:['storage.buckets.update']}, + permissions: {remediate: ['storage.buckets.setIamPolicy', 'storage.buckets.update'], rollback: ['storage.buckets.setIamPolicy','storage.buckets.update']}, + realtime_triggers: ['storage.buckets.update', 'storage.buckets.create', 'storage.buckets.delete'], run: function(cache, settings, callback) { var results = []; @@ -23,27 +30,29 @@ module.exports = { if (!buckets) return rcb(); if (buckets.err || !buckets.data) { - helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region); + helpers.addResult(results, 3, 'Unable to query storage buckets: ' + helpers.addError(buckets), region, null, null, buckets.err); return rcb(); } - if (!buckets.data.length) { + if (!helpers.hasBuckets(buckets.data)) { helpers.addResult(results, 0, 'No storage buckets found', region); return rcb(); } + buckets.data.forEach(bucket => { - if (bucket.id) { + if (bucket.name) { + let resource = helpers.createResourceName('b', bucket.name); if (bucket.versioning && bucket.versioning.enabled) { - helpers.addResult(results, 0, 'Bucket versioning Enabled', region, bucket.id); + helpers.addResult(results, 0, 'Bucket versioning Enabled', region, resource); } else if ((bucket.versioning && !bucket.versioning.enabled) || !bucket.versioning){ - helpers.addResult(results, 2, 'Bucket versioning not Enabled', region, bucket.id); + helpers.addResult(results, 2, 'Bucket versioning not Enabled', region, resource); } } else { helpers.addResult(results, 0, 'No storage buckets found', region); - return + return; } }); @@ -52,5 +61,38 @@ module.exports = { // Global checking goes here callback(null, results, source); }); + }, + remediate: function(config, cache, settings, resource, callback) { + var remediation_file = settings.remediation_file; + + // inputs specific to the plugin + var pluginName = 'bucketVersioning'; + var baseUrl = 'https://storage.googleapis.com/storage/v1/{resource}'; + var method = 'PUT'; + var putCall = this.actions.remediate; + + // create the params necessary for the remediation + var body = { + versioning: { + enabled: true + } + }; + // logging + remediation_file['pre_remediate']['actions'][pluginName][resource] = { + 'Versioning': 'Disabled' + }; + + helpers.remediatePlugin(config, method, body, baseUrl, resource, remediation_file, putCall, pluginName, function(err, action) { + if (err) return callback(err); + if (action) action.action = putCall; + + + remediation_file['post_remediate']['actions'][pluginName][resource] = action; + remediation_file['remediate']['actions'][pluginName][resource] = { + 'Action': 'Enabled' + }; + + callback(null, action); + }); } -} \ No newline at end of file +}; diff --git a/plugins/google/storage/bucketVersioning.spec.js b/plugins/google/storage/bucketVersioning.spec.js new file mode 100644 index 000000000..027c7a819 --- /dev/null +++ b/plugins/google/storage/bucketVersioning.spec.js @@ -0,0 +1,137 @@ +var expect = require('chai').expect; +var plugin = require('./bucketVersioning'); + +const createCache = (err, data) => { + return { + buckets: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketVersioning', function () { + describe('run', function () { + it('should give unknown result if a bucket error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query storage buckets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no storage buckets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage buckets found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if bucket versioning is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Bucket versioning Enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + logging: { + logBucket: "akhtar-bucket-1", + logObjectPrefix: "test_bucket_spec1", + }, + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false, + versioning: { + enabled: true, + } + }, + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if bucket versioning is not enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Bucket versioning not Enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + kind: "storage#bucket", + selfLink: "https://www.googleapis.com/storage/v1/b/test_bucket_spec1", + id: "test_bucket_spec1", + name: "test_bucket_spec1", + projectNumber: "dummy1", + metageneration: "3", + location: "US", + storageClass: "STANDARD", + etag: "CAM=", + defaultEventBasedHold: false, + timeCreated: "2021-04-07T17:55:53.104Z", + updated: "2021-04-07T19:03:16.173Z", + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: "2021-07-06T17:55:53.104Z", + }, + }, + locationType: "multi-region", + satisfiesPZS: false + }, + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vertexai/modelEncryption.js b/plugins/google/vertexai/modelEncryption.js new file mode 100644 index 000000000..ae333e574 --- /dev/null +++ b/plugins/google/vertexai/modelEncryption.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Vertex AI Model Encryption', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensure that Vertex AI models are encrypted using desired encryption protection level.', + more_info: 'By default Google encrypts all models using Google-managed encryption keys. To have more control over the encryption process of your Vertex AI models you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/vertex-ai/docs/general/cmek', + recommended_action: 'Recreate existing models with desired protection level.', + apis: ['vertexAI:listModels', 'keyRings:list', 'cryptoKeys:list'], + realtime_triggers: ['aiplatform.ModelService.UpdateModel', 'aiplatform.ModelService.DeleteModel'], + settings: { + vertexai_model_encryption_protection_level: { + name: 'Vertex AI Model Encryption Protection Level', + description: 'Desired protection level for Vertex AI models. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.vertexai_model_encryption_protection_level || this.settings.vertexai_model_encryption_protection_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.vertexAI, function(region, rcb) { + let models = helpers.addSource(cache, source, + ['vertexAI', 'listModels', region]); + + if (!models) return rcb(); + + if (models.err || !models.data) { + helpers.addResult(results, 3, 'Unable to query Vertex AI models: ' + helpers.addError(models), region); + return rcb(); + } + + if (!models.data.length) { + helpers.addResult(results, 0, 'No existing Vertex AI models found', region); + return rcb(); + } + + async.each(models.data, (model) => { + let currentEncryptionLevel; + + if (model.encryptionSpec && model.encryptionSpec.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[model.encryptionSpec.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Vertex AI model has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, model.name); + } else { + helpers.addResult(results, 2, + `Vertex AI model has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, model.name); + } + + }); + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vertexai/modelEncryption.spec.js b/plugins/google/vertexai/modelEncryption.spec.js new file mode 100644 index 000000000..8cdf8abb1 --- /dev/null +++ b/plugins/google/vertexai/modelEncryption.spec.js @@ -0,0 +1,161 @@ +var expect = require('chai').expect; +var plugin = require('./modelEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const models = [ + { + "name": 'projects/11111/locations/us-central1/models/3333333', + "displayName": 'untitled_1706619456701', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "modelSourceInfo": { "sourceType": 'AUTOML' }, + "encryptionSpec": { + "kmsKeyName": 'projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-2' + } + }, + { + "name": 'projects/11111/locations/us-central1/models/11111', + "displayName": 'untitled_1706619456701', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "modelSourceInfo": { "sourceType": 'AUTOML' } + }, +]; + +const createCache = (err, data, keysErr, keysList) => { + return { + vertexAI: { + listModels: { + 'us-central1': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'us-central1': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('modelEncryption', function () { + describe('run', function () { + it('should give unknown result if unable to query Vertex AI models', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Vertex AI models'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no models found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Vertex AI models found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if Vertex AI model has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [models[0]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if Vertex AI model does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [models[1]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }) + }) +}); \ No newline at end of file diff --git a/plugins/google/vertexai/modelLabelsAdded.js b/plugins/google/vertexai/modelLabelsAdded.js new file mode 100644 index 000000000..a0ccd94c9 --- /dev/null +++ b/plugins/google/vertexai/modelLabelsAdded.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Vertex AI Model Labels Added', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensure that all Vertex AI models have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/vertex-ai/docs/model-registry/model-labels', + recommended_action: 'Ensure labels are added to all Vertex AI models.', + realtime_triggers: ['aiplatform.ModelService.UpdateModel', 'aiplatform.ModelService.DeleteModel'], + apis: ['vertexAI:listModels'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.vertexAI, function(region, rcb){ + let models = helpers.addSource(cache, source, + ['vertexAI', 'listModels', region]); + + if (!models) return rcb(); + + if (models.err || !models.data) { + helpers.addResult(results, 3, 'Unable to query Vertex AI models', region, null, null, models.err); + return rcb(); + } + + if (!models.data.length) { + helpers.addResult(results, 0, 'No existing Vertex AI models found', region); + return rcb(); + } + + models.data.forEach(model => { + if (model.labels && + Object.keys(model.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(model.labels).length} labels found for Vertex AI model`, region, model.name); + } else { + helpers.addResult(results, 2, + 'Vertex AI model does not have any labels', region, model.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vertexai/modelLabelsAdded.spec.js b/plugins/google/vertexai/modelLabelsAdded.spec.js new file mode 100644 index 000000000..c47488597 --- /dev/null +++ b/plugins/google/vertexai/modelLabelsAdded.spec.js @@ -0,0 +1,123 @@ +var expect = require('chai').expect; +var plugin = require('./modelLabelsAdded'); + +const models = [ + { + "name": 'projects/11111/locations/us-central1/models/3333333', + "displayName": 'untitled_1706619456701', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "modelSourceInfo": { "sourceType": 'AUTOML' }, + "labels": { + "test": 'test' + } + }, + { + "name": 'projects/11111/locations/us-central1/models/11111', + "displayName": 'untitled_1706619456701', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "modelSourceInfo": { "sourceType": 'AUTOML' } + }, +]; + +const createCache = (err, data) => { + return { + vertexAI: { + listModels: { + 'us-central1': { + err: err, + data: data + } + } + } + } +}; + +describe('modelLabelsAdded', function () { + describe('run', function () { + it('should give unknown result if a model error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Vertex AI models'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no models are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Vertex AI models found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the model', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Vertex AI model'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [models[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the model', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Vertex AI model does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [models[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) diff --git a/plugins/google/vertexai/vertexAIDatasetEncryption.js b/plugins/google/vertexai/vertexAIDatasetEncryption.js new file mode 100644 index 000000000..99fa9bc19 --- /dev/null +++ b/plugins/google/vertexai/vertexAIDatasetEncryption.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Vertex AI Dataset Encryption', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'High', + description: 'Ensure that Vertex AI datasets are encrypted using desired encryption protection level.', + more_info: 'By default Google encrypts all datasets using Google-managed encryption keys. To have more control over the encryption process of your Vertex AI datasets you can use Customer-Managed Keys (CMKs).', + link: 'https://cloud.google.com/vertex-ai/docs/general/cmek', + recommended_action: 'Recreate existing datasets with desired protection level.', + apis: ['vertexAI:listDatasets', 'keyRings:list', 'cryptoKeys:list'], + realtime_triggers: ['aiplatform.DatasetService.CreateDataset', 'aiplatform.DatasetService.UpdateDataset', 'aiplatform.DatasetService.DeleteDataset'], + settings: { + vertexai_dataset_encryption_protection_level: { + name: 'Vertex AI Dataset Encryption Protection Level', + description: 'Desired protection level for Vertex AI datasets. default: google-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key, external: imported or externally managed key', + regex: '^(default|cloudcmek|cloudhsm|external)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let desiredEncryptionLevelStr = settings.vertexai_dataset_encryption_protection_level || this.settings.vertexai_dataset_encryption_protection_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + var keysObj = {}; + + async.series([ + function(cb) { + async.each(regions.cryptoKeys, function(region, rcb) { + let cryptoKeys = helpers.addSource( + cache, source, ['cryptoKeys', 'list', region]); + + if (cryptoKeys && cryptoKeys.data && cryptoKeys.data.length) helpers.listToObj(keysObj, cryptoKeys.data, 'name'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.vertexAI, function(region, rcb) { + let datasets = helpers.addSource(cache, source, + ['vertexAI', 'listDatasets', region]); + + if (!datasets) return rcb(); + + if (datasets.err || !datasets.data) { + helpers.addResult(results, 3, 'Unable to query Vertex AI datasets: ' + helpers.addError(datasets), region); + return rcb(); + } + + if (!datasets.data.length) { + helpers.addResult(results, 0, 'No existing Vertex AI datasets found', region); + return rcb(); + } + + async.each(datasets.data, (dataset) => { + let currentEncryptionLevel; + + if (dataset.encryptionSpec && dataset.encryptionSpec.kmsKeyName) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[dataset.encryptionSpec.kmsKeyName], helpers.PROTECTION_LEVELS); + } else { + currentEncryptionLevel = 1; //default + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Vertex AI dataset has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, + region, dataset.name); + } else { + helpers.addResult(results, 2, + `Vertex AI dataset has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, + region, dataset.name); + } + + }); + rcb(); + }, function() { + cb(); + }); + } + ], function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/vertexai/vertexAIDatasetEncryption.spec.js b/plugins/google/vertexai/vertexAIDatasetEncryption.spec.js new file mode 100644 index 000000000..73149c1ae --- /dev/null +++ b/plugins/google/vertexai/vertexAIDatasetEncryption.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +var plugin = require('./vertexAIDatasetEncryption'); + +const cryptoKeys = [ + { + name: "projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-2", + primary: { + name: "projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-1/cryptoKeyVersions/1", + state: "DESTROYED", + createTime: "2021-06-17T08:01:36.739860492Z", + destroyEventTime: "2021-06-18T11:17:00.798768Z", + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + generateTime: "2021-06-17T08:01:36.739860492Z", + }, + purpose: "ENCRYPT_DECRYPT", + createTime: "2021-06-17T08:01:36.739860492Z", + nextRotationTime: "2021-09-14T19:00:00Z", + rotationPeriod: "7776000s", + versionTemplate: { + protectionLevel: "SOFTWARE", + algorithm: "GOOGLE_SYMMETRIC_ENCRYPTION", + }, + } +]; +const datasets = [ + { + "name": 'projects/11111/locations/us-central1/datasets/33336', + "displayName": 'untitled_2222', + "metadataSchemaUri": 'gs://google-cloud-aiplatform/schema/dataset/metadata/text_1.0.0.yaml', + "createTime": '2024-01-30T12:58:02.933220Z', + "updateTime": '2024-01-31T06:45:38.630637Z', + "etag": "bbbbb", + "labels": { "test": 'test' }, + "dataItemCount": '40', + "encryptionSpec": { + "kmsKeyName": 'projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-2' + } + }, + { + "name": 'projects/11111/locations/us-central1/datasets/11111', + "displayName": 'untitled_1101', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "datasetSourceInfo": { "sourceType": 'AUTOML' } + }, +]; + +const createCache = (err, data, keysErr, keysList) => { + return { + vertexAI: { + listDatasets: { + 'us-central1': { + err: err, + data: data + } + } + }, + cryptoKeys: { + list: { + 'us-central1': { + err: keysErr, + data: keysList + } + } + }, + projects: { + get: { + 'global': { + data: [{ name: 'testproj' }] + } + } + } + } +}; + +describe('vertexAIDatasetEncryption', function () { + describe('run', function () { + it('should give unknown result if unable to query Vertex AI datasets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Vertex AI datasets'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if no datasets found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Vertex AI datasets found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if Vertex AI dataset has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('which is greater than or equal to'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [datasets[0]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }); + it('should give failing result if Vertex AI dataset does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which is less than'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [datasets[1]], + null, + cryptoKeys + ); + + plugin.run(cache, {}, callback); + }) + }) +}); diff --git a/plugins/google/vertexai/vertexAIDatasetLabels.js b/plugins/google/vertexai/vertexAIDatasetLabels.js new file mode 100644 index 000000000..4a4db7a16 --- /dev/null +++ b/plugins/google/vertexai/vertexAIDatasetLabels.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Vertex AI Dataset Labels Added', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Low', + description: 'Ensure that all Vertex AI datasets have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/resource-manager/docs/labels-overview', + recommended_action: 'Ensure labels are added to all Vertex AI datasets.', + apis: ['vertexAI:listDatasets'], + realtime_triggers: ['aiplatform.DatasetService.CreateDataset', 'aiplatform.DatasetService.UpdateDataset', 'aiplatform.DatasetService.DeleteDataset'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.vertexAI, function(region, rcb){ + let datasets = helpers.addSource(cache, source, + ['vertexAI', 'listDatasets', region]); + + if (!datasets) return rcb(); + + if (datasets.err || !datasets.data) { + helpers.addResult(results, 3, 'Unable to query Vertex AI datasets', region, null, null, datasets.err); + return rcb(); + } + + if (!datasets.data.length) { + helpers.addResult(results, 0, 'No existing Vertex AI datasets found', region); + return rcb(); + } + + datasets.data.forEach(dataset => { + if (dataset.labels && + Object.keys(dataset.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(dataset.labels).length} labels found for Vertex AI dataset`, region, dataset.name); + } else { + helpers.addResult(results, 2, + 'Vertex AI dataset does not have any labels', region, dataset.name); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vertexai/vertexAIDatasetLabels.spec.js b/plugins/google/vertexai/vertexAIDatasetLabels.spec.js new file mode 100644 index 000000000..442dcfc78 --- /dev/null +++ b/plugins/google/vertexai/vertexAIDatasetLabels.spec.js @@ -0,0 +1,119 @@ +var expect = require('chai').expect; +var plugin = require('./vertexAIDatasetLabels'); + +const datasets = [ + { + "name": 'projects/11111/locations/us-central1/datasets/33336', + "displayName": 'untitled_2222', + "metadataSchemaUri": 'gs://google-cloud-aiplatform/schema/dataset/metadata/text_1.0.0.yaml', + "createTime": '2024-01-30T12:58:02.933220Z', + "updateTime": '2024-01-31T06:45:38.630637Z', + "etag": "bbbbb", + "labels": { "test": 'test' }, + "dataItemCount": '40', + "encryptionSpec": { + "kmsKeyName": 'projects/test-dev/locations/us-central1/keyRings/test-kr/cryptoKeys/test-key-2' + } + }, + { + "name": 'projects/11111/locations/us-central1/datasets/11111', + "displayName": 'untitled_1101', + "supportedDeploymentResourcesTypes": ['AUTOMATIC_RESOURCES'], + "supportedInputStorageFormats": ['jsonl'], + "supportedOutputStorageFormats": ['jsonl'], + "createTime": '2024-01-30T13:09:20.818657Z', + "updateTime": '2024-01-31T06:06:12.979751Z', + "etag": 'bbbbbbbbb', + "versionId": '1', + "versionAliases": ['default'], + "versionCreateTime": '2024-01-30T13:09:20.818657Z', + "versionUpdateTime": '2024-01-31T03:38:17.129540Z', + "datasetSourceInfo": { "sourceType": 'AUTOML' } + }, +]; + + +const createCache = (err, data) => { + return { + vertexAI: { + listDatasets: { + 'us-central1': { + err: err, + data: data + } + } + } + } +}; + +describe('vertexAIDatasetLabels', function () { + describe('run', function () { + it('should give unknown result if a dataset error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query Vertex AI datasets'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no datasets are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Vertex AI datasets found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if labels have been added to the dataset', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Vertex AI dataset'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [datasets[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if no labels have been added to the dataset', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Vertex AI dataset does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + [datasets[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) diff --git a/plugins/google/vpcnetwork/defaultVPCExists.js b/plugins/google/vpcnetwork/defaultVPCExists.js new file mode 100644 index 000000000..b23481387 --- /dev/null +++ b/plugins/google/vpcnetwork/defaultVPCExists.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Default VPC Exists', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensures that your Google Cloud Project does not a default network.', + more_info: 'The default network has a preconfigured network configuration and automatically generates some insecure firewall rules which do not get audit logged and cannot be configured to enable firewall rule logging. Moreover, the subnets in default network use the same predefined range of IP addresses which makes it impossible to use Cloud VPN or VPC Network Peering with the default network.', + link: 'https://cloud.google.com/vpc/docs/vpc', + recommended_action: 'Delete the default network and create a new network with a different name.', + apis: ['networks:list'], + realtime_triggers: ['compute.networks.insert' , 'compute.networks.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.networks, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query VPC networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No VPC networks found', region); + return rcb(); + } + + let found; + + networks.data.forEach(network => { + let resource = helpers.createResourceName('networks', network.name, project, 'region', region); + + if (network.name === 'default') { + found = true; + helpers.addResult(results, 2, 'Default VPC Network exists in the project', region, resource); + } + }); + + if (!found) { + helpers.addResult(results, 0, 'Default VPC Network does not exist in the project', region, project); + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vpcnetwork/defaultVPCExists.spec.js b/plugins/google/vpcnetwork/defaultVPCExists.spec.js new file mode 100644 index 000000000..a68c999b6 --- /dev/null +++ b/plugins/google/vpcnetwork/defaultVPCExists.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +var plugin = require('./defaultVPCExists'); + +const createCache = (err, data) => { + return { + networks: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('defaultVPCExists', function () { + describe('run', function () { + it('should give unknown result if a network error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query VPC networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no network records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPC networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if default network exists in the project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default VPC Network exists in the project'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "default", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if default network does not exist in the project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default VPC Network does not exist in the project'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet1", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/defaultVpcInUse.js b/plugins/google/vpcnetwork/defaultVpcInUse.js index 9b2976037..4c326ad5e 100644 --- a/plugins/google/vpcnetwork/defaultVpcInUse.js +++ b/plugins/google/vpcnetwork/defaultVpcInUse.js @@ -4,23 +4,37 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Default VPC In Use', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines whether the default VPC is being used for launching VM instances', more_info: 'The default VPC should not be used in order to avoid launching multiple services in the same network which may not require connectivity. Each application, or network tier, should use its own VPC.', link: 'https://cloud.google.com/vpc/docs/vpc', recommended_action: 'Move resources from the default VPC to a new VPC created for that application or resource group.', - apis: ['networks:list', 'instances:compute:list'], + apis: ['networks:list', 'compute:list'], compliance: { pci: 'PCI has explicit requirements around default accounts and ' + 'resources. PCI recommends removing all default accounts, ' + 'only enabling necessary services as required for the function ' + 'of the system' }, + realtime_triggers: ['compute.networks.insert' , 'compute.networks.delete', 'compute.instances.insert', 'compute.instances.delete', 'compute.instances.updateNetworkInterface'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.networks, function(region, rcb){ let networks = helpers.addSource( cache, source, ['networks', 'list', region]); @@ -28,7 +42,7 @@ module.exports = { if (!networks) return rcb(); if (networks.err || !networks.data) { - helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region); + helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region, null, null, networks.err); return rcb(); } @@ -37,12 +51,13 @@ module.exports = { return rcb(); } var defVPC = false; - var vpcUrl = '' + var vpcUrl = ''; + networks.data.forEach(network => { - if (network.name == 'default') { + if (network.name == 'default') { defVPC = true; vpcUrl = network.selfLink; - } + } }); if (!defVPC) { helpers.addResult(results, 0, 'No default VPC found', 'global'); @@ -53,34 +68,33 @@ module.exports = { async.each(regions.zones, function(location, icb){ location.forEach(loc => { let instances = helpers.addSource(cache, source, - ['instances', 'compute','list', loc]); + ['compute','list', loc]); - if (!instances || instances.err || !instances.data) { - } else if (instances.data.length) { + if (instances && instances.data && instances.data.length) { instances.data.forEach(instance => { instance.networkInterfaces.forEach(interface => { - if (interface.network = vpcUrl) { + if (interface.network == vpcUrl) { numInstances += 1; } }); }); } - }, function() { - icb(); }); - }); - if (!numInstances) { - helpers.addResult(results, 0, 'Default VPC is not in use', region); - return rcb(); - } else { - var numStr = numInstances + ' VM instance' + (numInstances === 1 ? '' : 's') + '; '; - helpers.addResult(results, 2, 'Default VPC is in use: ' + numStr, region); - return rcb(); - } + icb(); + }, function() { + let resource = helpers.createResourceName('networks', 'default', project, 'global'); + if (!numInstances) { + helpers.addResult(results, 0, 'Default VPC is not in use', region, resource); + } else { + var numStr = numInstances + ' VM instance' + (numInstances === 1 ? '' : 's') + '; '; + helpers.addResult(results, 2, 'Default VPC is in use: ' + numStr, region, resource); + } + rcb(); + }); }, function(){ // Global checking goes here callback(null, results, source); }); } -} +}; diff --git a/plugins/google/vpcnetwork/defaultVpcInUse.spec.js b/plugins/google/vpcnetwork/defaultVpcInUse.spec.js new file mode 100644 index 000000000..9082399c5 --- /dev/null +++ b/plugins/google/vpcnetwork/defaultVpcInUse.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +var plugin = require('./defaultVpcInUse'); + +const createCache = (err, data) => { + instances = [] + if(data && data.length){ + instances = data[0].instances + } + return { + networks: { + list: { + 'global': { + err: err, + data: data + } + } + }, + compute:{ + list: { + 'us-central1-a':{ + err: err, + data: instances + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('defaultVpcInUse', function () { + describe('run', function () { + it('should give unknown result if a subnetwork error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no subnetwork records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no default vpc is found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No default VPC found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if default vpc is not in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default VPC is not in use'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "default", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + instances: [ + { + id: "5266474242833977495", + creationTimestamp: "2021-04-09T12:43:53.256-07:00", + name: "testing1", + description: "", + tags: { + fingerprint: "42WmSpB8rSM=", + }, + machineType: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/machineTypes/e2-micro", + status: "RUNNING", + zone: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a", + canIpForward: false, + networkInterfaces: [ + { + network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default-vpc", + subnetwork: "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/default-vpc", + networkIP: "10.128.0.2", + name: "nic0", + accessConfigs: [ + { + type: "ONE_TO_ONE_NAT", + name: "External NAT", + natIP: "34.67.184.85", + networkTier: "PREMIUM", + kind: "compute#accessConfig", + }, + ], + fingerprint: "0ooE15ntQvk=", + kind: "compute#networkInterface", + }, + ], + } + ] + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if default vpc is in use', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default VPC is in use'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "default", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + instances: [ + { + id: "5266474242833977495", + creationTimestamp: "2021-04-09T12:43:53.256-07:00", + name: "testing1", + description: "", + tags: { + fingerprint: "42WmSpB8rSM=", + }, + machineType: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/machineTypes/e2-micro", + status: "RUNNING", + zone: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a", + canIpForward: false, + networkInterfaces: [ + { + network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", + subnetwork: "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/default-vpc", + networkIP: "10.128.0.2", + name: "nic0", + accessConfigs: [ + { + type: "ONE_TO_ONE_NAT", + name: "External NAT", + natIP: "34.67.184.85", + networkTier: "PREMIUM", + kind: "compute#accessConfig", + }, + ], + fingerprint: "0ooE15ntQvk=", + kind: "compute#networkInterface", + }, + ], + } + ] + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/dnsLoggingEnabled.js b/plugins/google/vpcnetwork/dnsLoggingEnabled.js new file mode 100644 index 000000000..df4e148d0 --- /dev/null +++ b/plugins/google/vpcnetwork/dnsLoggingEnabled.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'VPC DNS Logging Enabled', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure that All VPC Network has DNS logging enabled.', + more_info: 'Cloud DNS logging records the queries coming from Compute Engine VMs, GKE containers, or other GCP resources provisioned within the VPC to Stackdriver.', + link: 'https://cloud.google.com/dns/docs/monitoring', + recommended_action: 'Create Cloud DNS Server Policy with logging enabled for VPC Networks', + apis: ['networks:list', 'policies:list'], + realtime_triggers: ['compute.networks.insert' , 'compute.networks.delete', 'dns.policies.create', 'dns.policies.patch', 'compute.networks.patch', 'dns.policies.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.networks, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No networks found', region); + return rcb(); + } + + let policies = helpers.addSource( + cache, source, ['policies', 'list', region]); + + if (!policies || policies.err || !policies.data) { + helpers.addResult(results, 3, 'Unable to query DNS policies: ' + helpers.addError(policies), region, null, null, policies.err); + return rcb(); + } + + var loggedVpcs = []; + policies.data.forEach(policy => { + if (policy.enableLogging && policy.networks && policy.networks.length) { + policy.networks.forEach(network => { + if (network.networkUrl) loggedVpcs.push(network.networkUrl); + }); + } + }); + + networks.data.forEach(network => { + if (!network.name) return; + + let resource = helpers.createResourceName('networks', network.name, project, 'global'); + if (loggedVpcs.includes(network.selfLink)){ + helpers.addResult(results, 0, + 'VPC Network has DNS logging enabled', region, resource); + } else { + helpers.addResult(results, 2, + 'VPC Network does not have DNS logging enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vpcnetwork/dnsLoggingEnabled.spec.js b/plugins/google/vpcnetwork/dnsLoggingEnabled.spec.js new file mode 100644 index 000000000..a5dc72663 --- /dev/null +++ b/plugins/google/vpcnetwork/dnsLoggingEnabled.spec.js @@ -0,0 +1,140 @@ +var expect = require('chai').expect; +var plugin = require('./dnsLoggingEnabled'); + +const networks = [ + { + "creationTimestamp": "2021-06-15T03:39:44.455-07:00", + "name": "test-vpc", + "description": "plugin vpc", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc", + "autoCreateSubnetworks": false, + "routingConfig": { + "routingMode": "REGIONAL" + }, + "mtu": 1460, + "kind": "compute#network" + }, + { + "creationTimestamp": "2021-06-15T03:39:44.455-07:00", + "name": "test-vpc-1", + "description": "plugin vpc", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc-1", + "autoCreateSubnetworks": false, + "routingConfig": { + "routingMode": "REGIONAL" + }, + "mtu": 1460, + "kind": "compute#network" + }, +]; + +const policies = [ + { + "id": "7088602240669436579", + "name": "test-policy", + "enableInboundForwarding": true, + "description": "abc", + "networks": [ + { + "networkUrl": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc", + "kind": "dns#policyNetwork" + } + ], + "enableLogging": true, + "kind": "dns#policy" + } +]; + +const createCache = (networks, networkErr, policies, policiesErr) => { + return { + networks: { + list: { + 'global': { + err: networkErr, + data: networks + } + } + }, + policies: { + list: { + 'global': { + err: policiesErr, + data: policies + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('dnsLoggingEnabled', function () { + describe('run', function () { + it('should give unknown result if network error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + ['error'], + ); + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no network found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [], + null, + ); + plugin.run(cache, {}, callback); + }); + + it('should give passing result if VPC Network has DNS logging enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VPC Network has DNS logging enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [networks[0]], + null, + policies + ); + plugin.run(cache, {}, callback); + }); + + it('should give failing result if VPC Network does not have DNS logging enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VPC Network does not have DNS logging enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [networks[1]], + null, + policies + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/excessiveFirewallRules.js b/plugins/google/vpcnetwork/excessiveFirewallRules.js index f33b98dd2..a2f01f813 100644 --- a/plugins/google/vpcnetwork/excessiveFirewallRules.js +++ b/plugins/google/vpcnetwork/excessiveFirewallRules.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Excessive Firewall Rules', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determines if there are an excessive number of firewall rules in the account', more_info: 'Keeping the number of firewall rules to a minimum helps reduce the attack surface of an account. Rather than creating new rules with the same rules for each project, common rules should be grouped under the same firewall rule. For example, instead of adding port 22 from a known IP to every firewall rule, create a single "SSH" firewall rule which can be used on multiple instances.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', @@ -29,6 +31,7 @@ module.exports = { default: 30 } }, + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete'], run: function(cache, settings, callback) { var config = { @@ -49,7 +52,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -74,4 +77,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/excessiveFirewallRules.spec.js b/plugins/google/vpcnetwork/excessiveFirewallRules.spec.js new file mode 100644 index 000000000..7326be97e --- /dev/null +++ b/plugins/google/vpcnetwork/excessiveFirewallRules.spec.js @@ -0,0 +1,345 @@ +var expect = require('chai').expect; +var plugin = require('./excessiveFirewallRules'); + +const createCache = (err, data) => { + return { + firewalls: { + list: { + 'global': { + err: err, + data: data + } + } + } + } +}; + +describe('excessiveFirewallRules', function () { + describe('run', function () { + it('should give unknown result if a firewall error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query firewall rules'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no firewall rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No firewall rules found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if excessive number of firewall rules are present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Excessive'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give warning result if large number of firewall rules are present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(1); + expect(results[0].message).to.include('Large'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if acceptable number of firewall rules are present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Acceptable'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "8212979312924624092", + }, + { + id: "6488532437295615846", + }, + { + id: "8442800633521702578", + }, + { + id: "4111718641158512144", + }, + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/firewallLoggingMetadata.js b/plugins/google/vpcnetwork/firewallLoggingMetadata.js new file mode 100644 index 000000000..c7d7e8ae2 --- /dev/null +++ b/plugins/google/vpcnetwork/firewallLoggingMetadata.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Firewall Logging Metadata', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure that VPC Network firewall logging is configured to exclude logging metadata in order to reduce the size of the log files.', + more_info: 'You can significantly reduce the size of your log files and optimize storage costs by not including metadata. By default, metadata is included in firewall rule log files.', + link: 'https://cloud.google.com/vpc/docs/firewall-rules-logging', + recommended_action: 'Ensure that metadata is not included in firewall rule log files.', + apis: ['networks:list', 'firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.networks.insert' , 'compute.networks.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.networks, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No networks found', region); + return rcb(); + } + + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + var loggedMetadataVPCs = []; + firewalls.data.forEach(firewall => { + if (!firewall.disabled && firewall.logConfig && firewall.logConfig.enable && firewall.logConfig.metadata == 'INCLUDE_ALL_METADATA') { + loggedMetadataVPCs.push(firewall.network); + } + }); + + networks.data.forEach(network => { + if (!network.name) return; + + let resource = helpers.createResourceName('networks', network.name, project, 'global'); + if (loggedMetadataVPCs.includes(network.selfLink)){ + helpers.addResult(results, 2, + 'VPC Network has firewall metadata logging enabled', region, resource); + } else { + helpers.addResult(results, 0, + 'VPC Network does not have firewall metadata logging enabled', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/firewallLoggingMetadata.spec.js b/plugins/google/vpcnetwork/firewallLoggingMetadata.spec.js new file mode 100644 index 000000000..4a00785c7 --- /dev/null +++ b/plugins/google/vpcnetwork/firewallLoggingMetadata.spec.js @@ -0,0 +1,152 @@ +var expect = require('chai').expect; +var plugin = require('./firewallLoggingMetadata'); + +const networks = [ + { + "creationTimestamp": "2021-06-15T03:39:44.455-07:00", + "name": "test-vpc", + "description": "plugin vpc", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc", + "autoCreateSubnetworks": false, + "routingConfig": { + "routingMode": "REGIONAL" + }, + "mtu": 1460, + "kind": "compute#network" + }, + { + "creationTimestamp": "2021-06-15T03:39:44.455-07:00", + "name": "test-vpc-1", + "description": "plugin vpc", + "selfLink": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc-1", + "autoCreateSubnetworks": false, + "routingConfig": { + "routingMode": "REGIONAL" + }, + "mtu": 1460, + "kind": "compute#network" + }, +]; + +const firewalls = [ + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true}, + disabled: false, + kind: 'compute#firewall' + }, + { + id: '7656774017226387060', + creationTimestamp: '2021-05-07T12:10:19.939-07:00', + name: 'default-allow-ssh', + description: 'Allow SSH from anywhere', + network: 'https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc-1', + priority: 65534, + sourceRanges: [ '0.0.0.0/0' ], + direction: 'INGRESS', + logConfig: { enable: true, metadata: 'INCLUDE_ALL_METADATA' }, + disabled: false, + kind: 'compute#firewall' + } +]; + +const createCache = (networks, networkErr, firewalls, firewallsErr) => { + return { + networks: { + list: { + 'global': { + err: networkErr, + data: networks + } + } + }, + firewalls: { + list: { + 'global': { + err: firewallsErr, + data: firewalls + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('firewallLoggingMetadata', function () { + describe('run', function () { + it('should give unknown result if network error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + ['error'], + ); + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no network found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [], + null, + ); + plugin.run(cache, {}, callback); + }); + + it('should give passing result if VPC Network does not have firewall metadata logging enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VPC Network does not have firewall metadata logging enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [networks[0]], + null, + [firewalls[0]] + ); + plugin.run(cache, {}, callback); + }); + + it('should give failing result if VPC Network has firewall metadata logging enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VPC Network has firewall metadata logging enabled'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [networks[1]], + null, + [firewalls[1]] + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/flowLogsEnabled.js b/plugins/google/vpcnetwork/flowLogsEnabled.js index e5b6c0c42..2b4aa6da6 100644 --- a/plugins/google/vpcnetwork/flowLogsEnabled.js +++ b/plugins/google/vpcnetwork/flowLogsEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Flow Logs Enabled', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures VPC flow logs are enabled for traffic logging', more_info: 'VPC flow logs record all traffic flowing in to and out of a VPC. These logs are critical for auditing and review after security incidents.', link: 'https://cloud.google.com/vpc/docs/using-flow-logs', @@ -16,12 +18,24 @@ module.exports = { pci: 'PCI requires logging of all network access to environments containing ' + 'cardholder data. Enable VPC flow logs to log these network requests.' }, + realtime_triggers: ['compute.subnetworks.insert', 'compute.subnetworks.patch','compute.subnetworks.delete'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.subnetworks, function(region, rcb){ let subnetworks = helpers.addSource(cache, source, ['subnetworks', 'list', region]); @@ -29,7 +43,7 @@ module.exports = { if (!subnetworks) return rcb(); if (subnetworks.err || !subnetworks.data) { - helpers.addResult(results, 3, 'Unable to query subnets: ' + helpers.addError(subnetworks), region); + helpers.addResult(results, 3, 'Unable to query subnets: ' + helpers.addError(subnetworks), region, null, null, subnetworks.err); return rcb(); } @@ -38,16 +52,24 @@ module.exports = { return rcb(); } + let found = false; subnetworks.data.forEach(subnet => { + let resource = helpers.createResourceName('subnetworks', subnet.name, project, 'region', region); if (subnet.creationTimestamp && !subnet.enableFlowLogs) { + found = true; helpers.addResult(results, 2, - 'The subnet does not have flow logs enabled', region, subnet.id); + 'The subnet does not have flow logs enabled', region, resource); } else if (subnet.creationTimestamp) { - helpers.addResult(results, 0, 'The subnet has flow logs enabled', region, subnet.id); + found = true; + helpers.addResult(results, 0, 'The subnet has flow logs enabled', region, resource); } }); + if (!found) { + helpers.addResult(results, 0, 'No subnetworks present', region); + } + rcb(); }, function(){ // Global checking goes here diff --git a/plugins/google/vpcnetwork/flowLogsEnabled.spec.js b/plugins/google/vpcnetwork/flowLogsEnabled.spec.js index 77e984b13..25de7d85e 100644 --- a/plugins/google/vpcnetwork/flowLogsEnabled.spec.js +++ b/plugins/google/vpcnetwork/flowLogsEnabled.spec.js @@ -1,4 +1,3 @@ -var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./flowLogsEnabled'); @@ -11,6 +10,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } } } }; diff --git a/plugins/google/vpcnetwork/instanceDefaultNetwork.js b/plugins/google/vpcnetwork/instanceDefaultNetwork.js new file mode 100644 index 000000000..1ebf2e119 --- /dev/null +++ b/plugins/google/vpcnetwork/instanceDefaultNetwork.js @@ -0,0 +1,97 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Instance Default Network', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure no VM instances exist in default network.', + more_info: 'Every GCP project comes with a default network with pre-populated firewall rules. A default network is suitable for getting started quickly, and for launching public instances for simple websites. But, if you need to host a complex multi-tier application or add more layers of security to your infrastructure it is a best practice to create non-default network with public, private subnets & demilitarized (DMZ) zones. This segregates the network based on their functionality, services, and security.', + link: 'https://cloud.google.com/vpc/docs/vpc', + recommended_action: 'Ensure the default network does not have any VM instances.', + apis: ['networks:list', 'compute:list'], + realtime_triggers: ['compute.networks.insert' , 'compute.networks.delete', 'compute.instances.insert', 'compute.instances.delete', 'compute.instances.updateNetworkInterface'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + let defaultNetwork, resource; + async.each(regions.networks, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query VPC networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No VPC networks found', region); + return rcb(); + } + + defaultNetwork = networks.data.find(network => network.name === 'default'); + if (defaultNetwork) { + resource = helpers.createResourceName('networks', defaultNetwork.name, project, 'region', region); + } + rcb(); + }, function(){ + if (!defaultNetwork) { + helpers.addResult(results, 0, 'Default Network does not exist in the project', 'global'); + return callback(null, results, source); + } + let instanceCount = 0; + async.each(regions.compute, (region, rcb) => { + var zones = regions.zones; + async.each(zones[region], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute','list', zone ]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + return zcb(); + } + + instances.data.forEach(instance => { + if (instance.networkInterfaces && + instance.networkInterfaces.find(interface => interface.network === defaultNetwork.selfLink)) { + instanceCount++; + } + }); + zcb(); + }, function() { + rcb(); + }); + }, function() { + if (instanceCount > 0) { + helpers.addResult(results, 2, `Default Network has ${instanceCount} VM instances`, 'global', resource); + } else { + helpers.addResult(results, 0, 'Default Network does not have any VM instances', 'global', resource); + } + callback(null, results, source); + }); + + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/instanceDefaultNetwork.spec.js b/plugins/google/vpcnetwork/instanceDefaultNetwork.spec.js new file mode 100644 index 000000000..9aede5d3c --- /dev/null +++ b/plugins/google/vpcnetwork/instanceDefaultNetwork.spec.js @@ -0,0 +1,230 @@ +var expect = require('chai').expect; +var plugin = require('./instanceDefaultNetwork'); + +const createCache = (err, data, instanceErr, instanceData) => { + return { + networks: { + list: { + 'global': { + err: err, + data: data + } + } + }, + compute: { + list: { + 'us-central1-a': { + err: instanceErr, + data: instanceData + } + } + }, + projects: { + get: { + 'global': { + data: 'testproject' + } + } + } + } +}; +const instances = [ + { + "kind": "compute#instance", + "id": "3111111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-3", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/testproject/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/testproject/zones/us-central1-a", + "canIpForward": true, + "networkInterfaces": [ + { + "kind": "compute#networkInterface", + "network": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", + "subnetwork": "https://www.googleapis.com/compute/v1/projects/testproject/regions/us-central1/subnetworks/default", + "networkIP": "10.128.0.5", + "name": "nic0", + "accessConfigs": [ + { + "kind": "compute#accessConfig", + "type": "ONE_TO_ONE_NAT", + "name": "External NAT", + "natIP": "35.193.110.217", + "networkTier": "PREMIUM" + } + ], + "fingerprint": "Wq0vYR9v5BQ=" + } + ] + }, + { + "kind": "compute#instance", + "id": "3111111111", + "creationTimestamp": "2019-10-04T13:44:44.117-07:00", + "name": "instance-2", + "description": "", + "tags": { + "fingerprint": "42WmSpB8rSM=" + }, + "machineType": "https://www.googleapis.com/compute/v1/projects/testproject/zones/us-central1-a/machineTypes/n1-standard-1", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/testproject/zones/us-central1-a", + "canIpForward": true, + "networkInterfaces": [ + { + "kind": "compute#networkInterface", + "network": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + "subnetwork": "https://www.googleapis.com/compute/v1/projects/testproject/regions/us-central1/subnetworks/default", + "networkIP": "10.128.0.5", + "name": "nic0", + "accessConfigs": [ + { + "kind": "compute#accessConfig", + "type": "ONE_TO_ONE_NAT", + "name": "External NAT", + "natIP": "35.193.110.217", + "networkTier": "PREMIUM" + } + ], + "fingerprint": "Wq0vYR9v5BQ=" + } + ] + } +] +const networks = [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "default", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + }, + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet1", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } +] + +describe('instanceDefaultNetwork', function () { + describe('run', function () { + it('should give unknown result if a network error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query VPC networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no network records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPC networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if default network does not exist in the project', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default Network does not exist in the project'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [networks[1]], + ); + plugin.run(cache, {}, callback); + }); + it('should give unknown if an instance error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query compute instances'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + null, + networks, + ['error'], + [] + ); + + plugin.run(cache, {}, callback); + }); + it('should give passing result if default network does not have VM instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Default Network does not have any VM instances'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + networks, + null, + [instances[1]] + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if default network has VM instances', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Default Network has'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + networks, + null, + instances + ); + plugin.run(cache, {}, callback); + }); + + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/legacyNetworksExist.js b/plugins/google/vpcnetwork/legacyNetworksExist.js new file mode 100644 index 000000000..29285a2c8 --- /dev/null +++ b/plugins/google/vpcnetwork/legacyNetworksExist.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Legacy Network Exists', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that your Google Cloud Project does not have legacy networks', + more_info: 'Legacy networks have a single network IPv4 prefix range and a single gateway IP address for the whole network, they do not allow creation of subnets which can impact high network traffic projects.', + link: 'https://cloud.google.com/vpc/docs/legacy', + recommended_action: 'Ensure that there are no legacy networks in the GCP Project.', + apis: ['networks:list'], + realtime_triggers: ['compute.networks.insert', 'compute.networks.delete', 'compute.networks.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + async.each(regions.networks, function(region, rcb){ + let networks = helpers.addSource( + cache, source, ['networks', 'list', region]); + + if (!networks) return rcb(); + + if (networks.err || !networks.data) { + helpers.addResult(results, 3, 'Unable to query VPC networks: ' + helpers.addError(networks), region, null, null, networks.err); + return rcb(); + } + + if (!networks.data.length) { + helpers.addResult(results, 0, 'No VPC networks found', region); + return rcb(); + } + + networks.data.forEach(network => { + let resource = helpers.createResourceName('networks', network.name, project, 'region', region); + + if (Object.prototype.hasOwnProperty.call(network, 'autoCreateSubnetworks')) { + helpers.addResult(results, 0, + 'VPC Network is not in legacy mode', region, resource); + } else { + helpers.addResult(results, 2, 'VPC Network is in legacy mode', region, resource); + } + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/legacyNetworksExist.spec.js b/plugins/google/vpcnetwork/legacyNetworksExist.spec.js new file mode 100644 index 000000000..8a40800c2 --- /dev/null +++ b/plugins/google/vpcnetwork/legacyNetworksExist.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +var plugin = require('./legacyNetworksExist'); + +const createCache = (err, data) => { + return { + networks: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('legacyNetworksExist', function () { + describe('run', function () { + it('should give unknown result if a network error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query VPC networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no network records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No VPC networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if vpc network is in legacy mode', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('VPC Network is in legacy mode'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if vpc network is not in legacy mode', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('VPC Network is not in legacy mode'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "123456", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet1", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/multipleSubnets.js b/plugins/google/vpcnetwork/multipleSubnets.js index a401c8b0b..696f020c0 100644 --- a/plugins/google/vpcnetwork/multipleSubnets.js +++ b/plugins/google/vpcnetwork/multipleSubnets.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Multiple Subnets', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures that VPCs have multiple networks to provide a layered architecture', more_info: 'A single network within a VPC increases the risk of a broader blast radius in the event of a compromise.', link: 'https://cloud.google.com/vpc/docs/vpc', recommended_action: 'Create multiple networks/subnets in each VPC and change the architecture to take advantage of public and private tiers.', apis: ['networks:list'], + realtime_triggers: ['compute.networks.insert', 'compute.networks.delete', 'compute.subnetworks.insert', 'compute.subnetworks.delete'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!networks) return rcb(); if (networks.err || !networks.data) { - helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region); + helpers.addResult(results, 3, 'Unable to query networks: ' + helpers.addError(networks), region, null, null, networks.err); return rcb(); } @@ -30,6 +33,18 @@ module.exports = { helpers.addResult(results, 0, 'No networks found', region); return rcb(); } + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + var subnetRegions; networks.data.forEach(network => { regions = helpers.regions(); @@ -41,23 +56,23 @@ module.exports = { var subnets = network.subnetworks; subnetRegions = regions.zones; - if (subnets) { + if (subnets && subnets.length) { subnets.forEach(subnet => { var splitSubnet = subnet.split('/'); - subnetName = splitSubnet[10]; - subnetRegion = splitSubnet[8]; + var subnetName = splitSubnet[10]; + var subnetRegion = splitSubnet[8]; - if (subnetRegions.hasOwnProperty(subnetRegion) && subnetName != 'default') { + if (subnetRegions[subnetRegion] && subnetName != 'default') { if (!myRegions[subnetRegion]) { myRegions[subnetRegion] = 1; } else { myRegions[subnetRegion] += 1; } - } else if (subnetRegions.hasOwnProperty(subnetRegion) && subnetName == 'default') { + } else if (subnetRegions[subnetRegion] && subnetName == 'default') { myRegions[subnetRegion] = 0.5; - } else if (!subnetRegions.hasOwnProperty(subnetRegion) && subnetName == 'default') { + } else if (!subnetRegions[subnetRegion] && subnetName == 'default') { if (!myRegions[subnetRegion]) { myRegions[subnetRegion] = .5; } else { @@ -65,7 +80,7 @@ module.exports = { } - } else if (!subnetRegions.hasOwnProperty(subnetRegion) && subnetName != 'default') { + } else if (!subnetRegions[subnetRegion] && subnetName != 'default') { if (!myRegions[subnetRegion]) { myRegions[subnetRegion] = 1; } else { @@ -74,6 +89,8 @@ module.exports = { } }); + } else { + noNetworks.push(1); } for (var sub in myRegions) { if (Math.floor(myRegions[sub]) > 1) { @@ -82,30 +99,29 @@ module.exports = { failNetworks.push(sub); } else if (myRegions[sub] == .5) { warnNetworks.push(sub); - } else if(myRegions[sub] == 0) { - noNetworks.push(sub); } } + let resource = helpers.createResourceName('networks', network.name, project, 'global'); if (passNetworks.length) { - var msg = 'There are ' + myRegions[sub] + ' different subnets used in these regions: '; + let msg = 'There are ' + myRegions[sub] + ' different subnets used in these regions: '; helpers.addResult(results, 0, - msg + passNetworks.join(', '), null, network.id); + msg + passNetworks.join(', '), null, resource); } if (failNetworks.length) { - var msg = 'Only one subnet in these regions is used: '; + let msg = 'Only one subnet in these regions is used: '; helpers.addResult(results, 2, - msg + failNetworks.join(', '), null, network.id); + msg + failNetworks.join(', '), null, resource); } if (warnNetworks.length) { - var msg = 'Only the default subnet in these regions is used: '; + let msg = 'Only the default subnet in these regions is used: '; helpers.addResult(results, 2, - msg + warnNetworks.join(', '), null, network.id); + msg + warnNetworks.join(', '), null, resource); } if (noNetworks.length) { - var msg = 'The VPC does not have any subnets in these regions: '; + let msg = 'The VPC does not have any subnets'; helpers.addResult(results, 0, - msg + noNetworks.join(', '), null, network.id); + msg, null, resource); } }); @@ -115,4 +131,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/multipleSubnets.spec.js b/plugins/google/vpcnetwork/multipleSubnets.spec.js new file mode 100644 index 000000000..609cbfdcb --- /dev/null +++ b/plugins/google/vpcnetwork/multipleSubnets.spec.js @@ -0,0 +1,179 @@ +var expect = require('chai').expect; +var plugin = require('./multipleSubnets'); + +const createCache = (err, data) => { + return { + networks: { + list: { + 'global': { + err: err, + data: data + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('multipleSubnets', function () { + describe('run', function () { + it('should give unknown result if a subnetwork error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query networks'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + ['error'], + null, + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if no subnetwork records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No networks found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if only one subnet is used in provided regions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Only one subnet in these regions is used'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if more than one subnet is used in provided regions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('different subnets used in these regions'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet", + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/oregon-subnet1", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give failing result if only the default subnet is used in provided regions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Only the default subnet in these regions is used'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-east1/subnetworks/default", + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + it('should give passing result if the VPC does not have any subnets in provided regions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('The VPC does not have any subnets'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + [ + { + id: "459972978914955087", + creationTimestamp: "2021-02-16T22:03:12.817-08:00", + name: "app-vpc", + description: "App VPC", + selfLink: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/app-vpc", + autoCreateSubnetworks: false, + subnetworks: [ + ], + routingConfig: { + routingMode: "GLOBAL", + }, + mtu: 1460, + kind: "compute#network", + } + ], + ); + plugin.run(cache, {}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openAllPorts.js b/plugins/google/vpcnetwork/openAllPorts.js index 51aac5365..d8b7dfc93 100644 --- a/plugins/google/vpcnetwork/openAllPorts.js +++ b/plugins/google/vpcnetwork/openAllPorts.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open All Ports', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if all ports are open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, services should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', @@ -18,6 +20,7 @@ module.exports = { 'Firewalls should be properly secured to prevent access to ' + 'backend services.' }, + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -31,7 +34,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -40,7 +43,7 @@ module.exports = { return rcb(); } - helpers.findOpenAllPorts(firewalls.data, region, results); + helpers.findOpenAllPorts(firewalls.data, region, results, cache, source); rcb(); }, function(){ @@ -48,4 +51,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openAllPorts.spec.js b/plugins/google/vpcnetwork/openAllPorts.spec.js new file mode 100644 index 000000000..ab4c8131d --- /dev/null +++ b/plugins/google/vpcnetwork/openAllPorts.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openAllPorts = require('./openAllPorts'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "all" }], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ] }], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openAllPorts', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has all ports open to public', function (done) { + const cache = createCache([firewalls[0]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openAllPortsEgress.js b/plugins/google/vpcnetwork/openAllPortsEgress.js new file mode 100644 index 000000000..1df73a3f4 --- /dev/null +++ b/plugins/google/vpcnetwork/openAllPortsEgress.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open All Ports Egress', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Ensure no firewall rules allow egress to all ports and protocols.', + more_info: 'Allowing outbound traffic to all protocols and ports can lead to internal resources accessing unwanted and untrusted resources. It is a best practice to follow the principle of least privilege, and grant access to only required protocols and ports.', + link: 'https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules', + recommended_action: 'Restrict outbound traffic to only required protocols and ports.', + apis: ['firewalls:list'], + compliance: { + hipaa: 'HIPAA requires strict access controls to networks and services ' + + 'processing sensitive data. Firewalls are the built-in ' + + 'method for restricting access to services and should be ' + + 'configured to allow least-privilege access.', + pci: 'PCI has explicit requirements around firewalled access to systems. ' + + 'Firewalls should be properly secured to prevent access to ' + + 'backend services.' + }, + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + helpers.findOpenAllPortsEgress(firewalls.data, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openAllPortsEgress.spec.js b/plugins/google/vpcnetwork/openAllPortsEgress.spec.js new file mode 100644 index 000000000..b8783b1bf --- /dev/null +++ b/plugins/google/vpcnetwork/openAllPortsEgress.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openAllPorts = require('./openAllPortsEgress'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "destinationRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "all" }], + "direction": "EGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "destinationRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ] }], + "direction": "EGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openAllPortsEgress', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has all ports open to public', function (done) { + const cache = createCache([firewalls[0]]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openAllPorts.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openCIFS.js b/plugins/google/vpcnetwork/openCIFS.js index a9708d362..10c7df509 100644 --- a/plugins/google/vpcnetwork/openCIFS.js +++ b/plugins/google/vpcnetwork/openCIFS.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open CIFS', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if UDP port 445 for CIFS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as CIFS should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict UDP port 445 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'CIFS'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openCIFS.spec.js b/plugins/google/vpcnetwork/openCIFS.spec.js new file mode 100644 index 000000000..773191e84 --- /dev/null +++ b/plugins/google/vpcnetwork/openCIFS.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openCIFS = require('./openCIFS'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "445" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCIFS', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule UDP port 445 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCIFS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openCassandra.js b/plugins/google/vpcnetwork/openCassandra.js new file mode 100644 index 000000000..f68f0ec79 --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandra.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Cassandra', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 7001 for Cassandra is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP ports 7001 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [7001] + }; + + let service = 'Cassandra'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openCassandra.spec.js b/plugins/google/vpcnetwork/openCassandra.spec.js new file mode 100644 index 000000000..3dda966de --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandra.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openCassandra = require('./openCassandra'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "7001" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCassandra', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCassandra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 7001 for Cassandra is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCassandra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCassandra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCassandra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCassandra.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openCassandraClient.js b/plugins/google/vpcnetwork/openCassandraClient.js new file mode 100644 index 000000000..164a688af --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraClient.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Cassandra Client', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 9042 for Cassandra Client is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Client should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 9042 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [9042] + }; + + let service = 'Cassandra Client'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openCassandraClient.spec.js b/plugins/google/vpcnetwork/openCassandraClient.spec.js new file mode 100644 index 000000000..3f3a912fe --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraClient.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openCassandraClient = require('./openCassandraClient'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "9042" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCassandraClient', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 9042 for Cassandra Client open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCassandraClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openCassandraInternode.js b/plugins/google/vpcnetwork/openCassandraInternode.js new file mode 100644 index 000000000..71e5df58e --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraInternode.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Cassandra Internode', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 7000 for Cassandra Internode is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Internode should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 7000 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [7000] + }; + + let service = 'Cassandra Internode'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vpcnetwork/openCassandraInternode.spec.js b/plugins/google/vpcnetwork/openCassandraInternode.spec.js new file mode 100644 index 000000000..b14e0a697 --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraInternode.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +const openCassandraInternode = require('./openCassandraInternode'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "7000" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCassandraInternode', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 7000 for Cassandra Internode open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCassandraInternode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/google/vpcnetwork/openCassandraMonitoring.js b/plugins/google/vpcnetwork/openCassandraMonitoring.js new file mode 100644 index 000000000..dd3767b93 --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraMonitoring.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Cassandra Monitoring', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 7199 for Cassandra Monitoring is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Monitoring should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 7199 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [7199] + }; + + let service = 'Cassandra Monitoring'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vpcnetwork/openCassandraMonitoring.spec.js b/plugins/google/vpcnetwork/openCassandraMonitoring.spec.js new file mode 100644 index 000000000..1e167913c --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraMonitoring.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +const openCassandraMonitoring = require('./openCassandraMonitoring'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "7199" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCassandraMonitoring', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 7199 for Cassandra Monitoring open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCassandraMonitoring.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/google/vpcnetwork/openCassandraThrift.js b/plugins/google/vpcnetwork/openCassandraThrift.js new file mode 100644 index 000000000..7235a16bc --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraThrift.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Cassandra Thrift', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 9160 for Cassandra Thrift is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Cassandra Thrift should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 9160 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [9160] + }; + + let service = 'Cassandra Thrift'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/google/vpcnetwork/openCassandraThrift.spec.js b/plugins/google/vpcnetwork/openCassandraThrift.spec.js new file mode 100644 index 000000000..29a22c966 --- /dev/null +++ b/plugins/google/vpcnetwork/openCassandraThrift.spec.js @@ -0,0 +1,117 @@ +var expect = require('chai').expect; +const openCassandraThrift = require('./openCassandraThrift'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "9160" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openCassandraThrift', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 9160 for Cassandra Thrift open to public', function (done) { + const cache = createCache([firewalls[0]]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openCassandraThrift.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); + diff --git a/plugins/google/vpcnetwork/openCustomPorts.js b/plugins/google/vpcnetwork/openCustomPorts.js new file mode 100644 index 000000000..0397fa6d2 --- /dev/null +++ b/plugins/google/vpcnetwork/openCustomPorts.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Custom Ports', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Ensure that defined custom ports are not open to public.', + more_info: 'To prevent attackers from identifying and exploiting the services running on your instances, make sure the VPC Network custom ports are not open to public.', + link: 'https://cloud.google.com/vpc/docs/firewalls', + recommended_action: 'Ensure that your VPC Network firewall rules do not allow inbound traffic for a range of ports.', + apis: ['firewalls:list'], + settings: { + restricted_open_ports: { + name: 'Restricted Open Ports', + description: 'Comma separated list of ports that should be restricted and not publicly open. Example: tcp:80,tcp:443', + regex: '[a-zA-Z0-9,:]', + default: 'tcp:80' + }, + }, + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var restricted_open_ports = settings.restricted_open_ports || this.settings.restricted_open_ports.default; + + if (!restricted_open_ports.length) return callback(null, results, source); + + restricted_open_ports = restricted_open_ports.split(','); + + var ports = {}; + restricted_open_ports.forEach(port => { + var [protocol, portNo] = port.split(':'); + if (ports[protocol]) { + ports[protocol].push(parseInt(portNo)); + } else { + ports[protocol] = [parseInt(portNo)]; + } + }); + + async.each(regions.firewalls, function(region, rcb) { + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + helpers.findOpenPorts(firewalls.data, ports, 'custom', region, results, cache, source); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openCustomPorts.spec.js b/plugins/google/vpcnetwork/openCustomPorts.spec.js new file mode 100644 index 000000000..21daff4a5 --- /dev/null +++ b/plugins/google/vpcnetwork/openCustomPorts.spec.js @@ -0,0 +1,126 @@ +var expect = require('chai').expect; +var plugin = require('./openCustomPorts'); + + +const firewalls = [ + { + "id": "1111111", + "creationTimestamp": "2021-05-07T12:10:19.939-07:00", + "name": "default-allow-ssh", + "description": "Allow SSH from anywhere", + "network": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc", + "priority": 65534, + "sourceRanges": [ + "0.0.0.0/0" + ], + "allowed": [ + { + "IPProtocol": "tcp", + "ports": [ + "90" + ] + } + ], + "direction": "INGRESS", + "logConfig": { + "enable": false + }, + "disabled": false, + "kind": "compute#firewall" + }, + { + "id": "111111", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/test-vpc-1", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "kind": "compute#firewall" + } +]; + +const createCache = (firewalls, firewallsErr) => { + return { + firewalls: { + list: { + 'global': { + err: firewallsErr, + data: firewalls + } + } + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + } +}; + +describe('openCustomPorts', function () { + describe('run', function () { + it('should give unknown if unable to describe firewall rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query firewall rules'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + null, + ['error'], + ); + plugin.run(cache, { restricted_open_ports: 'tcp:80' }, callback); + }); + + it('should give passing result if no firewall rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No firewall rules found'); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [], + null, + ); + plugin.run(cache, {restricted_open_ports: 'tcp:80'}, callback); + }); + + it('should give passing result if VPC Network has no open custom ports', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [firewalls[0]], + null + ); + plugin.run(cache, {restricted_open_ports: 'tcp:80'}, callback); + }); + + it('should give failing result if VPC Network has open custom ports', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done() + }; + const cache = createCache( + [firewalls[1]], + null + ); + plugin.run(cache, {restricted_open_ports: 'tcp:22'}, callback); + }); + }) +}); \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openDNS.js b/plugins/google/vpcnetwork/openDNS.js index 6702e17ee..161c066f2 100644 --- a/plugins/google/vpcnetwork/openDNS.js +++ b/plugins/google/vpcnetwork/openDNS.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open DNS', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP or UDP port 53 for DNS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as DNS should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP and UDP port 53 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -38,7 +41,7 @@ module.exports = { let service = 'DNS'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -46,4 +49,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openDNS.spec.js b/plugins/google/vpcnetwork/openDNS.spec.js new file mode 100644 index 000000000..cc1cb4e8c --- /dev/null +++ b/plugins/google/vpcnetwork/openDNS.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openDNS = require('./openDNS'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "53" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openDNS', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP or UDP port 53 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openDNS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openDocker.js b/plugins/google/vpcnetwork/openDocker.js index c71bc36cd..ed004d833 100644 --- a/plugins/google/vpcnetwork/openDocker.js +++ b/plugins/google/vpcnetwork/openDocker.js @@ -4,25 +4,28 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Docker', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if Docker port 2375 or 2376 is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Docker should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP ports 2375 and 2376 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], - run: function (cache, settings, callback) { + run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); - async.each(regions.firewalls, function (region, rcb) { + async.each(regions.firewalls, function(region, rcb) { let firewalls = helpers.addSource( cache, source, ['firewalls', 'list', region]); if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,12 +40,12 @@ module.exports = { let service = 'Docker'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); - }, function () { + }, function() { // Global checking goes here callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openDocker.spec.js b/plugins/google/vpcnetwork/openDocker.spec.js new file mode 100644 index 000000000..66c15cd68 --- /dev/null +++ b/plugins/google/vpcnetwork/openDocker.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openDocker = require('./openDocker'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "2375" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openDocker', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has Docker port 2375 or 2376 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openDocker.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openElasticsearch.js b/plugins/google/vpcnetwork/openElasticsearch.js new file mode 100644 index 000000000..c7374941e --- /dev/null +++ b/plugins/google/vpcnetwork/openElasticsearch.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Elasticsearch', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP ports 9200, 9300 for Elasticsearch are open to the public', + more_info: 'Databases are the placeholders for most sensitive and confidential information in an organization. Allowing Inbound traffic from external IPv4 addresses to the database ports can lead to attacks like DoS, Brute Force, Smurf and reconnaissance. It is a best practice to block public access, and restrict the Inbound traffic from specific addresses and make the connection secure.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP ports 9200, 9300 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp' : [9200, 9300] + }; + + let service = 'Elasticsearch'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openElasticsearch.spec.js b/plugins/google/vpcnetwork/openElasticsearch.spec.js new file mode 100644 index 000000000..2d772b2b9 --- /dev/null +++ b/plugins/google/vpcnetwork/openElasticsearch.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openElasticsearch = require('./openElasticsearch'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "9200" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openElasticsearch', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP ports 9200, 9300 open to public', function (done) { + const cache = createCache([firewalls[0]]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openElasticsearch.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openFTP.js b/plugins/google/vpcnetwork/openFTP.js index d78b8fcfb..868fba9b6 100644 --- a/plugins/google/vpcnetwork/openFTP.js +++ b/plugins/google/vpcnetwork/openFTP.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open FTP', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 20 or 21 for FTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as FTP should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 20 or 21 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'FTP'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openFTP.spec.js b/plugins/google/vpcnetwork/openFTP.spec.js new file mode 100644 index 000000000..bb3fac2fc --- /dev/null +++ b/plugins/google/vpcnetwork/openFTP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openFTP = require('./openFTP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "20" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openFTP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 20 or 21 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openFTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openHTTP.js b/plugins/google/vpcnetwork/openHTTP.js new file mode 100644 index 000000000..6080a4e02 --- /dev/null +++ b/plugins/google/vpcnetwork/openHTTP.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open HTTP', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 80 for HTTP is open to the public', + more_info: 'While some ports are required to be open to the public to function properly, more sensitive services such as HTTP should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 80 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [80] + }; + + let service = 'HTTP'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openHTTP.spec.js b/plugins/google/vpcnetwork/openHTTP.spec.js new file mode 100644 index 000000000..4fe39182f --- /dev/null +++ b/plugins/google/vpcnetwork/openHTTP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openHTTP = require('./openHTTP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "80" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openHTTP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 80 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openHTTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openHadoopNameNode.js b/plugins/google/vpcnetwork/openHadoopNameNode.js index be216da3f..7f3d4a989 100644 --- a/plugins/google/vpcnetwork/openHadoopNameNode.js +++ b/plugins/google/vpcnetwork/openHadoopNameNode.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Hadoop HDFS NameNode Metadata Service', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 8020 for HDFS NameNode metadata service is open to the public.', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 8020 to known IP addresses for Hadoop/HDFS.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'NameNodeWebUI'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openHadoopNameNode.spec.js b/plugins/google/vpcnetwork/openHadoopNameNode.spec.js new file mode 100644 index 000000000..532e5a66b --- /dev/null +++ b/plugins/google/vpcnetwork/openHadoopNameNode.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openHadoopNameNode = require('./openHadoopNameNode'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "8020" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openHadoopNameNode', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 8020 for HDFS NameNode metadata service is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openHadoopNameNode.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.js b/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.js index 578730644..625a18baf 100644 --- a/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.js +++ b/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Hadoop HDFS NameNode WebUI', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Hadoop/HDFS should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 50070 and 50470 to known IP addresses for Hadoop/HDFS', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'NameNodeWebUI'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.spec.js b/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.spec.js new file mode 100644 index 000000000..7a8cae86c --- /dev/null +++ b/plugins/google/vpcnetwork/openHadoopNameNodeWebUI.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openHadoopNameNodeWebUI = require('./openHadoopNameNodeWebUI'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "50070" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openHadoopNameNodeWebUI', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 50070 and 50470 for Hadoop/HDFS NameNode WebUI service is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openHadoopNameNodeWebUI.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openInternalWeb.js b/plugins/google/vpcnetwork/openInternalWeb.js new file mode 100644 index 000000000..17e404eb7 --- /dev/null +++ b/plugins/google/vpcnetwork/openInternalWeb.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Internal web', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if internal web port 8080 is open to the public', + more_info: 'Internal web port 8080 is used for web applications and proxy services. Allowing Inbound traffic from any IP address to TCP port 8080 is vulnerable to exploits like backdoor trojan attacks. It is a best practice to block port 8080 from the public internet.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 8080 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [8080] + }; + + let service = 'Internal Web'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openInternalWeb.spec.js b/plugins/google/vpcnetwork/openInternalWeb.spec.js new file mode 100644 index 000000000..2ae4f61e4 --- /dev/null +++ b/plugins/google/vpcnetwork/openInternalWeb.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openInternalWeb = require('./openInternalWeb'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "8080" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openInternalWeb', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has internal web port 8080 open to public', function (done) { + const cache = createCache([firewalls[0]]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openInternalWeb.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openKibana.js b/plugins/google/vpcnetwork/openKibana.js index 4e6dae22c..73966a303 100644 --- a/plugins/google/vpcnetwork/openKibana.js +++ b/plugins/google/vpcnetwork/openKibana.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Kibana', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 5601 for Kibana is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Kibana should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 5601 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'Kibana'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openKibana.spec.js b/plugins/google/vpcnetwork/openKibana.spec.js new file mode 100644 index 000000000..fe12fb9cc --- /dev/null +++ b/plugins/google/vpcnetwork/openKibana.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openKibana = require('./openKibana'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "5601" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openKibana', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 5601 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openKibana.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openLDAP.js b/plugins/google/vpcnetwork/openLDAP.js new file mode 100644 index 000000000..0d9d2cf6d --- /dev/null +++ b/plugins/google/vpcnetwork/openLDAP.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open LDAP', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP or UDP port 389 for LDAP is open to the public', + more_info: 'Allowing Inbound traffic from external IPv4 addresses to LDAP ports can lead to attacks like DoS, Brute Force, Smurf, and reconnaissance. It is a best practice to restrict the Inbound traffic from specific addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP and UDP port 389 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'udp' : [389], + 'tcp' : [389] + }; + + let service = 'LDAP'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openLDAP.spec.js b/plugins/google/vpcnetwork/openLDAP.spec.js new file mode 100644 index 000000000..88c26f683 --- /dev/null +++ b/plugins/google/vpcnetwork/openLDAP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openLDAP = require('./openLDAP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "389" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openLDAP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP or UDP port 389 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openLDAP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openLDAPS.js b/plugins/google/vpcnetwork/openLDAPS.js new file mode 100644 index 000000000..b5b084302 --- /dev/null +++ b/plugins/google/vpcnetwork/openLDAPS.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open LDAPS', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 636 for LDAP SSL is open to the public', + more_info: 'LDAP SSL port 636 is used for Secure LDAP authentication. Allowing Inbound traffic from any IP address to TCP port 636 is vulnerable to DoS attacks. It is a best practice to block port 636 from the public internet.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 636 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [636] + }; + + let service = 'LDAPS'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openLDAPS.spec.js b/plugins/google/vpcnetwork/openLDAPS.spec.js new file mode 100644 index 000000000..196ca5b9a --- /dev/null +++ b/plugins/google/vpcnetwork/openLDAPS.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openLDAPS = require('./openLDAPS'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "636" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openLDAPS', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 636 for LDAP is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openLDAPS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openMemcached.js b/plugins/google/vpcnetwork/openMemcached.js new file mode 100644 index 000000000..0619dff27 --- /dev/null +++ b/plugins/google/vpcnetwork/openMemcached.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Memcached', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP or UDP port 11211 for Memcached is open to the public', + more_info: 'Memcached port 11211 is used for caching system and to reduce response times and the load on components. Allowing inbound traffic from any external IP address on memcached port is vulnerable to DoS attacks. It is a best practice to restrict access from specific IP addresses to port 11211.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP and UDP port 11211 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'udp' : [11211], + 'tcp' : [11211] + }; + + let service = 'Memcached'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openMemcached.spec.js b/plugins/google/vpcnetwork/openMemcached.spec.js new file mode 100644 index 000000000..24587c5b2 --- /dev/null +++ b/plugins/google/vpcnetwork/openMemcached.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openMemcached = require('./openMemcached'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "11211" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openMemcached', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP or UDP port 11211 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openMemcached.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openMongo.js b/plugins/google/vpcnetwork/openMongo.js new file mode 100644 index 000000000..6272b69af --- /dev/null +++ b/plugins/google/vpcnetwork/openMongo.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open MongoDB', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 27017, 27018 or 27019 for MongoDB is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Mongo should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP ports 27017, 27018 and 27019 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [27017,27018,27019] + }; + + let service = 'Mongo'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openMongo.spec.js b/plugins/google/vpcnetwork/openMongo.spec.js new file mode 100644 index 000000000..ac4bfed0c --- /dev/null +++ b/plugins/google/vpcnetwork/openMongo.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openMongo = require('./openMongo'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "27017" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openMongo', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openMongo.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 27017 for Mongo is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openMongo.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openMongo.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openMongo.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openMongo.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openMsSQL.js b/plugins/google/vpcnetwork/openMsSQL.js new file mode 100644 index 000000000..9942cb198 --- /dev/null +++ b/plugins/google/vpcnetwork/openMsSQL.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open MSSQL', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 1433 for MSSQL is open to the public.', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MSSQL should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 1433 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [1433] + }; + + let service = 'MSSQL'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openMsSQL.spec.js b/plugins/google/vpcnetwork/openMsSQL.spec.js new file mode 100644 index 000000000..515c9bd65 --- /dev/null +++ b/plugins/google/vpcnetwork/openMsSQL.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openMsSQL = require('./openMsSQL'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "1433" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openMsSQL', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openMsSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 1433 for MSSQL is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openMsSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openMsSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openMsSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openMsSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openMySQL.js b/plugins/google/vpcnetwork/openMySQL.js index 2100e5998..5271ddcfc 100644 --- a/plugins/google/vpcnetwork/openMySQL.js +++ b/plugins/google/vpcnetwork/openMySQL.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open MySQL', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 4333 or 3306 for MySQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as MySQL should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP ports 4333 and 3306 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'MySQL'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openMySQL.spec.js b/plugins/google/vpcnetwork/openMySQL.spec.js new file mode 100644 index 000000000..d6babef66 --- /dev/null +++ b/plugins/google/vpcnetwork/openMySQL.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openMySQL = require('./openMySQL'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "3306" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openMySQL', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 5601 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openMySQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openNetBIOS.js b/plugins/google/vpcnetwork/openNetBIOS.js index 2ecccaaae..9181e5524 100644 --- a/plugins/google/vpcnetwork/openNetBIOS.js +++ b/plugins/google/vpcnetwork/openNetBIOS.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open NetBIOS', category: 'VPC Network', - description: 'Determines if UDP port 137 or 138 for NetBIOS is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if UDP port 137, 138 or 139 for NetBIOS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as NetBIOS should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', - recommended_action: 'Restrict UDP ports 137 and 138 to known IP addresses.', + recommended_action: 'Restrict UDP ports 137, 138 and 139 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -32,12 +35,12 @@ module.exports = { } let ports = { - 'udp': [137,138] + 'udp': [137,138,139] }; let service = 'NetBIOS'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openNetBIOS.spec.js b/plugins/google/vpcnetwork/openNetBIOS.spec.js new file mode 100644 index 000000000..a231a9c55 --- /dev/null +++ b/plugins/google/vpcnetwork/openNetBIOS.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openNetBIOS = require('./openNetBIOS'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "137" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openNetBIOS', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has UDP port 137 or 138 for NetBIOS is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openNetBIOS.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openOracle.js b/plugins/google/vpcnetwork/openOracle.js index 8f7d6bd61..e112e9080 100644 --- a/plugins/google/vpcnetwork/openOracle.js +++ b/plugins/google/vpcnetwork/openOracle.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Oracle', category: 'VPC Network', - description: 'Determines if TCP port 1521 for Oracle is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 1521, 2483 for Oracle is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', - recommended_action: 'Restrict TCP ports 1521 to known IP addresses.', + recommended_action: 'Restrict TCP ports 1521, 2483 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -32,12 +35,12 @@ module.exports = { } let ports = { - 'tcp': [1521] + 'tcp': [1521, 2483] }; let service = 'Oracle'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; diff --git a/plugins/google/vpcnetwork/openOracle.spec.js b/plugins/google/vpcnetwork/openOracle.spec.js new file mode 100644 index 000000000..bfae65dbf --- /dev/null +++ b/plugins/google/vpcnetwork/openOracle.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openOracle = require('./openOracle'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "1521" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openOracle', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 1521 for Oracle is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openOracle.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.js b/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.js index 1bee1cb54..a9664ae9a 100644 --- a/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.js +++ b/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Oracle Auto Data Warehouse', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 1522 for Oracle Auto Data Warehouse is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Oracle should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP ports 1522 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'Oracle Auto Data Warehouse'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.spec.js b/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.spec.js new file mode 100644 index 000000000..edfb31959 --- /dev/null +++ b/plugins/google/vpcnetwork/openOracleAutoDataWarehouse.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openOracleAutoDataWarehouse = require('./openOracleAutoDataWarehouse'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "1522" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openOracleAutoDataWarehouse', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 1522 for Oracle Auto Data Warehouse is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openOracleAutoDataWarehouse.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openPostgreSQL.js b/plugins/google/vpcnetwork/openPostgreSQL.js index 07f04928f..d00527092 100644 --- a/plugins/google/vpcnetwork/openPostgreSQL.js +++ b/plugins/google/vpcnetwork/openPostgreSQL.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open PostgreSQL', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 5432 for PostgreSQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as PostgreSQL should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 5432 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'PostgreSQL'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openPostgreSQL.spec.js b/plugins/google/vpcnetwork/openPostgreSQL.spec.js new file mode 100644 index 000000000..18fd8b451 --- /dev/null +++ b/plugins/google/vpcnetwork/openPostgreSQL.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openPostgreSQL = require('./openPostgreSQL'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "5432" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openPostgreSQL', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 5432 for PostgreSQL is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openPostgreSQL.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openRDP.js b/plugins/google/vpcnetwork/openRDP.js index 90378da61..1069c2da3 100644 --- a/plugins/google/vpcnetwork/openRDP.js +++ b/plugins/google/vpcnetwork/openRDP.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open RDP', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 3389 for RDP is open to the public', - more_info: "While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RDP should be restricted to known IP addresses.", + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RDP should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', - recommended_action: 'Restrict TCP port 5432 to known IP addresses.', + recommended_action: 'Restrict TCP port 3389 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'RDP'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; diff --git a/plugins/google/vpcnetwork/openRDP.spec.js b/plugins/google/vpcnetwork/openRDP.spec.js new file mode 100644 index 000000000..c07e7a609 --- /dev/null +++ b/plugins/google/vpcnetwork/openRDP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openRDP = require('./openRDP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "3389" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openRDP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 3389 for RDP is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openRDP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openRPC.js b/plugins/google/vpcnetwork/openRPC.js index cd9d90710..fc17f6d64 100644 --- a/plugins/google/vpcnetwork/openRPC.js +++ b/plugins/google/vpcnetwork/openRPC.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open RPC', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 135 for RPC is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as RPC should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 135 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'RPC'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openRPC.spec.js b/plugins/google/vpcnetwork/openRPC.spec.js new file mode 100644 index 000000000..b0c34f7cc --- /dev/null +++ b/plugins/google/vpcnetwork/openRPC.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openRPC = require('./openRPC'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "135" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openRPC', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 135 for RPC is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openRPC.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openRedis.js b/plugins/google/vpcnetwork/openRedis.js new file mode 100644 index 000000000..a3119317d --- /dev/null +++ b/plugins/google/vpcnetwork/openRedis.js @@ -0,0 +1,50 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open Redis', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 6379 for Redis is open to the public', + more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Redis should be restricted to known IP addresses.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict TCP port 6379 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'tcp': [6379] + }; + + let service = 'Redis'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openRedis.spec.js b/plugins/google/vpcnetwork/openRedis.spec.js new file mode 100644 index 000000000..2061b5db1 --- /dev/null +++ b/plugins/google/vpcnetwork/openRedis.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openRedis = require('./openRedis'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "6379" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openRedis', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 6379 for Redis is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openRedis.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSMBoTCP.js b/plugins/google/vpcnetwork/openSMBoTCP.js index 180f203a2..4b560569e 100644 --- a/plugins/google/vpcnetwork/openSMBoTCP.js +++ b/plugins/google/vpcnetwork/openSMBoTCP.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open SMBoTCP', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 445 for Windows SMB over TCP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMB should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 445 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'SMBoTCP'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openSMBoTCP.spec.js b/plugins/google/vpcnetwork/openSMBoTCP.spec.js new file mode 100644 index 000000000..10a19f890 --- /dev/null +++ b/plugins/google/vpcnetwork/openSMBoTCP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSMBoTCP = require('./openSMBoTCP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "445" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSMBoTCP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 445 for Windows SMB over TCP is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSMBoTCP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSMTP.js b/plugins/google/vpcnetwork/openSMTP.js index b17b20911..2e2869c24 100644 --- a/plugins/google/vpcnetwork/openSMTP.js +++ b/plugins/google/vpcnetwork/openSMTP.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open SMTP', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 25 for SMTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SMTP should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 25 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'SMTP'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openSMTP.spec.js b/plugins/google/vpcnetwork/openSMTP.spec.js new file mode 100644 index 000000000..637139463 --- /dev/null +++ b/plugins/google/vpcnetwork/openSMTP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSMTP = require('./openSMTP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "25" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSMTP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 25 for SMTP is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSMTP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSNMP.js b/plugins/google/vpcnetwork/openSNMP.js new file mode 100644 index 000000000..97b4b65b4 --- /dev/null +++ b/plugins/google/vpcnetwork/openSNMP.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Open SNMP', + category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if UDP port 161 for SNMP is open to the public', + more_info: 'SNMP UDP 161 used by various devices and applications for logging events, monitoring and management. Allowing Inbound traffic from any external IP address on port 161 is vulnerable to DoS attack. It is a best practice to block port 161 completely unless explicitly required.', + link: 'https://cloud.google.com/vpc/docs/using-firewalls', + recommended_action: 'Restrict UDP port 161 to known IP addresses.', + apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.firewalls, function(region, rcb){ + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', region]); + + if (!firewalls) return rcb(); + + if (firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); + return rcb(); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', region); + return rcb(); + } + + let ports = { + 'udp': [161] + }; + + let service = 'SNMP'; + + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openSNMP.spec.js b/plugins/google/vpcnetwork/openSNMP.spec.js new file mode 100644 index 000000000..8a089d17a --- /dev/null +++ b/plugins/google/vpcnetwork/openSNMP.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSNMP = require('./openSNMP'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "udp", "ports": [ "161" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSNMP', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has UDP port 161 for SNMP is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSNMP.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSQLServer.js b/plugins/google/vpcnetwork/openSQLServer.js index 5fb0e4a84..a4f42f239 100644 --- a/plugins/google/vpcnetwork/openSQLServer.js +++ b/plugins/google/vpcnetwork/openSQLServer.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open SQLServer', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 1433 or UDP port 1434 for SQL Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SQL server should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 1433 and UDP port 1434 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -38,7 +41,7 @@ module.exports = { let service = 'SQL Server'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -46,4 +49,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openSQLServer.spec.js b/plugins/google/vpcnetwork/openSQLServer.spec.js new file mode 100644 index 000000000..41dab0130 --- /dev/null +++ b/plugins/google/vpcnetwork/openSQLServer.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSQLServer = require('./openSQLServer'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "1433" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSQLServer', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 1433 or UDP port 1434 for SQL Server is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSQLServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSSH.js b/plugins/google/vpcnetwork/openSSH.js index 8471f953a..a32ddf241 100644 --- a/plugins/google/vpcnetwork/openSSH.js +++ b/plugins/google/vpcnetwork/openSSH.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open SSH', category: 'VPC Network', - description: 'Determines if TCP port 22 for FTP is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 22 for SSH is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as SSH should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 22 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'SSH'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; diff --git a/plugins/google/vpcnetwork/openSSH.spec.js b/plugins/google/vpcnetwork/openSSH.spec.js new file mode 100644 index 000000000..d2b5e593f --- /dev/null +++ b/plugins/google/vpcnetwork/openSSH.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSSH = require('./openSSH'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSSH', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 22 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSSH.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openSalt.js b/plugins/google/vpcnetwork/openSalt.js index bda2cca35..9f63b1a70 100644 --- a/plugins/google/vpcnetwork/openSalt.js +++ b/plugins/google/vpcnetwork/openSalt.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Salt', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP ports 4505 or 4506 for the Salt master are open to the public', more_info: 'Active Salt vulnerabilities, CVE-2020-11651 and CVE-2020-11652 are exploiting Salt instances exposed to the internet. These ports should be closed immediately.', link: 'https://help.saltstack.com/hc/en-us/articles/360043056331-New-SaltStack-Release-Critical-Vulnerability', recommended_action: 'Restrict TCP ports 4505 and 4506 to known IP addresses', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'Salt'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openSalt.spec.js b/plugins/google/vpcnetwork/openSalt.spec.js new file mode 100644 index 000000000..f3b21bea6 --- /dev/null +++ b/plugins/google/vpcnetwork/openSalt.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openSalt = require('./openSalt'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "4505" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openSalt', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP ports 4505 or 4506 for the Salt master is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openSalt.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openTelnet.js b/plugins/google/vpcnetwork/openTelnet.js index a0d7836b0..d424ca2c5 100644 --- a/plugins/google/vpcnetwork/openTelnet.js +++ b/plugins/google/vpcnetwork/openTelnet.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open Telnet', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 23 for Telnet is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as Telnet should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 23 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'Telnet'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openTelnet.spec.js b/plugins/google/vpcnetwork/openTelnet.spec.js new file mode 100644 index 000000000..a194384dd --- /dev/null +++ b/plugins/google/vpcnetwork/openTelnet.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openTelnet = require('./openTelnet'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "23" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openTelnet', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 23 is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openTelnet.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openVNCClient.js b/plugins/google/vpcnetwork/openVNCClient.js index b8e685da0..3dfe153b9 100644 --- a/plugins/google/vpcnetwork/openVNCClient.js +++ b/plugins/google/vpcnetwork/openVNCClient.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open VNC Client', category: 'VPC Network', - description: 'Determines if TCP port 5500 for VNC Client is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determines if TCP port 5500 for VNC Client is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Client should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 5500 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'VNC Client'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openVNCClient.spec.js b/plugins/google/vpcnetwork/openVNCClient.spec.js new file mode 100644 index 000000000..7f094e155 --- /dev/null +++ b/plugins/google/vpcnetwork/openVNCClient.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openVNCClient = require('./openVNCClient'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "5500" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openVNCClient', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 5500 for VNC Client is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openVNCClient.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/openVNCServer.js b/plugins/google/vpcnetwork/openVNCServer.js index db6078208..90ac3358a 100644 --- a/plugins/google/vpcnetwork/openVNCServer.js +++ b/plugins/google/vpcnetwork/openVNCServer.js @@ -4,11 +4,14 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Open VNC Server', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'High', description: 'Determines if TCP port 5900 for VNC Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public to function properly, more sensitive services such as VNC Server should be restricted to known IP addresses.', link: 'https://cloud.google.com/vpc/docs/using-firewalls', recommended_action: 'Restrict TCP port 5900 to known IP addresses.', apis: ['firewalls:list'], + realtime_triggers: ['compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], run: function(cache, settings, callback) { var results = []; @@ -22,7 +25,7 @@ module.exports = { if (!firewalls) return rcb(); if (firewalls.err || !firewalls.data) { - helpers.addResult(results, 3, 'Unable to query firewall rules: ' + helpers.addError(firewalls), region); + helpers.addResult(results, 3, 'Unable to query firewall rules', region, null, null, firewalls.err); return rcb(); } @@ -37,7 +40,7 @@ module.exports = { let service = 'VNC Server'; - helpers.findOpenPorts(firewalls.data, ports, service, region, results); + helpers.findOpenPorts(firewalls.data, ports, service, region, results, cache, source); rcb(); }, function(){ @@ -45,4 +48,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/openVNCServer.spec.js b/plugins/google/vpcnetwork/openVNCServer.spec.js new file mode 100644 index 000000000..7b4bc4663 --- /dev/null +++ b/plugins/google/vpcnetwork/openVNCServer.spec.js @@ -0,0 +1,116 @@ +var expect = require('chai').expect; +const openVNCServer = require('./openVNCServer'); + +const firewalls = [ + { + "id": "4111718641158512144", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "openall", + "description": "Open All Ports", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "0.0.0.0/0" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "5900" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/openall", + "kind": "compute#firewall" + }, + { + "id": "3482752052453535354", + "creationTimestamp": "2021-02-25T00:34:07.519-08:00", + "name": "opensome", + "description": "", + "network": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/networks/app-vpc", + "priority": 1000, + "sourceRanges": [ "192.168.0.0/16" ], + "allowed": [{ "IPProtocol": "tcp", "ports": [ "22" ]}], + "direction": "INGRESS", + "disabled": false, + "selfLink": "https://www.googleapis.com/compute/v1/projects/khulnasoft-dev-akhtar/global/firewalls/opensome", + "kind": "compute#firewall" + } +]; + +const createCache = (groups, err) => { + return { + firewalls:{ + list: { + 'global': { + data: groups, + err: err + }, + }, + }, + projects: { + get: { + 'global': { + data: 'testProj' + } + } + } + }; +}; + +const createNullCache = () => { + return { + firewalls:{ + list: { + 'global': null, + }, + }, + }; +}; + +describe('openVNCServer', function () { + describe('run', function () { + it('should PASS if no open ports found', function (done) { + const cache = createCache([firewalls[1]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should FAIL if firewall rule has TCP port 5900 for VNC Server is open to public', function (done) { + const cache = createCache([firewalls[0]]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should PASS if no firewall rules found', function (done) { + const cache = createCache([]); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should UNKNWON if unable to describe firewall rules', function (done) { + const cache = createCache([], { message: 'Unable to query firewall rules'}); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not return anything if describe firewall rules response not found', function (done) { + const cache = createNullCache(); + openVNCServer.run(cache, {}, (err, results) => { + expect(results.length).to.equal(0); + done(); + }); + }); + + }); +}); diff --git a/plugins/google/vpcnetwork/privateAccessEnabled.js b/plugins/google/vpcnetwork/privateAccessEnabled.js index dea7a2d61..fdc963a61 100644 --- a/plugins/google/vpcnetwork/privateAccessEnabled.js +++ b/plugins/google/vpcnetwork/privateAccessEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/google'); module.exports = { title: 'Private Access Enabled', category: 'VPC Network', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures Private Google Access is enabled for all Subnets', more_info: 'Private Google Access allows VM instances on a subnet to reach Google APIs and services without an IP address. This creates a more secure network for the internal communication.', link: 'https://cloud.google.com/vpc/docs/configure-private-google-access', @@ -14,12 +16,24 @@ module.exports = { 'any required service. This includes using secured technologies ' + 'such as Private Google Access.' }, + realtime_triggers: ['compute.subnetworks.insert','compute.subnetworks.delete', 'compute.subnetworks.setPrivateIpGoogleAccess'], run: function(cache, settings, callback) { var results = []; var source = {}; var regions = helpers.regions(); + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + async.each(regions.subnetworks, function(region, rcb){ let subnetworks = helpers.addSource( cache, source, ['subnetworks', 'list', region]); @@ -27,33 +41,31 @@ module.exports = { if (!subnetworks) return rcb(); if (subnetworks.err || !subnetworks.data) { - helpers.addResult(results, 3, 'Unable to query subnetworks: ' + helpers.addError(subnetworks), region); + helpers.addResult(results, 3, 'Unable to query subnetworks: ' + helpers.addError(subnetworks), region, null, null, subnetworks.err); return rcb(); - }; + } if (!subnetworks.data.length) { helpers.addResult(results, 0, 'No subnetworks present', region); return rcb(); - }; + } - var badSubnets = []; - var regionSubnets = false; + let found = false; subnetworks.data.forEach(subnet => { + let resource = helpers.createResourceName('subnetworks', subnet.name, project, 'region', region); + if (subnet.creationTimestamp && !subnet.privateIpGoogleAccess) { - badSubnets.push(subnet.id); + found = true; + helpers.addResult(results, 2, + 'Subnet does not have Private Google Access Enabled', region, resource); } else if (subnet.creationTimestamp) { - regionSubnets = true + found = true; + helpers.addResult(results, 0, 'Subnet has Private Google Access Enabled', region, resource); } }); - if (badSubnets.length) { - var badSubnetStr = badSubnets.join(', '); - helpers.addResult(results, 2, - `The following Subnets do not have Private Google Access Enabled: ${badSubnetStr}`, region); - } else if (regionSubnets){ - helpers.addResult(results, 0, 'All Subnets in the Region have Private Google Access Enabled', region); - } else { + if (!found) { helpers.addResult(results, 0, 'No subnetworks present', region); } @@ -63,4 +75,4 @@ module.exports = { callback(null, results, source); }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/plugins/google/vpcnetwork/privateAccessEnabled.spec.js b/plugins/google/vpcnetwork/privateAccessEnabled.spec.js index 94f51302a..3a68c6a41 100644 --- a/plugins/google/vpcnetwork/privateAccessEnabled.spec.js +++ b/plugins/google/vpcnetwork/privateAccessEnabled.spec.js @@ -11,6 +11,13 @@ const createCache = (err, data) => { data: data } } + }, + projects: { + get: { + 'global': { + data: 'testproj' + } + } } } }; @@ -53,7 +60,7 @@ describe('privateAccessEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('All Subnets in the Region have Private Google Access Enabled') + expect(results[0].message).to.include('Subnet has Private Google Access Enabled') expect(results[0].region).to.equal('us-east1') done() }; @@ -91,7 +98,7 @@ describe('privateAccessEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(0) expect(results[0].status).to.equal(2) - expect(results[0].message).to.include('The following Subnets do not have Private Google Access Enabled') + expect(results[0].message).to.include('Subnet does not have Private Google Access Enabled') expect(results[0].region).to.equal('us-east1') done() }; diff --git a/plugins/oracle/audit/logRetentionPeriod.js b/plugins/oracle/audit/logRetentionPeriod.js index 77c6f5894..66b1cfa1d 100644 --- a/plugins/oracle/audit/logRetentionPeriod.js +++ b/plugins/oracle/audit/logRetentionPeriod.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Log Retention Period', category: 'Audit', + domain: 'Management and Governance', + severity: 'Medium', description: 'Ensures that the audit log retention period is configured correctly.', more_info: 'Audit logs should be kept for as long as internal compliance requires. If no requirements exist, best practices suggest a minimum of 365 days.', recommended_action: 'Ensure that the audit log retention period is configured correctly.', @@ -30,43 +31,39 @@ module.exports = { }; var results = []; - var source = {}; - var regions = helpers.regions(settings.govcloud); + var source = {} + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); - async.each(regions.configuration, function(region, rcb){ - if (helpers.checkRegionSubscription(cache, source, results, region)) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { - var configurations = helpers.addSource(cache, source, - ['configuration', 'get', region]); + var configurations = helpers.addSource(cache, source, + ['configuration', 'get', region]); - if (!configurations) return rcb(); + if (!configurations) return callback(null, results, source); - if (configurations.err || !configurations.data) { - helpers.addResult(results, 3, - 'Unable to query for audit configurations: ' + helpers.addError(configurations), region); - return rcb(); - } + if (configurations.err || !configurations.data) { + helpers.addResult(results, 3, + 'Unable to query for audit configurations: ' + helpers.addError(configurations), region); + return callback(null, results, source); + } - if (!Object.keys(configurations.data).length) { - helpers.addResult(results, 0, 'No audit configurations found', region); - return rcb(); - } - var configuration = configurations.data; + if (!Object.keys(configurations.data).length) { + helpers.addResult(results, 0, 'No audit configurations found', region); + return callback(null, results, source); + } + var configuration = configurations.data; - if (configuration.retentionPeriodDays && - config.audit_log_retention_days && - configuration.retentionPeriodDays >= config.audit_log_retention_days) { - helpers.addResult(results, 0, - `Audit configuration period is ${configuration.retentionPeriodDays} days`, region); - } else { - helpers.addResult(results, 2, - `Audit configuration period is ${configuration.retentionPeriodDays} days`, region); - } + if (configuration.retentionPeriodDays && + config.audit_log_retention_days && + configuration.retentionPeriodDays >= config.audit_log_retention_days) { + helpers.addResult(results, 0, + `Audit configuration period is ${configuration.retentionPeriodDays} days`, region); + } else { + helpers.addResult(results, 2, + `Audit configuration period is ${configuration.retentionPeriodDays} days`, region); } - rcb(); - }, function(){ - // Global checking goes here - callback(null, results, source); - }); + } + + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/blockstorage/blockPolicyProtection.js b/plugins/oracle/blockstorage/blockPolicyProtection.js index 0049ca001..b579f50b7 100644 --- a/plugins/oracle/blockstorage/blockPolicyProtection.js +++ b/plugins/oracle/blockstorage/blockPolicyProtection.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Block Storage Policy Protection', category: 'Block Storage', + domain:'Storage', + severity: 'Medium', description: 'Ensure policy statements have deletion protection for block volumes unless it is an administrator group.', more_info: 'Adding deletion protection to Oracle block volume policies mitigates unintended deletion of block and boot volumes by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/iam_security.htm', @@ -12,8 +13,8 @@ module.exports = { settings: { policy_group_admins: { name: 'Admin groups with delete permissions.', - description: 'The admin groups allowed to delete resources.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + description: 'Comma separated list of the admin groups allowed to delete resources.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -21,96 +22,52 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default - }; - async.each(regions.default, function (region, rcb) { - - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); - - if (!policies) return rcb(); - - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) + + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); + + if (!policies) return callback(null, results, source); + + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } + + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; + var resourceTypes = ['volume', 'volume-backups', 'volume-attachments', 'volume-family']; + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins); + + if (!statementPasses) { + policyProtection = false; + + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to delete all block storage volumes in ${statementObj['location']}`, region, policy.id); + } + }); } + }); - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); - } - var policyProtection = true; - var entered = false; - - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; - policy.statements.forEach(statement => { - - const statementLower = statement.toLowerCase(); - - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - (statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1 && - statementLower.indexOf('_delete') === -1 && - (statementLower.indexOf('volume_') === -1 || - statementLower.indexOf('volume_backup_') === -1 || - statementLower.indexOf('volume_attachments_') === -1)) && - (statementLower.indexOf('volume') > -1 || - statementLower.indexOf('volumes-backups') > -1 || - statementLower.indexOf('volume-attachments') > -1 || - statementLower.indexOf('volume-family') > -1 || - statementLower.indexOf('all-resources') > -1)) { - - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - - var severity = 2; - - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = 'The ' + statementArr[1]; - } - - if (groupName === config.policy_group_admins.toLowerCase()) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'The user'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } - - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to delete all block storage volumes in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - - - } - }); + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies have block volume delete protection enabled', region); + } - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies have block volume delete protection enabled', region); - } - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); - }); + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/blockstorage/blockPolicyProtection.spec.js b/plugins/oracle/blockstorage/blockPolicyProtection.spec.js index 17320be16..3d26557a1 100644 --- a/plugins/oracle/blockstorage/blockPolicyProtection.spec.js +++ b/plugins/oracle/blockstorage/blockPolicyProtection.spec.js @@ -170,7 +170,7 @@ describe('blockPolicyProtection', function () { [ { "statements": [ - "Allow group Developers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'", + "Allow group Developers to read volumes in tenancy where request.permission != 'VOLUME_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/blockstorage/blockVolumeBackupEnabled.js b/plugins/oracle/blockstorage/blockVolumeBackupEnabled.js index 5447880b2..0f82b1342 100644 --- a/plugins/oracle/blockstorage/blockVolumeBackupEnabled.js +++ b/plugins/oracle/blockstorage/blockVolumeBackupEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Block Volume Backup Enabled', category: 'Block Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures block volumes have backups enabled.', more_info: 'Enabling block volume backup policies ensures that the block volume can be restored following in the event of data loss.', recommended_action: 'Enable backups on each block volume.', diff --git a/plugins/oracle/blockstorage/blockVolumeCMKEncryption.js b/plugins/oracle/blockstorage/blockVolumeCMKEncryption.js new file mode 100644 index 000000000..d4f5bdbf3 --- /dev/null +++ b/plugins/oracle/blockstorage/blockVolumeCMKEncryption.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle/'); + +module.exports = { + title: 'Block Volume CMK Encryption', + category: 'Block Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that block volumes have encryption enabled using desired protection level.', + more_info: 'By default, block volumes are encrypted using an Oracle-managed master encryption key. To have better control over the encryption process, you can use Customer-Managed Keys (CMKs).', + recommended_action: 'Ensure all block volumes have desired encryption level.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Security/Reference/blockstorage_security.htm#data-encryption', + apis: ['vault:list', 'keys:list', 'volume:list'], + settings: { + volume_encryption_level: { + name: 'Block Volume Encryption Level', + description: 'Desired protection level for block volumes. default: oracle-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key', + regex: '^(default|cloudcmek|cloudhsm)$', + default: 'cloudcmek' + } + }, + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var keysObj = {}; + + let desiredEncryptionLevelStr = settings.volume_encryption_level || this.settings.volume_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + async.series([ + function (cb) { + async.each(regions.keys, function (region, rcb) { + let keys = helpers.addSource( + cache, source, ['keys', 'list', region]); + if (keys && keys.data && keys.data.length) helpers.listToObj(keysObj, keys.data, 'id'); + rcb(); + }, function () { + cb(); + }); + }, + function (cb) { + async.each(regions.volume, function (region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var blockVolumes = helpers.addSource(cache, source, + ['volume', 'list', region]); + + if (!blockVolumes) return rcb(); + + if (blockVolumes.err || !blockVolumes.data) { + helpers.addResult(results, 3, + 'Unable to query for block volumes: ' + helpers.addError(blockVolumes), region); + return rcb(); + } + + if (!blockVolumes.data.length) { + helpers.addResult(results, 0, 'No block volumes found', region); + return rcb(); + } + + blockVolumes.data.forEach(blockVolume => { + if (blockVolume.lifecycleState && blockVolume.lifecycleState === 'TERMINATED') return; + + let currentEncryptionLevel = 1; //default + + if (blockVolume.kmsKeyId) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[blockVolume.kmsKeyId], helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Block volume (${blockVolume.displayName}) has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, region, blockVolume.id); + } else { + helpers.addResult(results, 2, + `Block volume (${blockVolume.displayName}) has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, region, blockVolume.id); + } + }); + } + + rcb(); + }, function () { + cb(); + }); + } + ], function () { + // Global checking goes here + callback(null, results, source); + }); + } +} \ No newline at end of file diff --git a/plugins/oracle/blockstorage/blockVolumeCMKEncryption.spec.js b/plugins/oracle/blockstorage/blockVolumeCMKEncryption.spec.js new file mode 100644 index 000000000..8e72ab95a --- /dev/null +++ b/plugins/oracle/blockstorage/blockVolumeCMKEncryption.spec.js @@ -0,0 +1,193 @@ +var expect = require('chai').expect; +var plugin = require('./blockVolumeCMKEncryption'); + +const blockVolumes = [ + { + "availabilityDomain": "fMgC:US-ASHBURN-AD-1", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaa111111", + "definedTags": {}, + "displayName": "vol-1", + "freeformTags": {}, + "systemTags": {}, + "id": "ocid1.volume.oc1.aaa.1111111", + "isHydrated": true, + "lifecycleState": "AVAILABLE", + "performanceTier": null, + "vpusPerGB": null, + "sizeInGBs": 1024, + "sizeInMBs": 1048576, + "sourceDetails": null, + "timeCreated": "2019-08-29T21:46:01.836Z", + "volumeGroupId": null, + + }, + { + "availabilityDomain": "fMgC:US-ASHBURN-AD-1", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaa111111", + "definedTags": {}, + "displayName": "vol-1", + "freeformTags": {}, + "systemTags": {}, + "id": "ocid1.volume.oc1.aaa.1111111", + "isHydrated": true, + "lifecycleState": "AVAILABLE", + "performanceTier": null, + "vpusPerGB": null, + "sizeInGBs": 1024, + "sizeInMBs": 1048576, + "sourceDetails": null, + "timeCreated": "2019-08-29T21:46:01.836Z", + "volumeGroupId": null, + "kmsKeyId": 'key-1' + + } + +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + volume: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + }, + keys: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "key-1", + "lifecycleState": "ENABLED", + "timeCreated": "2022-04-30T19:49:12.841Z", + "vaultId": "vault-1", + "protectionMode": "SOFTWARE", + "algorithm": "AES" + } + ], + + } + } + } + } +}; + +describe('blockVolumeCMKEncryption', function () { + describe('run', function () { + it('should give unknown result if a block volume error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for block volumes') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no block volumes are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No block volumes found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give failing result if block volume does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is less') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [blockVolumes[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if block volume has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is greater than or equal to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [blockVolumes[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/blockstorage/blockVolumeRestorable.js b/plugins/oracle/blockstorage/blockVolumeRestorable.js index 00c9c2983..afb998957 100644 --- a/plugins/oracle/blockstorage/blockVolumeRestorable.js +++ b/plugins/oracle/blockstorage/blockVolumeRestorable.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Block Volume Restorable', category: 'Block Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures block volumes can be restored to a recent point.', more_info: 'Having recent backups on block volumes prevents data loss in the case of a catastrophe.', recommended_action: 'Ensure block volumes have recent backups to prevent data loss.', diff --git a/plugins/oracle/blockstorage/volumeGroupsRestorable.js b/plugins/oracle/blockstorage/volumeGroupsRestorable.js index 1c4642239..39fc1c1ae 100644 --- a/plugins/oracle/blockstorage/volumeGroupsRestorable.js +++ b/plugins/oracle/blockstorage/volumeGroupsRestorable.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Volume Groups Restorable', category: 'Block Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures volume groups can be restored to a recent point.', more_info: 'Enabling volume groups backups ensures that the volume group can be restored following in the event of data loss.', recommended_action: 'Ensure volume groups can be restored to a recent point.', diff --git a/plugins/oracle/cloudguard/cloudguardEnabled.js b/plugins/oracle/cloudguard/cloudguardEnabled.js new file mode 100644 index 000000000..d66473052 --- /dev/null +++ b/plugins/oracle/cloudguard/cloudguardEnabled.js @@ -0,0 +1,41 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Cloud Guard Enabled', + category: 'Cloud Guard', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensure Cloud Guard is enabled in the root compartment of the tenancy.', + more_info: 'Cloud Guard detects misconfigured resources and insecure activity within a tenancy and provides security administrators with the visibility to resolve these issues. Upon detection, Cloud Guard can suggest, assist, or take corrective actions to mitigate these issues.', + recommended_action: 'Cloud Guard should be enabled in the root compartment of your tenancy.', + link: 'https://docs.oracle.com/en-us/iaas/cloud-guard/using/index.htm', + apis: ['cloudguardConfiguration:get'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var config = helpers.addSource(cache, source, + ['cloudguardConfiguration', 'get', region]); + + if (!config) return callback(null, results, source); + + if (config.err) { + helpers.addResult(results, 3, + 'Unable to query for cloud guard configuration: ' + helpers.addError(config), region); + return callback(null, results, source); + } + if (config.data && Object.keys(config.data).length && config.data.status && config.data.status === 'ENABLED') { + helpers.addResult(results, 0, + 'Cloud Guard is enabled in the root compartment of the tenancy.', region); + } else { + helpers.addResult(results, 2, + 'Cloud Guard is not enabled in the root compartment of the tenancy.', region); + } + } + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/oracle/cloudguard/cloudguardEnabled.spec.js b/plugins/oracle/cloudguard/cloudguardEnabled.spec.js new file mode 100644 index 000000000..76d12bd2c --- /dev/null +++ b/plugins/oracle/cloudguard/cloudguardEnabled.spec.js @@ -0,0 +1,104 @@ +var expect = require('chai').expect; +var plugin = require('./cloudguardEnabled'); + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + cloudguardConfiguration: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('cloudguardEnabled', function () { + describe('run', function () { + it('should give unknown result if a configuration error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for cloud guard configuration') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result cloud guard is enabled in the root compartment of the tenancy', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('is enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + { + reportingRegion: 'us-ashburn-1', + status: 'ENABLED', + selfManageResources: false + } + ); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if cloud guard is not enabled in the root compartment of the tenancy', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('is not enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + { + reportingRegion: 'us-ashburn-1', + status: 'DISABLED', + selfManageResources: false + } + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/oracle/compute/autoscaleEnabled.js b/plugins/oracle/compute/autoscaleEnabled.js index 143dde1c2..60ee0e736 100644 --- a/plugins/oracle/compute/autoscaleEnabled.js +++ b/plugins/oracle/compute/autoscaleEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Autoscale Enabled', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures autoscaling is enabled on instance pools.', more_info: 'Enabling autoscaling increases efficiency and improves cost management for resources.', link: 'https://docs.cloud.oracle.com/iaas/Content/Compute/Tasks/autoscalinginstancepools.htm', diff --git a/plugins/oracle/compute/bootVolumeBackupEnabled.js b/plugins/oracle/compute/bootVolumeBackupEnabled.js index c4e5eea29..15c54d3ee 100644 --- a/plugins/oracle/compute/bootVolumeBackupEnabled.js +++ b/plugins/oracle/compute/bootVolumeBackupEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Boot Volume Backup Enabled', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures boot volumes have a backup policy.', more_info: 'Enabling a boot volume backup policy ensures that the boot volumes can be restored in the event of a compromised system or hardware failure.', recommended_action: 'Ensure all boot volumes have a backup policy.', @@ -52,7 +54,7 @@ module.exports = { var enabledBootVolumes = []; bootVolumeBackupPolicies.data.forEach(bootVolumeBackupPolicy => { - enabledBootVolumes.push(bootVolumeBackupPolicy.bootVolumeId) + enabledBootVolumes.push(bootVolumeBackupPolicy.bootVolumeId || bootVolumeBackupPolicy.assetId) }); bootVolumes.data.forEach(bootVolume => { diff --git a/plugins/oracle/compute/bootVolumeBackupEnabled.spec.js b/plugins/oracle/compute/bootVolumeBackupEnabled.spec.js index ace3b1cb4..456795d7e 100644 --- a/plugins/oracle/compute/bootVolumeBackupEnabled.spec.js +++ b/plugins/oracle/compute/bootVolumeBackupEnabled.spec.js @@ -169,7 +169,9 @@ describe('bootVolumeBackupEnabled', function () { const callback = (err, results) => { expect(results.length).to.be.above(1) expect(results[0].status).to.equal(0) + expect(results[1].status).to.equal(0) expect(results[0].message).to.include('The boot volume has backup policies enabled') + expect(results[1].message).to.include('The boot volume has backup policies enabled') expect(results[0].region).to.equal('us-ashburn-1') done() }; @@ -195,6 +197,9 @@ describe('bootVolumeBackupEnabled', function () { "sourceDetails": null, "timeCreated": "2019-08-29T21:46:01.836Z", "volumeGroupId": null + }, + { + "id": "ocid1.bootvolume.oc1.uk-london-1.abwgiljtpbow2jvc3dnuqwxg63ljrszfmmhkdffbm2lgihwxyiddzti2ja2q" } ], [ @@ -218,6 +223,12 @@ describe('bootVolumeBackupEnabled', function () { "uniqueSizeInGBs": 1, "uniqueSizeInMbs": 1, "bootVolumeId": "ocid1.volume.oc1.iad.abuwcljtquhbwu5divro64gimkrnfdaxo43cy44cbpuz42g652ol4gw6qsma" + }, + { + "assetId": "ocid1.bootvolume.oc1.uk-london-1.abwgiljtpbow2jvc3dnuqwxg63ljrszfmmhkdffbm2lgihwxyiddzti2ja2q", + "id": "ocid1.volumebackuppolicyassign.oc1.uk-london-1.abwgiljt3we7xn7kllp2kpxzbnhy57xqvhwcukvkuk57rqd7iaiodr37xjba", + "policyId": "ocid1.volumebackuppolicy.oc1..aaaaaaaa7hwv7iscewqqcmyqe2zuzfce6setvckhbxduswtxf6ctew7e54ja", + "timeCreated": "2021-02-19T09:20:35.840Z" } ], null diff --git a/plugins/oracle/compute/bootVolumeCMKEncryption.js b/plugins/oracle/compute/bootVolumeCMKEncryption.js new file mode 100644 index 000000000..f2c0e149e --- /dev/null +++ b/plugins/oracle/compute/bootVolumeCMKEncryption.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle/'); + +module.exports = { + title: 'Boot Volume CMK Encryption', + category: 'Block Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that boot volumes have encryption enabled using desired protection level.', + more_info: 'By default, boot volumes are encrypted using an Oracle-managed master encryption key. To have better control over the encryption process, you can use Customer-Managed Keys (CMKs).', + recommended_action: 'Ensure all boot volumes have desired encryption level.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Security/Reference/blockstorage_security.htm#data-encryption', + apis: ['vault:list', 'keys:list', 'bootVolume:list'], + settings: { + volume_encryption_level: { + name: 'Boot Volume Encryption Level', + description: 'Desired protection level for boot volumes. default: oracle-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key', + regex: '^(default|cloudcmek|cloudhsm)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var keysObj = {}; + + let desiredEncryptionLevelStr = settings.volume_encryption_level || this.settings.volume_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + async.series([ + function(cb) { + async.each(regions.keys, function(region, rcb) { + let keys = helpers.addSource( + cache, source, ['keys', 'list', region]); + if (keys && keys.data && keys.data.length) helpers.listToObj(keysObj, keys.data, 'id'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.bootVolume, function(region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var bootVolumes = helpers.addSource(cache, source, + ['bootVolume', 'list', region]); + + if (!bootVolumes) return rcb(); + + if (bootVolumes.err || !bootVolumes.data) { + helpers.addResult(results, 3, + 'Unable to query for boot volumes: ' + helpers.addError(bootVolumes), region); + return rcb(); + } + + if (!bootVolumes.data.length) { + helpers.addResult(results, 0, 'No boot volumes found', region); + return rcb(); + } + + bootVolumes.data.forEach(bootVolume => { + if (bootVolume.lifecycleState && bootVolume.lifecycleState === 'TERMINATED') return; + + let currentEncryptionLevel = 1; //default + + if (bootVolume.kmsKeyId) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[bootVolume.kmsKeyId], helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Boot volume (${bootVolume.displayName}) has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, region, bootVolume.id); + } else { + helpers.addResult(results, 2, + `Boot volume (${bootVolume.displayName}) has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, region, bootVolume.id); + } + }); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/oracle/compute/bootVolumeCMKEncryption.spec.js b/plugins/oracle/compute/bootVolumeCMKEncryption.spec.js new file mode 100644 index 000000000..bbb13e952 --- /dev/null +++ b/plugins/oracle/compute/bootVolumeCMKEncryption.spec.js @@ -0,0 +1,189 @@ +var expect = require('chai').expect; +var plugin = require('./bootVolumeCMKEncryption'); + +const bootVolumes = [ + { + "compartmentId": "ocid1.tenancy.oc1.aaaaaa.111111", + "definedTags": {}, + "displayName": "vol-2", + "freeformTags": {}, + "systemTags": {}, + "id": "ocid1.volume.oc1.aaaaaa.1111111", + "isHydrated": true, + "kmsKeyId": null, + "lifecycleState": "AVAILABLE", + "performanceTier": null, + "vpusPerGB": null, + "sizeInGBs": 1024, + "sizeInMBs": 1048576, + "sourceDetails": null, + "timeCreated": "2019-08-29T21:46:01.836Z", + "volumeGroupId": null + }, + { + "compartmentId": "ocid1.tenancy.oc1.aaaaaa.111111", + "definedTags": {}, + "displayName": "vol-2", + "freeformTags": {}, + "systemTags": {}, + "id": "ocid1.volume.oc1.aaaaaa.1111111", + "isHydrated": true, + "lifecycleState": "AVAILABLE", + "performanceTier": null, + "vpusPerGB": null, + "sizeInGBs": 1024, + "sizeInMBs": 1048576, + "sourceDetails": null, + "timeCreated": "2019-08-29T21:46:01.836Z", + "volumeGroupId": null, + "kmsKeyId": 'key-1' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + bootVolume: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + }, + keys: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "key-1", + "lifecycleState": "ENABLED", + "timeCreated": "2022-04-30T19:49:12.841Z", + "vaultId": "vault-1", + "protectionMode": "SOFTWARE", + "algorithm": "AES" + } + ], + + } + } + } + } +}; + +describe('bootVolumeCMKEncryption', function () { + describe('run', function () { + it('should give unknown result if a boot volume error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for boot volumes') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no boot volumes are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No boot volumes found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give failing result if boot volume does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is less') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [bootVolumes[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if boot volume has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is greater than or equal to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [bootVolumes[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/compute/bootVolumeRestorable.js b/plugins/oracle/compute/bootVolumeRestorable.js index 098f3da38..39ee4ccf7 100644 --- a/plugins/oracle/compute/bootVolumeRestorable.js +++ b/plugins/oracle/compute/bootVolumeRestorable.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Boot Volume Restorable', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures boot volumes can be restored to a recent point.', more_info: 'Having an active backup ensures that the boot volumes can be restored in the event of a compromised system or hardware failure.', recommended_action: 'Ensures boot volumes can be restored to a recent point.', diff --git a/plugins/oracle/compute/bootVolumeTransitEncryption.js b/plugins/oracle/compute/bootVolumeTransitEncryption.js index aacc1d13e..e7d425831 100644 --- a/plugins/oracle/compute/bootVolumeTransitEncryption.js +++ b/plugins/oracle/compute/bootVolumeTransitEncryption.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Boot Volume Transit Encryption', category: 'Compute', + domain: 'Compute', + severity: 'High', description: 'Ensures in-transit data encryption is enabled on boot volumes.', more_info: 'Enabling boot volume in-transit data encryption ensures that boot volume data is secured and follows Oracle security best practices.', recommended_action: 'boot volume transit encryption can only be configured when creating a new instance. Recreate the instance with in-transit encryption enabled.', diff --git a/plugins/oracle/compute/instanceMaxCount.js b/plugins/oracle/compute/instanceMaxCount.js index 2c2e0dec9..d29389589 100644 --- a/plugins/oracle/compute/instanceMaxCount.js +++ b/plugins/oracle/compute/instanceMaxCount.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Instance Max Count', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures the total number of VM instances does not exceed a set threshold.', more_info: 'The number of running VM instances should be carefully audited, especially in unused regions, to ensure only approved applications are consuming compute resources. Many compromised Oracle accounts see large numbers of VM instances launched.', recommended_action: 'Ensure that the number of running VM instances matches the expected count. If instances are launched above the threshold, investigate to ensure they are legitimate.', @@ -87,7 +89,121 @@ module.exports = { description: 'Checks for the number of running instances in the eu-zurich-1 region and triggers a failing result if it exceeds the specified count', regex: '^[0-9]{1,4}$', default: 50 - } + }, + instance_count_region_threshold_ap_melbourne_1: { + name: 'Instance Count Region Threshold: ap-melbourne-1', + description: 'Checks for the number of running instances in the ap-melbourne-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_sa_vinhedo_1: { + name: 'Instance Count Region Threshold: sa-vinhedo-1', + description: 'Checks for the number of running instances in the sa-vinhedo-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_ca_montreal_1: { + name: 'Instance Count Region Threshold: ca-montreal-1', + description: 'Checks for the number of running instances in the ca-montreal-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_sa_santiago_1: { + name: 'Instance Count Region Threshold: sa-santiago-1', + description: 'Checks for the number of running instances in the sa-santiago-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_ap_hyderabad_1: { + name: 'Instance Count Region Threshold: ap-hyderabad-1', + description: 'Checks for the number of running instances in the ap-hyderabad-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_eu_amsterdam_1: { + name: 'Instance Count Region Threshold: eu-amsterdam-1', + description: 'Checks for the number of running instances in the eu-amsterdam-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_me_jeddah_1: { + name: 'Instance Count Region Threshold: me-jeddah-1', + description: 'Checks for the number of running instances in the me-jeddah-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_ap_chuncheon_1: { + name: 'Instance Count Region Threshold: ap-chuncheon-1', + description: 'Checks for the number of running instances in the ap-chuncheon-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_me_dubai_1: { + name: 'Instance Count Region Threshold: me-dubai-1', + description: 'Checks for the number of running instances in the me-dubai-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_uk_cardiff_1: { + name: 'Instance Count Region Threshold: uk-cardiff-1', + description: 'Checks for the number of running instances in the uk-cardiff-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_us_sanjose_1: { + name: 'Instance Count Region Threshold: us-sanjose-1', + description: 'Checks for the number of running instances in the us-sanjose-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_eu_marseille_1: { + name: 'Instance Count Region Threshold: eu-marseille-1', + description: 'Checks for the number of running instances in the eu-marseille-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_il_jerusalem_1: { + name: 'Instance Count Region Threshold: il-jerusalem-1', + description: 'Checks for the number of running instances in the il-jerusalem-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_eu_milan_1: { + name: 'Instance Count Region Threshold: eu-milan-1', + description: 'Checks for the number of running instances in the eu-milan-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_ap_singapore_1: { + name: 'Instance Count Region Threshold: ap-singapore-1', + description: 'Checks for the number of running instances in the ap-singapore-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_eu_amsterdam_1: { + name: 'Instance Count Region Threshold: eu-amsterdam-1', + description: 'Checks for the number of running instances in the eu-amsterdam-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_af_johannesburg_1: { + name: 'Instance Count Region Threshold: af-johannesburg-1', + description: 'Checks for the number of running instances in the af-johannesburg-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_eu_stockholm_1: { + name: 'Instance Count Region Threshold: eu-stockholm-1', + description: 'Checks for the number of running instances in the eu-stockholm-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, + instance_count_region_threshold_mx_queretaro_1: { + name: 'Instance Count Region Threshold: mx-queretaro-1', + description: 'Checks for the number of running instances in the mx-queretaro-1 region and triggers a failing result if it exceeds the specified count', + regex: '^[0-9]{1,4}$', + default: 50 + }, }, run: function(cache, settings, callback) { @@ -105,7 +221,18 @@ module.exports = { instance_count_region_threshold_sa_saopaulo_1: settings.instance_count_region_threshold_sa_saopaulo_1 || this.settings.instance_count_region_threshold_sa_saopaulo_1.default, instance_count_region_threshold_ap_osaka_1: settings.instance_count_region_threshold_ap_osaka_1 || this.settings.instance_count_region_threshold_ap_osaka_1.default, instance_count_region_threshold_eu_zurich_1: settings.instance_count_region_threshold_eu_zurich_1 || this.settings.instance_count_region_threshold_eu_zurich_1.default, - + instance_count_region_threshold_ap_melbourne_1: settings.instance_count_region_threshold_ap_melbourne_1 || this.settings.instance_count_region_threshold_ap_melbourne_1.default, + instance_count_region_threshold_sa_vinhedo_1: settings.instance_count_region_threshold_sa_vinhedo_1 || this.settings.instance_count_region_threshold_sa_vinhedo_1.default, + instance_count_region_threshold_ca_montreal_1: settings.instance_count_region_threshold_ca_montreal_1 || this.settings.instance_count_region_threshold_ca_montreal_1.default, + instance_count_region_threshold_sa_santiago_1: settings.instance_count_region_threshold_sa_santiago_1 || this.settings.instance_count_region_threshold_sa_santiago_1.default, + instance_count_region_threshold_ap_hyderabad_1: settings.instance_count_region_threshold_ap_hyderabad_1 || this.settings.instance_count_region_threshold_ap_hyderabad_1.default, + instance_count_region_threshold_eu_amsterdam_1: settings.instance_count_region_threshold_eu_amsterdam_1 || this.settings.instance_count_region_threshold_eu_amsterdam_1.default, + instance_count_region_threshold_me_jeddah_1: settings.instance_count_region_threshold_me_jeddah_1 || this.settings.instance_count_region_threshold_me_jeddah_1.default, + instance_count_region_threshold_ap_chuncheon_1: settings.instance_count_region_threshold_ap_chuncheon_1 || this.settings.instance_count_region_threshold_ap_chuncheon_1.default, + instance_count_region_threshold_me_dubai_1: settings.instance_count_region_threshold_me_dubai_1 || this.settings.instance_count_region_threshold_me_dubai_1.default, + instance_count_region_threshold_uk_cardiff_1: settings.instance_count_region_threshold_uk_cardiff_1 || this.settings.instance_count_region_threshold_uk_cardiff_1.default, + instance_count_region_threshold_us_sanjose_1: settings.instance_count_region_threshold_us_sanjose_1 || this.settings.instance_count_region_threshold_us_sanjose_1.default, + instance_count_region_threshold_mx_queretaro_1: settings.instance_count_region_threshold_mx_queretaro_1 || this.settings.instance_count_region_threshold_mx_queretaro_1.default }; for (c in config) { diff --git a/plugins/oracle/compute/instanceMonitoringEnabled.js b/plugins/oracle/compute/instanceMonitoringEnabled.js index ea642576b..8b5d24bcf 100644 --- a/plugins/oracle/compute/instanceMonitoringEnabled.js +++ b/plugins/oracle/compute/instanceMonitoringEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Instance Monitoring Enabled', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures monitoring is enabled for instances.', more_info: 'Enabling instance monitoring allows for metrics to be collected on the instance. Following security best practices. ', recommended_action: 'When creating a new instance, ensure monitoring is enabled under advanced settings.', diff --git a/plugins/oracle/compute/instancePolicyProtection.js b/plugins/oracle/compute/instancePolicyProtection.js index a82971d6c..49f829104 100644 --- a/plugins/oracle/compute/instancePolicyProtection.js +++ b/plugins/oracle/compute/instancePolicyProtection.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Instance Policy Protection', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures policy statements have deletion protection for compute instances unless it is an administrator group.', more_info: 'Adding deletion protection to Oracle compute instance policies mitigates unintended deletion of instances by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/iam_security.htm', @@ -12,8 +13,8 @@ module.exports = { settings: { policy_group_admins: { name: 'Admin groups with delete permissions.', - description: 'The admin groups allowed to delete resources.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + description: 'Comma separated list of the admin groups allowed to delete resources.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -21,88 +22,54 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); + var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default }; - async.each(regions.default, function (region, rcb) { - - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); - - if (!policies) return rcb(); - - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) + + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); + + if (!policies) return callback(null, results, source); + + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } + + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; + var resourceTypes = ['instance-family']; + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins); + + if (!statementPasses) { + policyProtection = false; + + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to delete compute instances in ${statementObj['location']}`, region, policy.id); + } + }); } + }); - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); - } - var policyProtection = true; - var entered = false; - - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; - - policy.statements.forEach(statement => { - - const statementLower = statement.toLowerCase(); - - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - (statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1 && - statementLower.indexOf('_delete') === -1 && - statementLower.indexOf('instance_') === -1) && - (statementLower.indexOf('instance-family') > -1 || - statementLower.indexOf('all-resources') > -1)) { - - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - var severity = 2; - - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = 'The ' + statementArr[1]; - } - - if (groupName === config.policy_group_admins.toLowerCase()) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'The user'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies have compute instance delete protection enabled', region); + } - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to delete all compute instances in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - } - }); - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies have compute instance delete protection enabled', region); - } - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); - }); + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/compute/instancePolicyProtection.spec.js b/plugins/oracle/compute/instancePolicyProtection.spec.js index 6a147e81c..82e393dff 100644 --- a/plugins/oracle/compute/instancePolicyProtection.spec.js +++ b/plugins/oracle/compute/instancePolicyProtection.spec.js @@ -170,7 +170,7 @@ describe('instancePolicyProtection', function () { [ { "statements": [ - "Allow group Developers to manage instance-family in tenancy where request.permission!='INSTANCE_DELETE'", + "Allow group Developers to read instance-family in tenancy where request.permission != 'INSTANCE_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/compute/instancePoolMultiAD.js b/plugins/oracle/compute/instancePoolMultiAD.js index 9c345bcf2..b68979329 100644 --- a/plugins/oracle/compute/instancePoolMultiAD.js +++ b/plugins/oracle/compute/instancePoolMultiAD.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Instance Pool Multiple AD', category: 'Compute', + domain: 'Compute', + severity: 'Medium', description: 'Ensures instance pools are launched in multiple availability domains.', more_info: 'Launching instance pools in multiple availability domains follows best practices by creating highly available resources.', recommended_action: 'When launching instance pools, Add multiple availability domains.', diff --git a/plugins/oracle/compute/legacyEndpointDisabled.js b/plugins/oracle/compute/legacyEndpointDisabled.js new file mode 100644 index 000000000..164bd401c --- /dev/null +++ b/plugins/oracle/compute/legacyEndpointDisabled.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Legacy Metadata Endpoint Disabled', + category: 'Compute', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that compute instances are configured with Legacy MetaData service (IMDSv1) endpoints disabled.', + more_info: 'For best security practices, it is recommended that the compute instances should be configured with legacy v1 endpoints (Instance Metadata Service v1) disabled, and use Instance Metadata Service v2 instead.', + recommended_action: 'Ensure all compute instances are configured to use IMDSv2.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/gettingmetadata.htm#upgrading-v2', + apis: ['instance:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.instance, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var instances = helpers.addSource(cache, source, + ['instance', 'list', region]); + + if (!instances) return rcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, + 'Unable to query for instances: ' + helpers.addError(instances), region); + return rcb(); + } + + if (!instances.data.length) { + helpers.addResult(results, 0, 'No instances found', region); + return rcb(); + } + + instances.data.forEach(instance => { + if (instance.instanceOptions && instance.instanceOptions.areLegacyImdsEndpointsDisabled) { + helpers.addResult(results, 0, 'Instance has Legacy MetaData service (IMDSv1) endpoints disabled', region, instance.id); + } else { + helpers.addResult(results, 2, 'Instance does not have Legacy MetaData service (IMDSv1) endpoints disabled', region, instance.id); + } + }); + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/compute/legacyEndpointDisabled.spec.js b/plugins/oracle/compute/legacyEndpointDisabled.spec.js new file mode 100644 index 000000000..c0c58f9bf --- /dev/null +++ b/plugins/oracle/compute/legacyEndpointDisabled.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var plugin = require('./legacyEndpointDisabled'); + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + instance: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('legacyEndpointDisabled', function () { + describe('run', function () { + it('should give unknown result if an instance error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for instances') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }) + it('should give passing result if no instance records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No instances found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }) + it('should give passing result if instance has legacy endpoints disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Instance has Legacy MetaData service (IMDSv1) endpoints disabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaaaaa", + "dedicatedVmHostId": null, + "definedTags": {}, + "displayName": "instance1", + "extendedMetadata": { + "compute_management": { + "instance_configuration": { + "state": "SUCCEEDED" + } + } + }, + "faultDomain": "FAULT-DOMAIN-1", + "id": "ocid1.instance.oc1", + "imageId": "ocid1.image.oc1", + "instanceOptions": { + "areLegacyImdsEndpointsDisabled": true + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if instance does not have legacy endpoints disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Instance does not have Legacy MetaData service (IMDSv1) endpoints disabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaaaaa", + "dedicatedVmHostId": null, + "definedTags": {}, + "displayName": "instance1", + "extendedMetadata": { + "compute_management": { + "instance_configuration": { + "state": "SUCCEEDED" + } + } + }, + "faultDomain": "FAULT-DOMAIN-1", + "id": "ocid1.instance.oc1", + "imageId": "ocid1.image.oc1", + "instanceOptions": { + "areLegacyImdsEndpointsDisabled": false + } + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/oracle/database/dbBackupEnabled.js b/plugins/oracle/database/dbBackupEnabled.js index 94210fe6f..c204264a3 100644 --- a/plugins/oracle/database/dbBackupEnabled.js +++ b/plugins/oracle/database/dbBackupEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Database Backup Enabled', category: 'Database', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that all databases have auto backup enabled', more_info: 'Enabling automatic backup on databases ensures that all sensitive data is protected from unwarranted deletion or loss of data.', link: 'https://docs.cloud.oracle.com/iaas/Content/Database/Tasks/backingupOS.htm', diff --git a/plugins/oracle/database/dbNSGEnabled.js b/plugins/oracle/database/dbNSGEnabled.js index da91c7851..65a04cd4e 100644 --- a/plugins/oracle/database/dbNSGEnabled.js +++ b/plugins/oracle/database/dbNSGEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'DB Network Security Groups Enabled', category: 'Database', + domain: 'Databases', + severity: 'High', description: 'Ensures that all databases have network security groups enabled.', more_info: 'Enabling network security groups on database systems allow for fine grain control over network access to the database, ensuring databases are only accessible from trusted entities and following security best practices.', link: 'https://docs.cloud.oracle.com/iaas/Content/Database/Tasks/backingupOS.htm', diff --git a/plugins/oracle/database/dbPolicyProtection.js b/plugins/oracle/database/dbPolicyProtection.js index ac043c9a9..0b2c61414 100644 --- a/plugins/oracle/database/dbPolicyProtection.js +++ b/plugins/oracle/database/dbPolicyProtection.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Database Policy Protection', category: 'Database', + domain: 'Databases', + severity: 'Low', description: 'Ensures policy statements have deletion protection for database systems, databases, and database homes unless it is an administrator group.', more_info: 'Adding deletion protection to Oracle database policies mitigates unintended deletion of database services by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/dbaas_security.htm', @@ -12,8 +13,8 @@ module.exports = { settings: { policy_group_admins: { name: 'Admin groups with delete permissions.', - description: 'The admin groups allowed to delete resources.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + description: 'Comma separated list of the admin groups allowed to delete resources.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -21,91 +22,54 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); + var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default }; + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) - async.each(regions.default, function (region, rcb) { - - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); - - if (!policies) return rcb(); + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } - - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); - } - var policyProtection = true; - var entered = false; + if (!policies) return callback(null, results, source); - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } - policy.statements.forEach(statement => { - const statementLower = statement.toLowerCase(); + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; + var resourceTypes = ['databases', 'db-homes'] - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - (statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1 && - statementLower.indexOf('_delete') === -1 && - (statementLower.indexOf('db_system_') === -1 || - statementLower.indexOf('database_') === -1 || - statementLower.indexOf('db_home_') === -1)) && - (statementLower.indexOf('db-systems') > -1 || - statementLower.indexOf('databases') > -1 || - statementLower.indexOf('db-homes') > -1 || - statementLower.indexOf('all-resources') > -1)) { + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins); - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - var severity = 2; + if (!statementPasses) { + policyProtection = false; - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = 'The ' + statementArr[1]; - } - - if (groupName === config.policy_group_admins.toLowerCase()) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'The user'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } - - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to delete all database services in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - } - }); - - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies have database delete protection enabled', region); + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to delete all database services in ${statementObj['location']}`, region, policy.id); + } + }); } - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); }); + + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies have database delete protection enabled', region); + } + + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/database/dbPolicyProtection.spec.js b/plugins/oracle/database/dbPolicyProtection.spec.js index 3e71163c8..b33415841 100644 --- a/plugins/oracle/database/dbPolicyProtection.spec.js +++ b/plugins/oracle/database/dbPolicyProtection.spec.js @@ -92,7 +92,7 @@ describe('dbPolicyProtection', function () { { "statements": [ "ALLOW GROUP Developers to manage all-resources IN TENANCY", - "Allow group Developers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'", + "Allow group Developers to manage file-systems in tenancy where request.permission != 'FILE_SYSTEM_DELETE'", "ALLOW GROUP ADMINISTRATORS to manage all-resources IN TENANCY" ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", @@ -170,7 +170,7 @@ describe('dbPolicyProtection', function () { [ { "statements": [ - "Allow group Developers to manage databases in tenancy where request.permission!='DATABASE_DELETE'", + "Allow group Developers to read databases in tenancy where request.permission!='DATABASE_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/database/dbPrivateSubnetOnly.js b/plugins/oracle/database/dbPrivateSubnetOnly.js index 0c193d368..e538fd636 100644 --- a/plugins/oracle/database/dbPrivateSubnetOnly.js +++ b/plugins/oracle/database/dbPrivateSubnetOnly.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'DB Private Subnet Only', category: 'Database', + domain: 'Databases', + severity: 'Medium', description: 'Ensures that all database systems are in private subnets only.', more_info: 'Database systems in private subnets ensure that access to the database can ' + 'only be from within the internal architecture, following security best practices.', diff --git a/plugins/oracle/filestorage/fileSystemsCMKEncryption.js b/plugins/oracle/filestorage/fileSystemsCMKEncryption.js new file mode 100644 index 000000000..a473b29c3 --- /dev/null +++ b/plugins/oracle/filestorage/fileSystemsCMKEncryption.js @@ -0,0 +1,94 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'File Systems CMK Encryption', + category: 'File Storage', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that OCI File Storage file systems have encryption enabled using desired protection level.', + more_info: 'By default, OCI File Storage file systems are encrypted using an Oracle-managed master encryption key. To have better control over the encryption process, you can use Customer-Managed Keys (CMKs).', + recommended_action: 'Ensure all file systems have desired encryption level.', + link: 'https://docs.oracle.com/en-us/iaas/Content/File/Concepts/filestorageoverview.htm#encryption', + apis: ['vault:list', 'keys:list', 'fileSystem:list'], + settings: { + file_system_encryption_level: { + name: 'File System Encryption Level', + description: 'Desired protection level for File Storage file systems. default: oracle-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key', + regex: '^(default|cloudcmek|cloudhsm)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var keysObj = {}; + + let desiredEncryptionLevelStr = settings.file_system_encryption_level || this.settings.file_system_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + async.series([ + function(cb) { + async.each(regions.keys, function(region, rcb) { + let keys = helpers.addSource( + cache, source, ['keys', 'list', region]); + if (keys && keys.data && keys.data.length) helpers.listToObj(keysObj, keys.data, 'id'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.fileSystem, function(region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var fileSystems = helpers.addSource(cache, source, + ['fileSystem', 'list', region]); + + if (!fileSystems) return rcb(); + + if (fileSystems.err || !fileSystems.data) { + helpers.addResult(results, 3, + 'Unable to query for file systems: ' + helpers.addError(fileSystems), region); + return rcb(); + } + + if (!fileSystems.data.length) { + helpers.addResult(results, 0, 'No file systems found', region); + return rcb(); + } + + fileSystems.data.forEach(fileSystem => { + let currentEncryptionLevel = 1; //default + + if (fileSystem.kmsKeyId) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[fileSystem.kmsKeyId], helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `File System (${fileSystem.displayName}) has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, region, fileSystem.id); + } else { + helpers.addResult(results, 2, + `File System (${fileSystem.displayName}) has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, region, fileSystem.id); + } + }); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/oracle/filestorage/fileSystemsCMKEncryption.spec.js b/plugins/oracle/filestorage/fileSystemsCMKEncryption.spec.js new file mode 100644 index 000000000..434b00d71 --- /dev/null +++ b/plugins/oracle/filestorage/fileSystemsCMKEncryption.spec.js @@ -0,0 +1,169 @@ +var expect = require('chai').expect; +var plugin = require('./fileSystemsCMKEncryption'); + +const fileSystems = [ + { + "compartmentId": 'ocid1.tenancy.oc1.aaaaa.111111', + "displayName": 'FileSystem1', + "id": 'ocid1.filesystem.oc1.iad.1111', + "lifecycleState": 'ACTIVE', + "timeCreated": '2022-05-29T21:12:16.928Z', + "kmsKeyId": '', + }, + { + "compartmentId": 'ocid1.tenancy.oc1.aaaaa.111111', + "displayName": 'FileSystem1', + "id": 'ocid1.filesystem.oc1.iad.1111', + "lifecycleState": 'ACTIVE', + "timeCreated": '2022-05-29T21:12:16.928Z', + "kmsKeyId": 'key-1', + }, +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + fileSystem: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + }, + keys: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "key-1", + "lifecycleState": "ENABLED", + "timeCreated": "2022-04-30T19:49:12.841Z", + "vaultId": "vault-1", + "protectionMode": "SOFTWARE", + "algorithm": "AES" + } + ], + + } + } + } + } +}; + +describe('fileSystemsCMKEncryption', function () { + describe('run', function () { + it('should give unknown result if a file system error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for file systems') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no file systems are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No file systems found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give failing result if file system does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is less') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [fileSystems[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if file system has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is greater than or equal to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [fileSystems[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/filestorage/nfsPolicyProtection.js b/plugins/oracle/filestorage/nfsPolicyProtection.js index 78d411fd8..1d0c3116e 100644 --- a/plugins/oracle/filestorage/nfsPolicyProtection.js +++ b/plugins/oracle/filestorage/nfsPolicyProtection.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'File Storage Policy Protection', category: 'File Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures policy statements have deletion protection for file storage services unless it is an administrator group.', more_info: 'Adding deletion protection to Oracle file storage policies mitigates unintended deletion of file storage services by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/filestorage_security.htm', @@ -12,8 +13,8 @@ module.exports = { settings: { policy_group_admins: { name: 'Admin groups with delete permissions.', - description: 'The admin groups allowed to delete resources.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + description: 'Comma separated list of the admin groups allowed to delete resources.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -21,93 +22,55 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default }; - async.each(regions.default, function (region, rcb) { + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); - if (!policies) return rcb(); + if (!policies) return callback(null, results, source); - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } - - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); - } - var policyProtection = true; - var entered = false; - - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; - policy.statements.forEach(statement => { - - const statementLower = statement.toLowerCase(); + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - (statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1 && - statementLower.indexOf('_delete') === -1 && - (statementLower.indexOf('file_system_') === -1 || - statementLower.indexOf('mount_target_') === -1 || - statementLower.indexOf('export_set_') === -1)) && - (statementLower.indexOf('file-systems') > -1 || - statementLower.indexOf('mount-targets') > -1 || - statementLower.indexOf('export-sets') > -1 || - statementLower.indexOf('all-resources') > -1)) { + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - var severity = 2; + var resourceTypes = ['file-systems', 'mount-targets', 'export-sets']; - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = 'The ' + statementArr[1]; - } + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins); - if (groupName === config.policy_group_admins.toLowerCase()) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'The user'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } + if (!statementPasses) { + policyProtection = false; - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to delete all file storage services in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - } - }); - - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies have file storage delete protection enabled', region); + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to delete all file storage services in ${statementObj['location']}`, region, policy.id); + } + }); } - - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); }); + + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies have file storage delete protection enabled', region); + } + + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/filestorage/nfsPolicyProtection.spec.js b/plugins/oracle/filestorage/nfsPolicyProtection.spec.js index 4d8467a1a..76ee36bb4 100644 --- a/plugins/oracle/filestorage/nfsPolicyProtection.spec.js +++ b/plugins/oracle/filestorage/nfsPolicyProtection.spec.js @@ -92,7 +92,6 @@ describe('nfsPolicyProtection', function () { { "statements": [ "ALLOW GROUP Developers to manage all-resources IN TENANCY", - "Allow group Developers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'", "ALLOW GROUP ADMINISTRATORS to manage all-resources IN TENANCY" ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", @@ -170,7 +169,7 @@ describe('nfsPolicyProtection', function () { [ { "statements": [ - "Allow group Developers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'", + "Allow group Developers to read file-systems in tenancy where request.permission != 'FILE_SYSTEM_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/filestorage/nfsPublicAccess.js b/plugins/oracle/filestorage/nfsPublicAccess.js index 6a4c0354b..92c12a4d2 100644 --- a/plugins/oracle/filestorage/nfsPublicAccess.js +++ b/plugins/oracle/filestorage/nfsPublicAccess.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'NFS Public Access', category: 'File Storage', + domain: 'Storage', + severity: 'Medium', description: 'Ensures that all file systems do not have public access.', more_info: 'All network file systems should be configured to only allow access from trusted sources.', link: 'https://docs.cloud.oracle.com/iaas/Content/File/Tasks/exportoptions.htm', diff --git a/plugins/oracle/identity/adminUserAPIKeys.js b/plugins/oracle/identity/adminUserAPIKeys.js new file mode 100644 index 000000000..fe688509a --- /dev/null +++ b/plugins/oracle/identity/adminUserAPIKeys.js @@ -0,0 +1,111 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Admin User API Keys', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that API keys do not exist for tenancy administrator users.', + more_info: 'The administrator user should avoid using API keys. Since the administrator user has full permissions across the entire tenancy, creating API keys for it only increases the chance that they are compromised. ' + + 'Instead, create non-admin user with limited permissions and use its API keys.', + link: 'https://docs.oracle.com/en/cloud/get-started/subscriptions-cloud/csgsg/create-users-and-assign-roles.html', + recommended_action: 'Remove API keys for administrator users', + apis: ['user:list', 'apiKey:list', 'group:list', 'userGroupMembership:list', 'authToken:list'], + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + var groups = helpers.addSource(cache, source, + ['group', 'list', region]); + + var userGroups = helpers.addSource(cache, source, + ['userGroupMembership', 'list', region]); + + var apiKeys = helpers.addSource(cache, source, + ['apiKey', 'list', region]); + + if (!users || !groups || !userGroups || !apiKeys) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(users), 'global'); + return callback(null, results, source); + } + + if (!users.data.length) { + helpers.addResult(results, 0, 'No user accounts found', 'global'); + return callback(null, results, source); + } + + if (groups.err || !groups.data) { + helpers.addResult(results, 3, + 'Unable to query user groups: ' + helpers.addError(groups), 'global'); + return callback(null, results, source); + } + + if (!groups.data.length) { + helpers.addResult(results, 0, 'No groups found', 'global'); + return callback(null, results, source); + } + + if (userGroups.err || !userGroups.data) { + helpers.addResult(results, 3, + 'Unable to query user groups: ' + helpers.addError(userGroups), 'global'); + return callback(null, results, source); + } + + if (!userGroups.data.length) { + helpers.addResult(results, 0, 'No user group membership found', 'global'); + return callback(null, results, source); + } + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, + 'Unable to query user API keys: ' + helpers.addError(apiKeys), 'global'); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No user API keys found', 'global'); + return callback(null, results, source); + } + + let adminGroup = groups.data.find(group => group.name == 'Administrators'); + let apiKeyIds = apiKeys.data.map(apiKey => apiKey.userId) || []; + + if (adminGroup) { + + let adminUsers = []; + userGroups.data.forEach(userGroup => { + if (userGroup.groupId === adminGroup.id) { + // User group is part of the 'Administrators' group + adminUsers.push(userGroup.userId); // Add user ID to the array + } + }); + + for (let user of users.data) { + if (!user.id) continue; + + if (adminUsers.includes(user.id)) { + if (apiKeyIds.includes(user.id)) { + helpers.addResult(results, 2, 'API keys exist for admin user', 'global', user.id); + } else { + helpers.addResult(results, 0, 'API key does not exist for admin user', 'global', user.id); + } + } else { + helpers.addResult(results, 0, 'User is not admin user', 'global', user.id); + } + } + } else { + helpers.addResult(results, 0, 'No groups found'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/adminUserAPIKeys.spec.js b/plugins/oracle/identity/adminUserAPIKeys.spec.js new file mode 100644 index 000000000..eddab03eb --- /dev/null +++ b/plugins/oracle/identity/adminUserAPIKeys.spec.js @@ -0,0 +1,294 @@ +var expect = require('chai').expect; +var plugin = require('./adminUserAPIKeys'); + +const users = [ + { + "capabilities": { + "canUseConsolePassword": true, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "email": "test1@khulnasoft.com", + "emailVerified": false, + "identityProviderId": null, + "externalIdentifier": null, + "timeModified": "2022-01-04T17:00:10.630Z", + "isMfaActivated": false, + "id": "ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "test1@khulnasoft.com", + "description": "admin", + "timeCreated": "2022-01-04T16:59:57.900Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "tomer.daniel@khulnasoft.com", + "CreatedOn": "2022-01-04T16:59:57.803Z" + } + }, + "lifecycleState": "ACTIVE" + }, + { + "capabilities": { + "canUseConsolePassword": true, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "email": "test2@khulnasoft.com", + "emailVerified": false, + "identityProviderId": null, + "externalIdentifier": null, + "timeModified": "2022-01-09T14:53:08.201Z", + "isMfaActivated": false, + "lastSuccessfulLoginTime": "2022-01-09T14:52:08.882Z", + "id": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "test2@khulnasoft.com", + "description": "Muhammad admin", + "timeCreated": "2022-01-04T17:18:33.571Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "tomer.daniel@khulnasoft.com", + "CreatedOn": "2022-01-04T17:18:33.494Z" + } + }, + "lifecycleState": "ACTIVE" + }, +]; + +const groups = [ + { + "id": "ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaanemweaxe4b6q2gcodq", + "name": "Administrators", + "description": "Administrators", + "timeCreated": "2022-01-04T16:41:28.470Z", + "freeformTags": {}, + "definedTags": {}, + "lifecycleState": "ACTIVE" + } +] + +const createCache = (userData, groupData, userErr, groupErr) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: userErr, + data: userData + } + } + }, + group: { + list: { + 'us-ashburn-1': { + err: groupErr, + data: groupData + } + } + }, + userGroupMembership: { + list: { + 'us-ashburn-1': { + data: [ + { + "userId": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "groupId": "ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq", + "id": "ocid1.groupmembership.oc1..aaaaaaaabis5ze6hqgxd77hn3j7z6vy6m3xtq6ydzo2hc3j6hnod37hsh6fq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "GRP_MBR:ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq-ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "timeCreated": "2022-01-04T17:18:39.587Z", + "lifecycleState": "ACTIVE" + }, + { + "userId": "ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "groupId": "ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq", + "id": "ocid1.groupmembership.oc1..aaaaaaaanbleveej3cu2rxfkdqyiidg3mwcz4tm32ug7oyiimnf3y34r2q4a", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "GRP_MBR:ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq-ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "timeCreated": "2022-01-04T17:00:10.630Z", + "lifecycleState": "ACTIVE" + }, + { + "userId": "ocid1.user.oc1..aaaaaaaaqii64e6rkq55nm36h37rvvei3wq43nfuyvg5vsnh5t7sftwgyw3q", + "groupId": "ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq", + "id": "ocid1.groupmembership.oc1..aaaaaaaaeakryb4nlhi2vr3wwxcelgbjsvh66uk55ffdgt2o4zyb57snvneq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "GRP_MBR:ocid1.group.oc1..pidxp7gzxcmsfxyow2p6i6siyagvgdysbuk7iq-ocid1.user.oc1..aaaaaaaaqii64e6rkq55nm36h37rvvei3wq43nfuyvg5vsnh5t7sftwgyw3q", + "timeCreated": "2022-01-04T16:41:28.470Z", + "lifecycleState": "ACTIVE" + }, + { + "userId": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "groupId": "ocid1.group.oc1..aaaaaaaatf7gxukphgsxjpk7ypge6jd6ilxuwp4pfsvwjzvfloirep42o3kq", + "id": "ocid1.groupmembership.oc1..aaaaaaaaqed6hoql3ud5ekesy7kchjqjrrhdcnv7qt3mhgpqrxzaxdaaowva", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "GRP_MBR:ocid1.group.oc1..aaaaaaaatf7gxukphgsxjpk7ypge6jd6ilxuwp4pfsvwjzvfloirep42o3kq-ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "timeCreated": "2022-01-09T12:42:39.396Z", + "lifecycleState": "ACTIVE" + } + ] + } + } + }, + apiKey: { + list: { + 'us-ashburn-1': { + data: [ + { + "timeCreated": "2022-01-09T12:34:21.976Z", + "userId": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "fingerprint": "65:ab:7d:b9:54:a8:7e:c7:42:cb:5a:87:2f:ed:08:d3", + "keyValue": "", + "keyId": "ocid1.tenancy.oc1..aaaaa2gcodq/ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa/65:ab:7d:b9:54:a8:7e:c7:42:cb:5a:87:2f:ed:08:d3", + "lifecycleState": "ACTIVE", + "users": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa" + } + ] + } + } + } + } +}; + +describe('adminUserAPIKeys', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + undefined, + {}, + { err: 'no data found' } + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user accounts found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user accounts found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result API keys exist for admin user', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('API keys exist for admin user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [users[1]], + groups + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result API key does not exist for admin user', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('API key does not exist for admin user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [users[0]], + groups + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No groups found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No groups found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [users[1]], + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query user group', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query user group') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [users[1]], + null, + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/defaultTagsForResources.js b/plugins/oracle/identity/defaultTagsForResources.js new file mode 100644 index 000000000..09d3af6f1 --- /dev/null +++ b/plugins/oracle/identity/defaultTagsForResources.js @@ -0,0 +1,51 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Default Tags For Resources', + category: 'Logging and Monitoring', + domain: 'Management and Governance', + severity: 'Low', + description: 'Ensures default tags are used on resources.', + more_info: 'Having default tags like "CreatedBy" on resources help determine who created the resource in case of an accident.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Tagging/Tasks/managingtagdefaults.htm', + recommended_action: 'Create default tags at the root compartment to ensure that all resources get tagged.', + apis: ['defaultTags:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var defaultTags = helpers.addSource(cache, source, + ['defaultTags', 'list', region]); + + if (!defaultTags) return callback(null, results, source); + + if (defaultTags.err || !defaultTags.data) { + helpers.addResult(results, 3, + 'Unable to query for default tags: ' + helpers.addError(defaultTags), region); + return callback(null, results, source); + } + + if (!defaultTags.data.length) { + helpers.addResult(results, 2, 'No default tags found', region); + return callback(null, results, source); + } + + const compartment = defaultTags.data[0].compartmentId; + + const resourceTag = defaultTags.data.find(tag => + tag.value && tag.value === '${iam.principal.name}' + && tag.lifecycleState && tag.lifecycleState === 'ACTIVE'); + + + if (resourceTag) { + helpers.addResult(results, 0, 'Compartment is using default tags for resources', region, compartment); + } else { + helpers.addResult(results, 2, 'Compartment is not using default tags for resources', region, compartment); + } + + callback(null, results, source); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/defaultTagsForResources.spec.js b/plugins/oracle/identity/defaultTagsForResources.spec.js new file mode 100644 index 000000000..bc920c916 --- /dev/null +++ b/plugins/oracle/identity/defaultTagsForResources.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var plugin = require('./defaultTagsForResources'); + +const defaultTagsList = [ + { + value: '${iam.principal.name}', + tagNamespaceId: 'namespace-1', + tagDefinitionId: 'tag-1', + tagDefinitionName: 'CreatedBy', + isRequired: false, + id: 'tag-1', + compartmentId: 'compartment-1', + timeCreated: '2022-01-04T16:42:07.246Z', + lifecycleState: 'ACTIVE' + }, + { + value: '${oci.datetime}', + tagNamespaceId: 'namespace-1', + tagDefinitionId: 'tag-1', + tagDefinitionName: 'CreatedOn', + isRequired: false, + id: 'tag-1', + compartmentId: 'compartment-1', + timeCreated: '2022-01-04T16:42:07.561Z', + lifecycleState: 'ACTIVE' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + defaultTags: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('defaultTags', function () { + describe('run', function () { + it('should give unknown result if unable to query for default tags', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for default tags') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no default tags found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No default tags found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if compartment does not have default tags for resources', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('is not using') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [defaultTagsList[1]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if comaprtment has default tags for resources enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('is using') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [defaultTagsList[0]] + ); + + plugin.run(cache, {}, callback); + }); + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/emptyGroups.js b/plugins/oracle/identity/emptyGroups.js index 863c15e5c..9896bcc97 100644 --- a/plugins/oracle/identity/emptyGroups.js +++ b/plugins/oracle/identity/emptyGroups.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Empty Groups', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Low', description: 'Ensures all groups have at least one member.', more_info: 'While having empty groups does not present a direct security risk, it does broaden the management landscape which could potentially introduce risks in the future.', link: 'https://docs.oracle.com/cd/E10391_01/doc.910/e10360/usergroups.htm', @@ -50,7 +52,7 @@ module.exports = { }); if (users && users.length) { - helpers.addResult(results, 0, `Group: ${group.name} contains ' + users.length + ' user(s)`, defaultRegion, group.id); + helpers.addResult(results, 0, `Group: ${group.name} contains ${users.length} user(s)`, defaultRegion, group.id); } else { helpers.addResult(results, 1, `Group: ${group.name} does not contain any users`, defaultRegion,group.id) } diff --git a/plugins/oracle/identity/emptyGroups.spec.js b/plugins/oracle/identity/emptyGroups.spec.js new file mode 100644 index 000000000..027ac9667 --- /dev/null +++ b/plugins/oracle/identity/emptyGroups.spec.js @@ -0,0 +1,126 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./emptyGroups'); + +const userGroups = [ + { + "userId": 'ocid1.user.oc1..11111111111111222222222222222333333333333', + "groupId": 'ocid1.group.oc1..aaaaaaaa5y47111111222222333333', + "id": 'ocid1.groupmembership.oc1..111111111222222222223333333333333' + }, + { + "userId": 'ocid1.user.oc1..1111111111111111112222222222222222333333333333', + "groupId": 'ocid1.group.oc1..aaaaaaaa5y47111111222222333333', + "id": 'ocid1.groupmembership.oc1..1111111111111222222222222222233333333333333' + }, + { + "userId": 'ocid1.user.oc1..111111111111111122222222222222222333333333333333', + "groupId": 'ocid1.group.oc1..aaaaaaaa5y47111111222222333333', + "id": 'ocid1.groupmembership.oc1..1111111111111112222222222222222222333333333333' + } +]; + +const groups = [ + { + "id": 'ocid1.group.oc1..aaaaaaaa5y47111111222222333333', + "name": 'Administrators' + }, + { + "id": 'ocid1.group.oc1..111111111111111222222222222223333333333333333', + "name": 'securityAudit' + } +] + +const createCache = (groups, userGroups, groupsErr, userGroupsErr) => { + return { + group: { + list: { + 'us-ashburn-1': { + data: groups, + err: groupsErr + } + } + }, + userGroupMembership: { + list: { + 'us-ashburn-1': { + data: userGroups, + err: userGroupsErr + } + } + } + } +}; + +describe('emptyGroups', function () { + describe('run', function () { + it('should give unknown result if unable to query user groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query user groups') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [{}], + [{}], + {err: 'error'} + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no groups found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No groups found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [], + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give warn result if group does not contain any users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('does not contain any users') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [groups[1]], + userGroups + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if group contains user(s)', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('contains 3 user(s)') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [groups[0]], + userGroups + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/excessivePolicies.js b/plugins/oracle/identity/excessivePolicies.js index a297bc2d4..149094a39 100644 --- a/plugins/oracle/identity/excessivePolicies.js +++ b/plugins/oracle/identity/excessivePolicies.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Excessive Policies', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Determine if there are an excessive number of policies in the account', more_info: 'Keeping the number of policies to a minimum helps reduce the chances of ' + 'compromised accounts causing catastrophic damage to the account. Rather than ' + @@ -31,7 +32,6 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); var config = { excessive_policy_fail: settings.excessive_policy_fail || this.settings.excessive_policy_fail.default, excessive_policy_warn: settings.excessive_policy_warn || this.settings.excessive_policy_warn.default @@ -39,42 +39,39 @@ module.exports = { var custom = helpers.isCustom(settings, this.settings); - async.each(regions.default, function(region, rcb){ + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) - if (helpers.checkRegionSubscription(cache, source, results, region)) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); - if (!policies) return rcb(); + if (!policies) return callback(null, results, source); - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); - } + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } - var policyAmt = policies.data.length; + var policyAmt = policies.data.length; - var returnMsg = ' number of policies: ' + policyAmt + ' found'; + var returnMsg = ' number of policies: ' + policyAmt + ' found'; - if (policyAmt > config.excessive_policy_fail) { - helpers.addResult(results, 2, 'Excessive' + returnMsg, region, null, custom); - } else if (policyAmt > config.excessive_policy_warn) { - helpers.addResult(results, 1, 'Large' + returnMsg, region, null, custom); - } else { - helpers.addResult(results, 0, 'Acceptable' + returnMsg, region, null, custom); - } + if (policyAmt > config.excessive_policy_fail) { + helpers.addResult(results, 2, 'Excessive' + returnMsg, region, null, custom); + } else if (policyAmt > config.excessive_policy_warn) { + helpers.addResult(results, 1, 'Large' + returnMsg, region, null, custom); + } else { + helpers.addResult(results, 0, 'Acceptable' + returnMsg, region, null, custom); } - rcb(); - }, function(){ - // Global checking goes here - callback(null, results, source); - }); + } + + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/identity/excessivePolicyStatements.js b/plugins/oracle/identity/excessivePolicyStatements.js index ce5e984d0..d9835608e 100644 --- a/plugins/oracle/identity/excessivePolicyStatements.js +++ b/plugins/oracle/identity/excessivePolicyStatements.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Excessive Policy Statements', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Determine if there are an excessive number of policy statements in the account', more_info: 'Keeping the number of policy statements to a minimum helps reduce the chances ' + 'of compromised accounts causing catastrophic damage to the account. Common statements ' + @@ -30,51 +31,48 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); var config = { excessive_policy_statement_fail: settings.excessive_policy_statement_fail || this.settings.excessive_policy_statement_fail.default, excessive_policy_statement_warn: settings.excessive_policy_statement_warn || this.settings.excessive_policy_statement_warn.default }; var custom = helpers.isCustom(settings, this.settings); + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) + - async.each(regions.default, function(region, rcb){ + if (helpers.checkRegionSubscription(cache, source, results, region)) { - if (helpers.checkRegionSubscription(cache, source, results, region)) { + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); + if (!policies) return callback(null, results, source); - if (!policies) return rcb(); + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + + policies.data.forEach(policy => { + var policyAmt = policy.statements.length; + var returnMsg = ' number of policy statements: ' + policyAmt + ' found'; - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); + if (policyAmt > config.excessive_policy_statement_fail) { + helpers.addResult(results, 2, 'Excessive' + returnMsg, region, policy.id, custom); + } else if (policyAmt > config.excessive_policy_statement_warn) { + helpers.addResult(results, 1, 'Large' + returnMsg, region, policy.id, custom); + } else { + helpers.addResult(results, 0, 'Acceptable' + returnMsg, region, policy.id, custom); } + }); + } - policies.data.forEach(policy => { - var policyAmt = policy.statements.length; - var returnMsg = ' number of policy statements: ' + policyAmt + ' found'; - - if (policyAmt > config.excessive_policy_statement_fail) { - helpers.addResult(results, 2, 'Excessive' + returnMsg, region, policy.id, custom); - } else if (policyAmt > config.excessive_policy_statement_warn) { - helpers.addResult(results, 1, 'Large' + returnMsg, region, policy.id, custom); - } else { - helpers.addResult(results, 0, 'Acceptable' + returnMsg, region, policy.id, custom); - } - }); - } - rcb(); - }, function(){ - // Global checking goes here - callback(null, results, source); - }); + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/identity/iamGroupChanges.js b/plugins/oracle/identity/iamGroupChanges.js new file mode 100644 index 000000000..13dcd5171 --- /dev/null +++ b/plugins/oracle/identity/iamGroupChanges.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'IAM Group Changes', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure an event rule is configured for IAM Group changes.', + more_info: 'Monitoring changes to IAM groups like create, update and delete will help in identifying changes to satisfy least privilege principle.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for IAM Group changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.identitycontrolplane.creategroup' }, + { displayName: 'Update', value: 'com.oraclecloud.identitycontrolplane.updategroup' }, + { displayName: 'Delete', value: 'com.oraclecloud.identitycontrolplane.deletegroup' } + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'IAM Group', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/iamGroupChanges.spec.js b/plugins/oracle/identity/iamGroupChanges.spec.js new file mode 100644 index 000000000..834e343d3 --- /dev/null +++ b/plugins/oracle/identity/iamGroupChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./iamGroupChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.creategroup","com.oraclecloud.identitycontrolplane.updategroup","com.oraclecloud.identitycontrolplane.deletegroup"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.creategroup"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('iamGroupChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no IAM group rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some IAM Group changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all IAM Group changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/iamPolicyChanges.js b/plugins/oracle/identity/iamPolicyChanges.js new file mode 100644 index 000000000..f001414c1 --- /dev/null +++ b/plugins/oracle/identity/iamPolicyChanges.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'IAM Policy Changes', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure an event rule is configured for IAM Policy changes.', + more_info: 'Monitoring changes to IAM policies like create, update and delete will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for IAM policy changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.identitycontrolplane.createpolicy' }, + { displayName: 'Update', value: 'com.oraclecloud.identitycontrolplane.updatepolicy' }, + { displayName: 'Delete', value: 'com.oraclecloud.identitycontrolplane.deletepolicy' } + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'IAM Policy', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/iamPolicyChanges.spec.js b/plugins/oracle/identity/iamPolicyChanges.spec.js new file mode 100644 index 000000000..aa186b321 --- /dev/null +++ b/plugins/oracle/identity/iamPolicyChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./iamPolicyChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createpolicy","com.oraclecloud.identitycontrolplane.updatepolicy","com.oraclecloud.identitycontrolplane.deletepolicy"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createpolicy"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('iamPolicyChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no IAM Policy rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some IAM Policy changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all IAM Policy changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/identityProviderChanges.js b/plugins/oracle/identity/identityProviderChanges.js new file mode 100644 index 000000000..8a1e80a48 --- /dev/null +++ b/plugins/oracle/identity/identityProviderChanges.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Identity Provider Changes', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure an event rule is configured for Identity Provider changes.', + more_info: 'Monitoring Identity Provider changes like create, update and delete will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for Identity Provider changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.identitycontrolplane.createidentityprovider' }, + { displayName: 'Update', value: 'com.oraclecloud.identitycontrolplane.updateidentityprovider' }, + { displayName: 'Delete', value: 'com.oraclecloud.identitycontrolplane.deleteidentityprovider' } + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Identity Provider', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/identityProviderChanges.spec.js b/plugins/oracle/identity/identityProviderChanges.spec.js new file mode 100644 index 000000000..e48eab970 --- /dev/null +++ b/plugins/oracle/identity/identityProviderChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./identityProviderChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createidentityprovider","com.oraclecloud.identitycontrolplane.updateidentityprovider","com.oraclecloud.identitycontrolplane.deleteidentityprovider"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.deleteidentityprovider"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('identityProviderChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no identity provider rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some identity provider changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all identity provider changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/idpGroupMappingChanges.js b/plugins/oracle/identity/idpGroupMappingChanges.js new file mode 100644 index 000000000..c5b585518 --- /dev/null +++ b/plugins/oracle/identity/idpGroupMappingChanges.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Idp Group Mapping Changes', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure an event rule is configured for Idp Group Mapping changes.', + more_info: 'Monitoring changes to Identity Provider Group Mappings like create, update and delete will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for Idp Group Mapping changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.identitycontrolplane.createidpgroupmapping' }, + { displayName: 'Update', value: 'com.oraclecloud.identitycontrolplane.updateidpgroupmapping' }, + { displayName: 'Delete', value: 'com.oraclecloud.identitycontrolplane.deleteidpgroupmapping' } + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Idp Group Mapping', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/idpGroupMappingChanges.spec.js b/plugins/oracle/identity/idpGroupMappingChanges.spec.js new file mode 100644 index 000000000..25a8ee64d --- /dev/null +++ b/plugins/oracle/identity/idpGroupMappingChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./idpGroupMappingChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createidpgroupmapping","com.oraclecloud.identitycontrolplane.updateidpgroupmapping","com.oraclecloud.identitycontrolplane.deleteidpgroupmapping"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createidpgroupmapping"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('idpGroupMappingChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no idp group mapping rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some idp group mapping changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all idp group mapping changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/minPasswordLength.js b/plugins/oracle/identity/minPasswordLength.js index c932af8ca..f4ae17cd8 100644 --- a/plugins/oracle/identity/minPasswordLength.js +++ b/plugins/oracle/identity/minPasswordLength.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Minimum Password Length', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires a minimum password length.', more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/managingpasswordrules.htm', @@ -18,15 +19,8 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var defaultRegion = ''; - if (cache.authenticationPolicy && - cache.authenticationPolicy.get && - Object.keys(cache.authenticationPolicy.get).length) { - defaultRegion = helpers.objectFirstKey(cache.authenticationPolicy.get); - } else { - return callback(null, results, source); - } + var defaultRegion = helpers.objectFirstKey(cache['regionSubscription']['list']); var authenticationPolicy = helpers.addSource(cache, source, ['authenticationPolicy', 'get', defaultRegion]); @@ -48,12 +42,12 @@ module.exports = { var passwordPolicy = policy.passwordPolicy; if (passwordPolicy && passwordPolicy.minimumPasswordLength) { - if (passwordPolicy.minimumPasswordLength > 14) { + if (passwordPolicy.minimumPasswordLength >= 14) { helpers.addResult(results, 0, 'Minimum password length of: ' + passwordPolicy.minimumPasswordLength + ' is suitable', 'global', authenticationPolicy.data.compartmentId); - } else if (passwordPolicy && + } else if (passwordPolicy && passwordPolicy.minimumPasswordLength && passwordPolicy.minimumPasswordLength < 10) { - helpers.addResult(results, 2, 'Minimum password length of: ' + passwordPolicy.minimumPasswordLength + ' is less than the recommded 14 characters', 'global', authenticationPolicy.data.compartmentId); + helpers.addResult(results, 2, 'Minimum password length of: ' + passwordPolicy.minimumPasswordLength + ' is less than the recommended 14 characters', 'global', authenticationPolicy.data.compartmentId); } else if (passwordPolicy && passwordPolicy.minimumPasswordLength && passwordPolicy.minimumPasswordLength < 14) { diff --git a/plugins/oracle/identity/minPasswordLength.spec.js b/plugins/oracle/identity/minPasswordLength.spec.js new file mode 100644 index 000000000..459f0a199 --- /dev/null +++ b/plugins/oracle/identity/minPasswordLength.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var plugin = require('./minPasswordLength'); + +const authenticationPolicy = [ + { + "passwordPolicy": { + "minimumPasswordLength": 15, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 12, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 8, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + authenticationPolicy: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('minPasswordLength', function () { + describe('run', function () { + it('should give unknown result if unable to query for password policy status', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for password policy status') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no password policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No password policies found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if password policy does not require a minimum password length', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Password policy does not require a minimum password length') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[3]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Minimum password length is suitable', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('is suitable') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warn result if Minimum password length is less than the recommended 14 characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('is less than the recommended 14 characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/notificationTopicSubscription.js b/plugins/oracle/identity/notificationTopicSubscription.js new file mode 100644 index 000000000..711f9a3df --- /dev/null +++ b/plugins/oracle/identity/notificationTopicSubscription.js @@ -0,0 +1,79 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Notification Topic With Active Subscription', + category: 'Identity', + domain: 'Logging and Monitoring', + severity: 'Medium', + description: 'Ensure that there is at least one notification topic and subscription to receive monitoring alerts.', + more_info: 'Creating and subscribing to one or more notification topics allows administrators to be notified of any changes in the Oracle Cloud Infrastructure.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Notification/Tasks/managingtopicsandsubscriptions.htm', + recommended_action: 'Create at least one notification topic with an active subscription.', + apis: ['topics:list','subscriptions:list'], + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.subscriptions, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var topics = helpers.addSource(cache, source, + ['topics', 'list', region]); + + if (!topics) return rcb(); + + if (topics.err || !topics.data) { + helpers.addResult(results, 3, + 'Unable to query for topics: ' + helpers.addError(topics), region); + return rcb(); + } + if (!topics.data.length) { + helpers.addResult(results, 2, 'No topics found', region); + return rcb(); + } + + const activeTopic = topics.data.find(topic => + topic.lifecycleState && topic.lifecycleState === 'ACTIVE' + ); + + if (!activeTopic) { + helpers.addResult(results, 2, + 'No active topics found in the region', region); + return rcb(); + } + + var subscriptions = helpers.addSource(cache, source, + ['subscriptions', 'list', region]); + + + if (!subscriptions || subscriptions.err || !subscriptions.data) { + helpers.addResult(results, 3, + 'Unable to query for subscriptions: ' + helpers.addError(subscriptions), region); + return rcb(); + } + if (!subscriptions.data.length) { + helpers.addResult(results, 2, 'No subscriptions found in the region', region); + return rcb(); + } + + const activeSubscription = subscriptions.data.find(subscription => + subscription.lifecycleState && subscription.lifecycleState === 'ACTIVE' + ); + + if (activeSubscription) { + helpers.addResult(results, 0, + 'There is at least one notification topic with an active subscription', region); + } else { + helpers.addResult(results, 2, + 'No notification topics with active subscriptions found', region); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/oracle/identity/notificationTopicSubscription.spec.js b/plugins/oracle/identity/notificationTopicSubscription.spec.js new file mode 100644 index 000000000..38d943223 --- /dev/null +++ b/plugins/oracle/identity/notificationTopicSubscription.spec.js @@ -0,0 +1,228 @@ +var expect = require('chai').expect; +var plugin = require('./notificationTopicSubscription'); + +const topics = [{ + name: 'topic-1', + topicId: 'topic-1', + shortTopicId: null, + compartmentId: 'compartment-1', + lifecycleState: 'ACTIVE', + description: null, + timeCreated: '2022-06-26T00:56:58.950Z', + }, + { + name: 'topic-2', + topicId: 'topic-1', + shortTopicId: null, + compartmentId: 'compartment-1', + lifecycleState: 'DELETED', + description: null, + timeCreated: '2022-06-26T00:56:58.950Z', + } +]; +const subscriptions = [ + { + id: 'sub-1', + topicId: 'topic-1', + protocol: 'EMAIL', + endpoint: 'myemail@gmail.com', + lifecycleState: 'PENDING', + compartmentId: 'compartment-1', + createdTime: 1656205041117, + }, + { + id: 'sub-2', + topicId: 'topic-1', + protocol: 'EMAIL', + endpoint: 'myemail@gmail.com', + lifecycleState: 'ACTIVE', + compartmentId: 'compartment-1', + createdTime: 1656205041117, + } +]; + +const createCache = (err, subscriptions, topicsErr, topics) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + subscriptions: { + list: { + 'us-ashburn-1': { + err: err, + data: subscriptions + } + } + }, + topics: { + list: { + 'us-ashburn-1': { + err: topicsErr, + data: topics + } + } + } + } +}; + +describe('notificationTopicSubscription', function () { + describe('run', function () { + it('should give unknown result if unable to query for topics', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for topics') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + null, + ['err'], + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no topics found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No topics found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + null, + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no active topics found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No active topics found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + null, + null, + [topics[1]] + ); + + plugin.run(cache, {}, callback); + }) + it('should give unknown result if unable to query for subscriptions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for subscriptions') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + undefined, + null, + topics + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no subscriptions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No subscriptions found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [], + null, + topics + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no active subscriptions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No notification topics with active subscriptions found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [subscriptions[0]], + null, + topics + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give passing result if there is one topic with active subscription', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('There is at least one') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [subscriptions[1]], + null, + topics + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/passwordRequiresLowercase.js b/plugins/oracle/identity/passwordRequiresLowercase.js index b96bab47c..daba13e46 100644 --- a/plugins/oracle/identity/passwordRequiresLowercase.js +++ b/plugins/oracle/identity/passwordRequiresLowercase.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Password Requires Lowercase', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one lowercase letter.', more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/managingpasswordrules.htm', @@ -16,17 +17,9 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var defaultRegion = ''; - - if (cache.authenticationPolicy && - cache.authenticationPolicy.get && - Object.keys(cache.authenticationPolicy.get).length && - Object.keys(cache.authenticationPolicy.get).length > 0) { - defaultRegion = helpers.objectFirstKey(cache.authenticationPolicy.get); - } else { - return callback(null, results, source); - } + var defaultRegion = helpers.objectFirstKey(cache['regionSubscription']['list']); + var authenticationPolicy = helpers.addSource(cache, source, ['authenticationPolicy', 'get', defaultRegion]); diff --git a/plugins/oracle/identity/passwordRequiresLowercase.spec.js b/plugins/oracle/identity/passwordRequiresLowercase.spec.js new file mode 100644 index 000000000..3b9accf07 --- /dev/null +++ b/plugins/oracle/identity/passwordRequiresLowercase.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var plugin = require('./passwordRequiresLowercase'); + +const authenticationPolicy = [ + { + "passwordPolicy": { + "minimumPasswordLength": 15, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 12, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": false, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + authenticationPolicy: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('passwordRequiresLowercase', function () { + describe('run', function () { + it('should give unknown result if unable to query for password policy status', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for password policy status') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no password policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No password policies found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if password policy requires lowercase characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Password policy requires lowercase characters') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warn result if password policy does not require lowercase characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('Password policy does not require lowercase characters') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/passwordRequiresNumbers.js b/plugins/oracle/identity/passwordRequiresNumbers.js index 8a2cae06a..4d6fd1a40 100644 --- a/plugins/oracle/identity/passwordRequiresNumbers.js +++ b/plugins/oracle/identity/passwordRequiresNumbers.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Password Requires Numbers', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one number.', more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/managingpasswordrules.htm', @@ -16,16 +17,8 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var defaultRegion = ''; - - if (cache.authenticationPolicy && - cache.authenticationPolicy.get && - Object.keys(cache.authenticationPolicy.get).length && - Object.keys(cache.authenticationPolicy.get).length > 0) { - defaultRegion = helpers.objectFirstKey(cache.authenticationPolicy.get); - } else { - return callback(null, results, source); - } + + var defaultRegion = helpers.objectFirstKey(cache['regionSubscription']['list']); var authenticationPolicy = helpers.addSource(cache, source, ['authenticationPolicy', 'get', defaultRegion]); diff --git a/plugins/oracle/identity/passwordRequiresNumbers.spec.js b/plugins/oracle/identity/passwordRequiresNumbers.spec.js new file mode 100644 index 000000000..ea55f2945 --- /dev/null +++ b/plugins/oracle/identity/passwordRequiresNumbers.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var plugin = require('./passwordRequiresNumbers'); + +const authenticationPolicy = [ + { + "passwordPolicy": { + "minimumPasswordLength": 15, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 12, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": false, + "isNumericCharactersRequired": false, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + authenticationPolicy: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('passwordRequiresNumbers', function () { + describe('run', function () { + it('should give unknown result if unable to query for password policy status', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for password policy status') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no password policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No password policies found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if password policy requires numeric characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Password policy requires numeric characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warn result if password policy does not require numeric characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('Password policy does not require numeric characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/passwordRequiresSymbols.js b/plugins/oracle/identity/passwordRequiresSymbols.js index fff741bd4..524b18fae 100644 --- a/plugins/oracle/identity/passwordRequiresSymbols.js +++ b/plugins/oracle/identity/passwordRequiresSymbols.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Password Requires Symbols', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one symbol.', more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/managingpasswordrules.htm', @@ -16,16 +17,8 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var defaultRegion = ''; - - if (cache.authenticationPolicy && - cache.authenticationPolicy.get && - Object.keys(cache.authenticationPolicy.get).length && - Object.keys(cache.authenticationPolicy.get).length > 0) { - defaultRegion = helpers.objectFirstKey(cache.authenticationPolicy.get); - } else { - return callback(null, results, source); - } + + var defaultRegion = helpers.objectFirstKey(cache['regionSubscription']['list']); var authenticationPolicy = helpers.addSource(cache, source, ['authenticationPolicy', 'get', defaultRegion]); diff --git a/plugins/oracle/identity/passwordRequiresSymbols.spec.js b/plugins/oracle/identity/passwordRequiresSymbols.spec.js new file mode 100644 index 000000000..2cd9e7426 --- /dev/null +++ b/plugins/oracle/identity/passwordRequiresSymbols.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var plugin = require('./passwordRequiresSymbols'); + +const authenticationPolicy = [ + { + "passwordPolicy": { + "minimumPasswordLength": 15, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 12, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": false, + "isNumericCharactersRequired": false, + "isSpecialCharactersRequired": false, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + authenticationPolicy: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('passwordRequiresSymbols', function () { + describe('run', function () { + it('should give unknown result if unable to query for password policy status', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for password policy status') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no password policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No password policies found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if password policy requires special characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Password policy requires special characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warn result if password policy does not require special characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('Password policy does not require special characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/passwordRequiresUppercase.js b/plugins/oracle/identity/passwordRequiresUppercase.js index f80f14a50..40193c56c 100644 --- a/plugins/oracle/identity/passwordRequiresUppercase.js +++ b/plugins/oracle/identity/passwordRequiresUppercase.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Password Requires Uppercase', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', description: 'Ensures password policy requires at least one uppercase character.', more_info: 'A strong password policy enforces minimum length, expiration, reuse, and symbol usage.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/managingpasswordrules.htm', @@ -16,16 +17,8 @@ module.exports = { run: function(cache, settings, callback) { var results = []; var source = {}; - var defaultRegion = ''; - - if (cache.authenticationPolicy && - cache.authenticationPolicy.get && - Object.keys(cache.authenticationPolicy.get).length && - Object.keys(cache.authenticationPolicy.get).length > 0) { - defaultRegion = helpers.objectFirstKey(cache.authenticationPolicy.get); - } else { - return callback(null, results, source); - } + + var defaultRegion = helpers.objectFirstKey(cache['regionSubscription']['list']); var authenticationPolicy = helpers.addSource(cache, source, ['authenticationPolicy', 'get', defaultRegion]); diff --git a/plugins/oracle/identity/passwordRequiresUppercase.spec.js b/plugins/oracle/identity/passwordRequiresUppercase.spec.js new file mode 100644 index 000000000..d712234c2 --- /dev/null +++ b/plugins/oracle/identity/passwordRequiresUppercase.spec.js @@ -0,0 +1,138 @@ +var expect = require('chai').expect; +var plugin = require('./passwordRequiresUppercase'); + +const authenticationPolicy = [ + { + "passwordPolicy": { + "minimumPasswordLength": 15, + "isUppercaseCharactersRequired": true, + "isLowercaseCharactersRequired": true, + "isNumericCharactersRequired": true, + "isSpecialCharactersRequired": true, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + }, + { + "passwordPolicy": { + "minimumPasswordLength": 12, + "isUppercaseCharactersRequired": false, + "isLowercaseCharactersRequired": false, + "isNumericCharactersRequired": false, + "isSpecialCharactersRequired": false, + "isUsernameContainmentAllowed": false, + "isPasswordResetEnabled": true + } + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + authenticationPolicy: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('passwordRequiresUppercase', function () { + describe('run', function () { + it('should give unknown result if unable to query for password policy status', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for password policy status') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no password policies found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No password policies found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if password policy requires uppercase characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Password policy requires uppercase characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[0]] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give warn result if password policy does not require uppercase characters', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(1) + expect(results[0].message).to.include('Password policy does not require uppercase characters') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [authenticationPolicy[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/policyLeastPrivilege.js b/plugins/oracle/identity/policyLeastPrivilege.js index c88b85d04..fca0c77b0 100644 --- a/plugins/oracle/identity/policyLeastPrivilege.js +++ b/plugins/oracle/identity/policyLeastPrivilege.js @@ -1,10 +1,11 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Policy Least Privilege', category: 'Identity', - description: 'Ensure only service-level admins have blanket statements to manage or use resources without restriction.', + domain: 'Identity and Access Management', + severity: 'High', + description: 'Ensure only Compartment/Tenancy admins have blanket statements to manage or use resources without restriction.', more_info: 'Adding service-level admins to Oracle policies instead of blanket statements mitigates unintended access to resources by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/iam_security.htm', recommended_action: 'When writing policies, avoid blanket statements, and instead give full permissions only to Service-level admins, all other groups should have least access to services.', @@ -19,9 +20,9 @@ module.exports = { }, settings: { policy_group_admins: { - name: 'Service-level Admins.', - description: 'Comma separated list of all service-level admins to ignore for this plugin.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + name: 'Global Admins.', + description: 'Comma separated list of all admins with permissions to use all resources to ignore for this plugin.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -29,91 +30,52 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default }; - - async.each(regions.default, function (region, rcb) { - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); - - if (!policies) return rcb(); - - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } - - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); + var region = helpers.objectFirstKey(cache['regionSubscription']['list']) + + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); + + if (!policies) return callback(null, results, source); + + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } + + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; + var resourceTypes = ['all-resources']; + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins, ['manage', 'use']); + + if (!statementPasses) { + policyProtection = false; + + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to ${statementObj['verb']} all resources in ${statementObj['location']}`, region, policy.id); + } + }); } - var policyProtection = true; - var entered = false; - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; - policy.statements.forEach(statement => { - - const statementLower = statement.toLowerCase(); - - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - ((statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1) || - (statementLower.indexOf('request.operation') === -1 && - statementLower.indexOf('!=') === -1))) { - - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - var severity = 2; - - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var resourceType = statementArr[4]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - var verb = statementArr[3]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var resourceType = statementArr[5]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = statementArr[1]; - var verb = statementArr[4]; - } - - var adminsArr = config.policy_group_admins.toLowerCase().replace(' ', '').split(','); - - if (adminsArr.indexOf(groupName) > -1) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'User'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } - - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to ${verb} ${resourceType} in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - } - }); + }); - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies follow least access.', region); - } + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies follow least access.', region); + } - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); - }); + callback(null, results, source); } -}; +}; \ No newline at end of file diff --git a/plugins/oracle/identity/policyLeastPrivilege.spec.js b/plugins/oracle/identity/policyLeastPrivilege.spec.js index 8983a77c3..f55f2a650 100644 --- a/plugins/oracle/identity/policyLeastPrivilege.spec.js +++ b/plugins/oracle/identity/policyLeastPrivilege.spec.js @@ -170,7 +170,7 @@ describe('policyLeastPrivilege', function () { [ { "statements": [ - "Allow group Developers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'", + "Allow group Developers to read file-systems in tenancy where request.permission != 'FILE_SYSTEM_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/identity/userAPIKeysRotated.js b/plugins/oracle/identity/userAPIKeysRotated.js new file mode 100644 index 000000000..8e2da7a21 --- /dev/null +++ b/plugins/oracle/identity/userAPIKeysRotated.js @@ -0,0 +1,71 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'User API Keys Rotated', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that user API keys are rotated regularly in order to reduce accidental exposures.', + more_info: 'User API keys should be rotated frequently to avoid having them accidentally exposed.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm', + recommended_action: 'Rotate user API keys after regular intervals', + apis: ['user:list', 'apiKey:list'], + settings: { + api_keys_rotated_fail: { + name: 'Auth Tokens Rotated Fail', + description: 'Return a failing result when API keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var config = { + api_keys_rotated_fail: parseInt(settings.api_keys_rotated_fail || this.settings.api_keys_rotated_fail.default) + } + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + var apiKeys = helpers.addSource(cache, source, + ['apiKey', 'list', region]); + + if (!users || !apiKeys) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(users), 'global'); + return callback(null, results, source); + } + + if (apiKeys.err || !apiKeys.data) { + helpers.addResult(results, 3, + 'Unable to query user API keys: ' + helpers.addError(apiKeys), 'global'); + return callback(null, results, source); + } + + if (!apiKeys.data.length) { + helpers.addResult(results, 0, 'No user API keys found', 'global'); + return callback(null, results, source); + } + + for (let key of apiKeys.data) { + if (!key.keyId) continue; + + let timeCreated = key.timeCreated ? key.timeCreated : new Date(); + let difference = helpers.daysBetween(timeCreated, new Date()); + + if (difference > config.api_keys_rotated_fail) { + helpers.addResult(results, 2, `API key is ${difference} days old`, 'global', key.id); + } else { + helpers.addResult(results, 0, `API key is ${difference} days old`, 'global', key.id); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/userAPIKeysRotated.spec.js b/plugins/oracle/identity/userAPIKeysRotated.spec.js new file mode 100644 index 000000000..ffac6646e --- /dev/null +++ b/plugins/oracle/identity/userAPIKeysRotated.spec.js @@ -0,0 +1,186 @@ +var expect = require('chai').expect; +var plugin = require('./userAPIKeysRotated'); + +const apiKeys = [ + { + "timeCreated": new Date(), + "userId": "ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa", + "keyValue": "", + "keyId": "ocid1.tenancy.oc1..aaaaaaaaip36ginmudtormvwsv45imsyctt3vhme6rnemweaxe4b6q2gcodq/ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa/65:ab:7d:b9:54:a8:7e:c7:42:cb:5a:87:2f:ed:08:d3", + "lifecycleState": "ACTIVE", + "users": "ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa" + }, + { + "timeCreated": "2021-01-09T12:34:21.976Z", + "userId": "ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa", + "keyValue": "", + "keyId": "ocid1.tenancy.oc1..aaaaaaaaip36ginmudtormvwsv45imsyctt3vhme6rnemweaxe4b6q2gcodq/ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa/65:ab:7d:b9:54:a8:7e:c7:42:cb:5a:87:2f:ed:08:d3", + "lifecycleState": "ACTIVE", + "users": "ocid1.user.oc1..aaaaaaaaddzcrvo7kdxa" + } +]; + +const createCache = (apiData, userErr, apiErr) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: userErr, + data: [ + { + "capabilities": { + "canUseConsolePassword": true, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "email": "test1@khulnasoft.com", + "emailVerified": false, + "identityProviderId": null, + "externalIdentifier": null, + "timeModified": "2022-01-04T17:00:10.630Z", + "isMfaActivated": false, + "id": "ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "test1@khulnasoft.com", + "description": "admin", + "timeCreated": "2022-01-04T16:59:57.900Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "tomer.daniel@khulnasoft.com", + "CreatedOn": "2022-01-04T16:59:57.803Z" + } + }, + "lifecycleState": "ACTIVE" + }, + ] + } + } + }, + apiKey: { + list: { + 'us-ashburn-1': { + data: apiData, + err: apiErr + } + } + } + } +}; + +describe('userAPIKeysRotated', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [], + { error: 'error' } + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result API keys has not been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('API key is') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [apiKeys[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result API keys has been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('API key is 0 days old') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [apiKeys[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user API keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user API keys found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query user API keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query user API keys') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + null, + { err: 'err' } + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/userAuthTokenRotated.js b/plugins/oracle/identity/userAuthTokenRotated.js new file mode 100644 index 000000000..fd5b95690 --- /dev/null +++ b/plugins/oracle/identity/userAuthTokenRotated.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'User Auth Token Rotated', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that user auth tokens are rotated regularly in order to reduce accidental exposures.', + more_info: 'User auth tokens should be rotated frequently to avoid having them accidentally exposed.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm', + recommended_action: 'Rotate user auth tokens after regular intervals', + apis: ['user:list', 'authToken:list'], + settings: { + auth_tokens_rotated_fail: { + name: 'Auth Tokens Rotated Fail', + description: 'Return a failing result when auth tokens exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var config = { + auth_tokens_rotated_fail: parseInt(settings.auth_tokens_rotated_fail || this.settings.auth_tokens_rotated_fail.default) + } + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + var authTokens = helpers.addSource(cache, source, + ['authToken', 'list', region]); + + if (!users || !authTokens) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(users), 'global'); + return callback(null, results, source); + } + + if (!users.data.length) { + helpers.addResult(results, 0, 'No user accounts found', 'global'); + return callback(null, results, source); + } + + if (authTokens.err || !authTokens.data) { + helpers.addResult(results, 3, + 'Unable to query user auth tokens: ' + helpers.addError(authTokens), 'global'); + return callback(null, results, source); + } + + if (!authTokens.data.length) { + helpers.addResult(results, 0, 'No user auth tokens found', 'global'); + return callback(null, results, source); + } + + for (let token of authTokens.data) { + if (!token.id) continue; + + let timeCreated = token.timeCreated ? token.timeCreated : new Date(); + let difference = helpers.daysBetween(timeCreated, new Date()); + + if (difference > config.auth_tokens_rotated_fail) { + helpers.addResult(results, 2, `Auth token is ${difference} days old`, 'global', token.id); + } else { + helpers.addResult(results, 0, `Auth token is ${difference} days old`, 'global', token.id); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/userAuthTokenRotated.spec.js b/plugins/oracle/identity/userAuthTokenRotated.spec.js new file mode 100644 index 000000000..aa09e1247 --- /dev/null +++ b/plugins/oracle/identity/userAuthTokenRotated.spec.js @@ -0,0 +1,188 @@ +var expect = require('chai').expect; +var plugin = require('./userAuthTokenRotated'); + +const authTokens = [ + { + "id": "ocid1.credential.oc1..aaaaaaaa4f57ig72lh5gu6hicy5d5nl7wzb6q", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaip3imsyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "For CIS", + "timeCreated": new Date(), + "userId": "ocid1.user.oc1..aaaaaaaaddzaosqxqgeryy43emaptcnzzcrekfkxmha6u5cyvqjkmvo7kdxa", + "lifecycleState": "ACTIVE", + "users": "ocid1.user.oc1..aaaaaaaaddzaosqxqgeryy43emaptcnzzcrekfkxmha6u5cyvqjkmvo7kdxa" + }, + { + "id": "ocid1.credential.oc1..aaaaaaaa4f57ig72lh5gu6hicy5d5nl7wzb6q", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaip3imsyctt3vhme6rnemweaxe4b6q2gcodq", + "description": "For CIS", + "timeCreated": "2021-01-09T14:53:08.201Z", + "userId": "ocid1.user.oc1..aaaaaaaaddzaosqrekfkxmha6u5cyvqjkmvo7kdxa", + "lifecycleState": "ACTIVE", + "users": "ocid1.user.oc1..aaaaaaaaddzaosqrekfkxmha6u5cyvqjkmvo7kdxa" + } +]; + +const createCache = (authData, userErr, authErr) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: userErr, + data: [ + { + "capabilities": { + "canUseConsolePassword": true, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "email": "test1@khulnasoft.com", + "emailVerified": false, + "identityProviderId": null, + "externalIdentifier": null, + "timeModified": "2022-01-04T17:00:10.630Z", + "isMfaActivated": false, + "id": "ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "test1@khulnasoft.com", + "description": "admin", + "timeCreated": "2022-01-04T16:59:57.900Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "tomer.daniel@khulnasoft.com", + "CreatedOn": "2022-01-04T16:59:57.803Z" + } + }, + "lifecycleState": "ACTIVE" + }, + ] + } + } + }, + authToken: { + list: { + 'us-ashburn-1': { + data: authData, + err: authErr + } + } + } + } +}; + +describe('userAuthTokenRotated', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [], + { error: 'error' } + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result Auth token has not been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Auth token is') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [authTokens[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result Auth token has been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Auth token is 0 days old') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [authTokens[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user auth tokens found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user auth tokens found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query user auth tokens', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query user auth tokens') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + null, + { err: 'err' } + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/userChanges.js b/plugins/oracle/identity/userChanges.js new file mode 100644 index 000000000..678b28096 --- /dev/null +++ b/plugins/oracle/identity/userChanges.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'User Changes', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure an event rule is configured for User changes.', + more_info: 'Monitoring changes to users and their states and capabilities will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for changes to users and their states and capabilities.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.identitycontrolplane.createuser' }, + { displayName: 'Update', value: 'com.oraclecloud.identitycontrolplane.updateuser' }, + { displayName: 'Delete', value: 'com.oraclecloud.identitycontrolplane.deleteuser' }, + { displayName: 'Update User State', value: 'com.oraclecloud.identitycontrolplane.updateuserstate' }, + { displayName: 'Update User Capabilities', value: 'com.oraclecloud.identitycontrolplane.updateusercapabilities' } + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'User', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/identity/userChanges.spec.js b/plugins/oracle/identity/userChanges.spec.js new file mode 100644 index 000000000..f47f69e3e --- /dev/null +++ b/plugins/oracle/identity/userChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./userChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createuser","com.oraclecloud.identitycontrolplane.updateuser","com.oraclecloud.identitycontrolplane.deleteuser", "com.oraclecloud.identitycontrolplane.updateuserstate", "com.oraclecloud.identitycontrolplane.updateusercapabilities"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.identitycontrolplane.createuser","com.oraclecloud.identitycontrolplane.updateuser","com.oraclecloud.identitycontrolplane.deleteuser"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('userChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no user rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some user changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all user changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/userCustomerSecretKeysRotated.js b/plugins/oracle/identity/userCustomerSecretKeysRotated.js new file mode 100644 index 000000000..a83b3d940 --- /dev/null +++ b/plugins/oracle/identity/userCustomerSecretKeysRotated.js @@ -0,0 +1,76 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'User Customer Secret Keys Rotated', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure that user customer secret keys are rotated regularly in order to reduce accidental exposures.', + more_info: 'User customer secret keys should be rotated frequently to avoid having them accidentally exposed.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm', + recommended_action: 'Rotate user customer secret keys', + apis: ['user:list', 'customerSecretKey:list'], + settings: { + customer_secret_key_rotated_fail: { + name: 'Customer Secret Keys Rotated Fail', + description: 'Return a failing result when customer secret keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var config = { + customer_secret_key_rotated_fail: parseInt(settings.customer_secret_key_rotated_fail || this.settings.customer_secret_key_rotated_fail.default) + } + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + var customerSecretKeys = helpers.addSource(cache, source, + ['customerSecretKey', 'list', region]); + + if (!users || !customerSecretKeys) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(users), 'global'); + return callback(null, results, source); + } + + if (!users.data.length) { + helpers.addResult(results, 0, 'No user accounts found', 'global'); + return callback(null, results, source); + } + + if (customerSecretKeys.err || !customerSecretKeys.data) { + helpers.addResult(results, 3, + 'Unable to query user customer secret keys: ' + helpers.addError(customerSecretKeys), 'global'); + return callback(null, results, source); + } + + if (!customerSecretKeys.data.length) { + helpers.addResult(results, 0, 'No user customer secret keys found', 'global'); + return callback(null, results, source); + } + + for (let csk of customerSecretKeys.data) { + if (!csk.id) continue; + + let timeCreated = csk.timeCreated ? csk.timeCreated : new Date(); + let difference = helpers.daysBetween(timeCreated, new Date()); + + if (difference > config.customer_secret_key_rotated_fail) { + helpers.addResult(results, 2, `Customer secret key is ${difference} days old`, 'global', csk.id); + } else { + helpers.addResult(results, 0, `Customer secret key is ${difference} days old`, 'global', csk.id); + } + } + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/userCustomerSecretKeysRotated.spec.js b/plugins/oracle/identity/userCustomerSecretKeysRotated.spec.js new file mode 100644 index 000000000..769831fde --- /dev/null +++ b/plugins/oracle/identity/userCustomerSecretKeysRotated.spec.js @@ -0,0 +1,190 @@ +var expect = require('chai').expect; +var plugin = require('./userCustomerSecretKeysRotated'); + +const customerSecretKeys = [ + { + "id": "5f60514e3d78e8e14", + "userId": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "displayName": "For CIS", + "timeCreated": new Date(), + "lifecycleState": "ACTIVE", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaip36ginmudtormvwsv45imsyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "5f60514e3d78e8e14", + "users": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa" + }, + { + "id": "5f60514e3d78e8e14", + "userId": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa", + "displayName": "For CIS", + "timeCreated": "2021-01-09T17:42:32.413Z", + "lifecycleState": "ACTIVE", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaip36ginmudtormvwsv45imsyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "5f60514e3d78e8e14", + "users": "ocid1.user.oc1..aaaaaaaarekfkxmha6u5cyvqjkmvo7kdxa" + } +]; + +const createCache = (cskData, userErr, cskErr) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: userErr, + data: [ + { + "capabilities": { + "canUseConsolePassword": true, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "email": "test1@khulnasoft.com", + "emailVerified": false, + "identityProviderId": null, + "externalIdentifier": null, + "timeModified": "2022-01-04T17:00:10.630Z", + "isMfaActivated": false, + "id": "ocid1.user.oc1..aaadnfbtjs35pen2qr3b3tzqmq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaasyctt3vhme6rnemweaxe4b6q2gcodq", + "name": "test1@khulnasoft.com", + "description": "admin", + "timeCreated": "2022-01-04T16:59:57.900Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "tomer.daniel@khulnasoft.com", + "CreatedOn": "2022-01-04T16:59:57.803Z" + } + }, + "lifecycleState": "ACTIVE" + }, + ] + } + } + }, + customerSecretKey: { + list: { + 'us-ashburn-1': { + data: cskData, + err: cskErr + } + } + } + } +}; + +describe('userCustomerSecretKeysRotated', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [], + { error: 'error' } + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result customer secret key has not been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Customer secret key is') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [customerSecretKeys[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result customer secret key has been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Customer secret key is 0 days old') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [customerSecretKeys[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user customer secret keys found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user customer secret keys found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query user customer secret keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query user customer secret keys') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + null, + { err: 'err' } + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/usersEmailVerified.js b/plugins/oracle/identity/usersEmailVerified.js new file mode 100644 index 000000000..f21029b6a --- /dev/null +++ b/plugins/oracle/identity/usersEmailVerified.js @@ -0,0 +1,51 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Users Email Verified', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensure all IAM user accounts have a valid and current email address.', + more_info: 'To Have a valid email address associated with an OCI IAM local user account enables you to tie the account to identity in your organization ' + + 'as well as allows that user to reset their password if it is forgotten or lost.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingusers.htm', + recommended_action: 'Modify IAM users to add their email addresses', + apis: ['user:list'], + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + if (!users) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for users: ' + helpers.addError(users)); + return callback(null, results, source); + } + + if (users.data.length < 2) { + helpers.addResult(results, 0, 'No user accounts found'); + return callback(null, results, source); + } + + users.data.forEach(user => { + if (!user.id || !user.name) return; + + if (user.email && user.email.length && user.emailVerified) { + helpers.addResult(results, 0, `Email for user ${user.name} is verified`, region, user.id); + } else if (!user.email || !user.email.length) { + helpers.addResult(results, 2, `Email for user ${user.name} not found`, region, user.id); + } else { + helpers.addResult(results, 2, `Email for user ${user.name} is not verified`, region, user.id); + } + }); + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/usersEmailVerified.spec.js b/plugins/oracle/identity/usersEmailVerified.spec.js new file mode 100644 index 000000000..c16f5b0f1 --- /dev/null +++ b/plugins/oracle/identity/usersEmailVerified.spec.js @@ -0,0 +1,181 @@ +var expect = require('chai').expect; +var plugin = require('./usersEmailVerified'); + +const user = [ + { + "description": "login user", + "email": "user1@gmail.com", + "emailVerified": true, + "external-identifier": null, + "freeform-tags": {}, + "identity-provider-id": null, + "inactive-status": null, + "is-mfa-activated": false, + "last-successful-login-time": "2021-02-22T17:20:19.791000+00:00", + "lifecycle-state": "ACTIVE", + "name": "user1", + "id": "111", + "previous-successful-login-time": null, + "time-created": "2021-02-16T18:05:07.150000+00:00" + }, + { + "description": "login user", + "email": "user3@gmail.com", + "emailVerified": false, + "external-identifier": null, + "freeform-tags": {}, + "identity-provider-id": null, + "inactive-status": null, + "is-mfa-activated": false, + "id": "111", + "last-successful-login-time": "2021-02-22T17:20:19.791000+00:00", + "lifecycle-state": "ACTIVE", + "name": "user3", + "previous-successful-login-time": null, + "time-created": "2021-02-16T18:05:07.150000+00:00" + }, + { + "defined-tags": {}, + "description": "login user", + "email": "", + "external-identifier": null, + "freeform-tags": {}, + "identity-provider-id": null, + "inactive-status": null, + "is-mfa-activated": false, + "last-successful-login-time": "2021-02-25T15:53:51.093000+00:00", + "lifecycle-state": "ACTIVE", + "id": "111", + "name": "user2", + "previous-successful-login-time": null, + "time-created": "2021-02-16T17:55:53.412000+00:00" + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('usersEmailVerified', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for users') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user accounts found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user accounts found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if user does not have an email', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[2].status).to.equal(2) + expect(results[2].message).to.include('not found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if user email is not verified', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('not verified') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if user email is verified', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('is verified') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/usersMfaEnabled.js b/plugins/oracle/identity/usersMfaEnabled.js index 9c51f6ccc..85eef6b3a 100644 --- a/plugins/oracle/identity/usersMfaEnabled.js +++ b/plugins/oracle/identity/usersMfaEnabled.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Users MFA Enabled', category: 'Identity', + domain: 'Identity and Access Management', + severity: 'High', description: 'Ensures a multi-factor authentication device is enabled for all users within the account.', more_info: 'User accounts should have an MFA device setup to enable two-factor authentication.', link: 'https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/usingmfa.htm', @@ -17,41 +18,51 @@ module.exports = { pci: 'PCI requires MFA for all access to cardholder environments. ' + 'Create an MFA key for user accounts.' }, + setting: { + warn_federated_users: { + name: 'Warn Federated Users', + description: 'Give a WARN instead of FAIL result for federated users', + regex: '^(true|false)$', + default: 'false' + } + }, run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); - var noMFAUsers = []; + var config = { + warn_federated_users: settings.warn_federated_users || this.setting.warn_federated_users.default + }; + var warnFedUsers = (config.warn_federated_users == 'true'); + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); - async.each(regions.user, function (region, rcb) { var users = helpers.addSource(cache, source, ['user', 'list', region]); - if (!users) return rcb(); + if (!users) return callback(null, results, source); if (users.err || !users.data) { helpers.addResult(results, 3, 'Unable to query for user MFA status: ' + helpers.addError(users)); - return rcb(); + return callback(null, results, source); } if (users.data.length < 2) { helpers.addResult(results, 0, 'No user accounts found'); - return rcb(); + return callback(null, results, source); } users.data.forEach(user => { if (user.isMfaActivated) { helpers.addResult(results, 0, 'The user has MFA enabled', 'global', user.id); } else { - helpers.addResult(results, 2, 'The user has MFA disabled', 'global', user.id); + if (user.identityProviderId && user.identityProviderId.length && warnFedUsers) { + helpers.addResult(results, 1, 'The federated user has MFA disabled', 'global', user.id); + } else helpers.addResult(results, 2, 'The user has MFA disabled', 'global', user.id); } }); - rcb(); - }, function () { - // Global checking goes here + callback(null, results, source); - }); } }; diff --git a/plugins/oracle/identity/usersMfaEnabled.spec.js b/plugins/oracle/identity/usersMfaEnabled.spec.js new file mode 100644 index 000000000..3b368ce7c --- /dev/null +++ b/plugins/oracle/identity/usersMfaEnabled.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var plugin = require('./usersMfaEnabled'); + +const user = [ + { + "description": "login user", + "email": "user3@gmail.com", + "emailVerified": false, + "isMfaActivated": true, + "id": "111" + }, + { + "defined-tags": {}, + "description": "login user", + "email": "user1@gmail.com", + "isMfaActivated": false, + "id": "111" + }, + { + "capabilities": { + "canUseConsolePassword": false, + "canUseApiKeys": true, + "canUseAuthTokens": true, + "canUseSmtpCredentials": true, + "canUseCustomerSecretKeys": true, + "canUseOAuth2ClientCredentials": true, + "canUseDbCredentials": true + }, + "emailVerified": true, + "identityProviderId": "ocid1.saml2idp.oc1..aaaaaaaaknersalsctbatwefdwefdwdxwvnyxpdzlbs4vuuu7zxgjxqts6a", + "externalIdentifier": "3ec8cd96d83c49aebfc0fccaf8e92d03", + "timeModified": "2021-12-26T10:48:01.848Z", + "isMfaActivated": false, + "id": "ocid1.user.oc1..aaaaaaaayqmr4afeyuihjclv22j6265xacwedwefwfasdwedeqdifoq", + "compartmentId": "ocid1.tenancy.oc1..aaaaaaaaidksdowchziencs3c3zx4gmpw3weung65id5bdjyw24nbdqih4ya", + "name": "oracleidentitycloudservice/test@gmail.com", + "description": "test@gmail.com", + "timeCreated": "2021-12-26T10:48:00.940Z", + "freeformTags": {}, + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "scim-service", + "CreatedOn": "2021-12-26T10:48:00.914Z" + } + }, + "lifecycleState": "ACTIVE" + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('usersMfaEnabled', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if No user accounts found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user accounts found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if user has MFA disabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('The user has MFA disabled') + expect(results[1].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, {}, callback); + }) + + it('should give warning result if federated user has MFA disabled and warn_federated_users setting is enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[2].status).to.equal(1) + expect(results[2].message).to.include('The federated user has MFA disabled') + expect(results[2].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, { warn_federated_users: 'true' }, callback); + }) + + it('should give passing result if user has MFA enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('The user has MFA enabled') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + user + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/identity/usersPasswordLastUsed.js b/plugins/oracle/identity/usersPasswordLastUsed.js new file mode 100644 index 000000000..ca19c8fe9 --- /dev/null +++ b/plugins/oracle/identity/usersPasswordLastUsed.js @@ -0,0 +1,85 @@ +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Users Password Last Used', + category: 'Identity', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Detect users that have not logged in for a period of time and should be deactivated.', + more_info: 'Having multiple user accounts that have been inactive for a period of time can increase the risk of security attacks and breaches.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingusers.htm', + recommended_action: 'Delete old user accounts that allow password-based logins and have not been used recently.', + apis: ['user:list'], + compliance: { + pci: 'PCI requires that all user credentials are rotated every 90 days. If the user password has not been used in the last 90 days, the user should be deactivated.' + }, + settings: { + identity_users_password_last_used_fail: { + name: 'Users Password Last Used Fail', + description: 'Return a failing result when users with password logins exceed this number of days without being used', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '180' + } + }, + + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var config = { + identity_users_password_last_used_fail: parseInt(settings.identity_users_password_last_used_fail || this.settings.identity_users_password_last_used_fail.default) + }; + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var users = helpers.addSource(cache, source, + ['user', 'list', region]); + + if (!users) return callback(null, results, source); + + if (users.err || !users.data) { + helpers.addResult(results, 3, + 'Unable to query for user MFA status: ' + helpers.addError(users)); + return callback(null, results, source); + } + + if (users.data.length < 2) { + helpers.addResult(results, 0, 'No user accounts found'); + return callback(null, results, source); + } + + + var found = false; + + users.data.forEach(user => { + var returnCode, returnMsg, daysAgo; + + if (user.lastSuccessfulLoginTime) { + daysAgo = helpers.daysBetween(new Date(), new Date(user.lastSuccessfulLoginTime)); + returnMsg = `User's last successful login was ${daysAgo} days ago`; + } else if (user.timeCreated) { + daysAgo = helpers.daysBetween(new Date(), new Date(user.timeCreated)); + returnMsg = `User was created ${daysAgo} days ago but never logged in`; + } + + if (!isNaN(daysAgo)) { + + if (daysAgo > config.identity_users_password_last_used_fail) { + returnCode = 2; + } else { + returnCode = 0; + } + + helpers.addResult(results, returnCode, returnMsg, 'global', user.id); + + found = true; + } + }); + + if (!found) { + helpers.addResult(results, 0, 'No users with password logins found'); + } + + callback(null, results, source); + } +}; diff --git a/plugins/oracle/identity/usersPasswordLastUsed.spec.js b/plugins/oracle/identity/usersPasswordLastUsed.spec.js new file mode 100644 index 000000000..102bca5bf --- /dev/null +++ b/plugins/oracle/identity/usersPasswordLastUsed.spec.js @@ -0,0 +1,208 @@ +var expect = require('chai').expect; +var plugin = require('./usersPasswordLastUsed'); + +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 2); +var failDate = new Date(); +failDate.setMonth(failDate.getMonth() - 7); + +const user = [ + { + "description": "login user", + "email": "user3@gmail.com", + "emailVerified": false, + "isMfaActivated": true, + "id": "111", + "timeCreated": failDate, + "lastSuccessfulLoginTime": failDate, + }, + { + "description": "login user", + "email": "user3@gmail.com", + "emailVerified": false, + "isMfaActivated": true, + "id": "111", + "timeCreated": failDate, + }, + { + "email": "user2@gmail.com", + "emailVerified": true, + "name": "user2", + "description": "user2", + "timeCreated": passDate, + "lastSuccessfulLoginTime": passDate, + }, + { + "email": "user2@gmail.com", + "emailVerified": true, + "name": "user2", + "description": "user2", + "timeCreated": passDate, + }, + { + "description": "login user", + "email": "user3@gmail.com", + "emailVerified": false, + "isMfaActivated": true, + "id": "111", + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + user: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('usersPasswordLastUsed', function () { + describe('run', function () { + it('should give unknown result if unable to query for users', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for user') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + {err: 'error'}, + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no user accounts found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No user accounts found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no users with password logins found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No users with password logins found') + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[4], user[4]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should PASS if the user password was last used within the pass limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[2], user[3]] + ); + + plugin.run(cache, { identity_users_password_last_used_fail: 180 }, callback); + }) + + it('should PASS if the user was created within the pass limit but never used', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[3], user[2]] + ); + + plugin.run(cache, { identity_users_password_last_used_fail: 180 }, callback); + }) + + it('should FAIL if the user password was last used within the fail limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[0], user[1]] + ); + + plugin.run(cache, { identity_users_password_last_used_fail: 180 }, callback); + }) + + it('should FAIL if the user was created within the fail limit but never used', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].region).to.equal('global') + done() + }; + + const cache = createCache( + null, + [user[1], user[0]] + ); + + plugin.run(cache, { identity_users_password_last_used_fail: 180 }, callback); + }) + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/defaultSecurityList.js b/plugins/oracle/networking/defaultSecurityList.js index 22815475d..6a7c2fbb2 100644 --- a/plugins/oracle/networking/defaultSecurityList.js +++ b/plugins/oracle/networking/defaultSecurityList.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Default Security List', category: 'Networking', + domain: 'Network Access Control', + severity: 'Low', description: 'Ensure the default security lists block all traffic by default', more_info: 'The default security list is often used for resources launched without a defined security list. For this reason, the default rules should be to block all traffic to prevent an accidental exposure.', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securitylists.htm', diff --git a/plugins/oracle/networking/excessiveSecurityLists.js b/plugins/oracle/networking/excessiveSecurityLists.js index 330a136a1..587353f1c 100644 --- a/plugins/oracle/networking/excessiveSecurityLists.js +++ b/plugins/oracle/networking/excessiveSecurityLists.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Excessive Security Lists', category: 'Networking', + domain: 'Network Access Control', + severity: 'Low', description: 'Determine if there are an excessive number of security lists in the account', more_info: 'Keeping the number of security lists to a minimum helps reduce the attack surface of an account. Rather than creating new groups with the same rules for each project, common rules should be grouped under the same security lists. For example, instead of adding port 22 from a known IP to every group, create a single "SSH" security group which can be used on multiple instances.', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securitylists.htm', diff --git a/plugins/oracle/networking/flowLogsEnabled.js b/plugins/oracle/networking/flowLogsEnabled.js new file mode 100644 index 000000000..e305da198 --- /dev/null +++ b/plugins/oracle/networking/flowLogsEnabled.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Flow Logs Enabled', + category: 'Networking', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures VCN flow logs are enabled for traffic logging.', + more_info: 'VCN flow logs allow you to monitor traffic flowing within your virtual network and can help in detecting anomalous traffic.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/vcn_flow_logs.htm', + recommended_action: 'Enable VCN flow logs for each VCN subnet.', + apis: ['vcn:list', 'subnet:list', 'logGroup:list', 'log:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.subnet, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var subnets = helpers.addSource(cache, source, + ['subnet', 'list', region]); + + if (!subnets) return rcb(); + + if (subnets.err || !subnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(subnets), region); + return rcb(); + } + + if (!subnets.data.length) { + helpers.addResult(results, 0, 'No subnets found', region); + return rcb(); + } + + var logs = helpers.addSource(cache, source, + ['log', 'list', region]); + + subnets.data.forEach(subnet => { + let subnetFlowLog = null; + + if (logs && !logs.err && logs.data && logs.data.length) { + subnetFlowLog = logs.data.find(log => log.isEnabled && log.configuration + && Object.keys(log.configuration).length && log.configuration.source + && Object.keys(log.configuration.source).length && log.configuration.source.service === 'flowlogs' + && log.configuration.source.resource === subnet.id); + } + + if (subnetFlowLog) { + helpers.addResult(results, 0, 'The subnet has flow logs enabled', region, subnet.id); + } else { + helpers.addResult(results, 2, 'The subnet does not have flow logs enabled', region, subnet.id); + } + }); + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/flowLogsEnabled.spec.js b/plugins/oracle/networking/flowLogsEnabled.spec.js new file mode 100644 index 000000000..956f6882d --- /dev/null +++ b/plugins/oracle/networking/flowLogsEnabled.spec.js @@ -0,0 +1,185 @@ +var expect = require('chai').expect; +var plugin = require('./flowLogsEnabled'); + +const logs = [ + { + "id": "ocid1.log.oc1.log11111111", + "logGroupId": "ocid1.loggroup.oc1.loggroup11111", + "displayName": "subnet_log", + "isEnabled": true, + "lifecycleState": "ACTIVE", + "logType": "SERVICE", + "configuration": { + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "source": { + "sourceType": "OCISERVICE", + "service": "flowlogs", + "resource": "ocid1.subnet.oc1.iad.subnet1", + "category": "all", + "parameters": {} + }, + "archiving": { + "isEnabled": false + } + }, + "freeformTags": {}, + "timeCreated": "2022-07-15T00:25:53.258Z", + "timeLastModified": "2022-07-15T00:25:53.258Z", + "retentionDuration": 30, + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "logGroups": "ocid1.loggroup.oc1.loggroup11111" + } +] + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + subnet: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + log: { + list: { + 'us-ashburn-1': { + err: null, + data: logs + } + } + } + } +}; + +describe('flowLogsEnabled', function () { + describe('run', function () { + it('should give unknown result if a subnet error occurs or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for subnets') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }) + it('should give passing result if no subnets are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No subnets found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if the subnet does not have flow logs enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('The subnet does not have flow logs enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + + { + "cidrBlock": "10.0.1.0/24", + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "definedTags": {}, + "displayName": "subnet1", + "freeformTags": {}, + "id": "ocid1.subnet.oc1.iad.subnet2", + "lifecycleState": "AVAILABLE", + "securityListIds": [ + "ocid1.securitylist.oc1.iad.list11111" + ], + "timeCreated": "2019-02-28T18:19:38.677Z", + "vcnId": "ocid1.vcn.oc1.iad.vcn111111", + "virtualRouterIp": "10.0.1.1", + "virtualRouterMac": "00:00:17:33:E4:A0" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if the subnet has flow logs enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('The subnet has flow logs enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "cidrBlock": "10.0.1.0/24", + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "definedTags": {}, + "displayName": "subnet1", + "freeformTags": {}, + "id": "ocid1.subnet.oc1.iad.subnet1", + "lifecycleState": "AVAILABLE", + "securityListIds": [ + "ocid1.securitylist.oc1.iad.list11111" + ], + "timeCreated": "2019-02-28T18:19:38.677Z", + "vcnId": "ocid1.vcn.oc1.iad.vcn111111", + "virtualRouterIp": "10.0.1.1", + "virtualRouterMac": "00:00:17:33:E4:A0" + } + ] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/oracle/networking/inboundSecurityLists.js b/plugins/oracle/networking/inboundSecurityLists.js new file mode 100644 index 000000000..5eaa9151e --- /dev/null +++ b/plugins/oracle/networking/inboundSecurityLists.js @@ -0,0 +1,57 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'VCN Inbound Security List', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Low', + description: 'Ensure all security lists have ingress rules configured.', + more_info: 'To control network access to your instancesx, it is recommended that Virtual Cloud Networks (VCN) security lists are configured with ingress rules which provide stateful and stateless firewall capability.', + link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securityrules.htm', + recommended_action: 'Add ingress rules to all security lists.', + apis: ['vcn:list', 'securityList:list'], + + run: function (cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.securityList, function (region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var securityLists = helpers.addSource(cache, source, + ['securityList', 'list', region]); + + if (!securityLists || securityLists.err || !securityLists.data) { + helpers.addResult(results, 3, + 'Unable to query for security lists: ' + helpers.addError(securityLists), region); + return rcb(); + } + if (!securityLists.data.length) { + helpers.addResult(results, 0, 'No security lists found', region); + return rcb(); + } + + securityLists.data.forEach(securityList => { + if (securityList.ingressSecurityRules && securityList.ingressSecurityRules.length) { + helpers.addResult(results, 0, + `Security list has ingress rules configured`, region, securityList.id); + } + else { + helpers.addResult(results, 2, + `Security list does not have ingress rules configured`, region, securityList.id); + } + + + }); + + + } + rcb(); + }, function () { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/inboundSecurityLists.spec.js b/plugins/oracle/networking/inboundSecurityLists.spec.js new file mode 100644 index 000000000..1df38f1c6 --- /dev/null +++ b/plugins/oracle/networking/inboundSecurityLists.spec.js @@ -0,0 +1,138 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var plugin = require('./inboundSecurityLists'); + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + securityList: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('inboundSecurityLists', function () { + describe('run', function () { + it('should give unknown result if an security list error is passed or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if no security lists records are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if security list has ingress rules configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Security list has ingress rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "list2", + "displayName": "list-2", + "timeCreated": "2019-07-29T22:10:50.075Z", + "ingressSecurityRules": [{ + "isStateless": false, + "protocol": '6', + "source": '0.0.0.0/0', + "sourceType": 'CIDR_BLOCK', + }], + "lifecycleState": "AVAILABLE", + "vcnId": "v1" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if security list does not have ingress rules configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Security list does not have ingress rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "list2", + "displayName": "list-2", + "timeCreated": "2019-07-29T22:10:50.075Z", + "ingressSecurityRules": [], + "lifecycleState": "AVAILABLE", + "vcnId": "v1" + } + ] + ); + + plugin.run(cache, {}, callback); + }); + }) +}) \ No newline at end of file diff --git a/plugins/oracle/networking/lbHttpsOnly.js b/plugins/oracle/networking/lbHttpsOnly.js index fc8969034..47af386f4 100644 --- a/plugins/oracle/networking/lbHttpsOnly.js +++ b/plugins/oracle/networking/lbHttpsOnly.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Load Balancer HTTPS Only', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures LBs are configured to only accept ' + 'connections on HTTPS ports.', more_info: 'For maximum security, LBs can be configured to only ' + diff --git a/plugins/oracle/networking/lbNSGEnabled.js b/plugins/oracle/networking/lbNSGEnabled.js index 7192c66d0..9ff856a49 100644 --- a/plugins/oracle/networking/lbNSGEnabled.js +++ b/plugins/oracle/networking/lbNSGEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'LB Network Security Groups Enabled', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures Load Balancers are using network security groups to restrict network access.', more_info: 'Network security groups gives fine grained control of resources. Security rules associated with network security groups can be associated with specific resources.', link: 'https://docs.cloud.oracle.com/iaas/Content/Security/Reference/networking_security.htm', diff --git a/plugins/oracle/networking/lbNoInstances.js b/plugins/oracle/networking/lbNoInstances.js index b792624f8..5656a3490 100644 --- a/plugins/oracle/networking/lbNoInstances.js +++ b/plugins/oracle/networking/lbNoInstances.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Load Balancer No Instances', category: 'Networking', + domain: 'Network Access Control', + severity: 'Low', description: 'Detects LBs that have no backend instances attached', more_info: 'All LBs should have backend server resources. ' + 'Those without any are consuming costs without providing ' + @@ -39,6 +41,8 @@ module.exports = { async.each(loadBalancers.data, function (lb, cb) { if (lb.backendSets) { + if (!lb.displayName) return cb; + var lbBackend = lb.backendSets['bs_' + lb.displayName]; if (lbBackend && lbBackend.backends && diff --git a/plugins/oracle/networking/multipleSubnets.js b/plugins/oracle/networking/multipleSubnets.js index bfae0b883..559479241 100644 --- a/plugins/oracle/networking/multipleSubnets.js +++ b/plugins/oracle/networking/multipleSubnets.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'VCN Multiple Subnets', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensures that VCNs have multiple networks to provide a layered architecture', more_info: 'A single network within a VCN increases the risk of a broader blast radius in the event of a compromise.', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Tasks/managingVCNs.htm', diff --git a/plugins/oracle/networking/networkGatewayChanges.js b/plugins/oracle/networking/networkGatewayChanges.js new file mode 100644 index 000000000..ecc6dc8a9 --- /dev/null +++ b/plugins/oracle/networking/networkGatewayChanges.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Network Gateway Changes', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure an event rule is configured for network gateway changes.', + more_info: 'Monitoring changes to Network Gateways like create, update and delete will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for network gateway changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create DRG', value: 'com.oraclecloud.virtualnetwork.createdrg' }, + { displayName: 'Update DRG', value: 'com.oraclecloud.virtualnetwork.updatedrg' }, + { displayName: 'Delete DRG', value: 'com.oraclecloud.virtualnetwork.deletedrg' }, + { displayName: 'Create DRG Attachment', value: 'com.oraclecloud.virtualnetwork.createdrgattachment' }, + { displayName: 'Update DRG Attachment', value: 'com.oraclecloud.virtualnetwork.updatedrgattachment' }, + { displayName: 'Delete DRG Attachment', value: 'com.oraclecloud.virtualnetwork.deletedrgattachment' }, + { displayName: 'Create Internet Gateway', value: 'com.oraclecloud.virtualnetwork.createinternetgateway' }, + { displayName: 'Update Internet Gateway', value: 'com.oraclecloud.virtualnetwork.updateinternetgateway' }, + { displayName: 'Delete Internet Gateway', value: 'com.oraclecloud.virtualnetwork.deleteinternetgateway' }, + { displayName: 'Change Internet Gateway Compartment', value: 'com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment' }, + { displayName: 'Create Local Peering Gateway', value: 'com.oraclecloud.virtualnetwork.createlocalpeeringgateway' }, + { displayName: 'Update Local Peering Gateway', value: 'com.oraclecloud.virtualnetwork.updatelocalpeeringgateway' }, + { displayName: 'Delete Local Peering Gateway - Begin', value: 'com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.begin' }, + { displayName: 'Delete Local Peering Gateway - End', value: 'com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end' }, + { displayName: 'Change Local Peering Gateway Compartment', value: 'com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment' }, + { displayName: 'Create NAT Gateway', value: 'com.oraclecloud.natgateway.createnatgateway' }, + { displayName: 'Update NAT Gateway', value: 'com.oraclecloud.natgateway.updatenatgateway' }, + { displayName: 'Delete NAT Gateway', value: 'com.oraclecloud.natgateway.deletenatgateway' }, + { displayName: 'Change NAT Gateway Compartment', value: 'com.oraclecloud.natgateway.changenatgatewaycompartment' }, + { displayName: 'Attach Service Id', value: 'com.oraclecloud.servicegateway.attachserviceid' }, + { displayName: 'Detach Service Id', value: 'com.oraclecloud.servicegateway.detachserviceid' }, + { displayName: 'Create Service Gateway', value: 'com.oraclecloud.servicegateway.createservicegateway' }, + { displayName: 'Update Service Gateway', value: 'com.oraclecloud.servicegateway.updateservicegateway' }, + { displayName: 'Delete Service Gateway - Begin', value: 'com.oraclecloud.servicegateway.deleteservicegateway.begin' }, + { displayName: 'Delete Service Gateway - End', value: 'com.oraclecloud.servicegateway.deleteservicegateway.end' }, + { displayName: 'Change Service Gateway Compartment', value: 'com.oraclecloud.servicegateway.changeservicegatewaycompartment' }, + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Network Gateway', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/networkGatewayChanges.spec.js b/plugins/oracle/networking/networkGatewayChanges.spec.js new file mode 100644 index 000000000..c68c1a36f --- /dev/null +++ b/plugins/oracle/networking/networkGatewayChanges.spec.js @@ -0,0 +1,171 @@ +var expect = require('chai').expect; +var plugin = require('./networkGatewayChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: `{"eventType":["com.oraclecloud.virtualnetwork.createdrg","com.oraclecloud.virtualnetwork.updatedrg","com.oraclecloud.virtualnetwork.deletedrg", + "com.oraclecloud.virtualnetwork.createdrgattachment", "com.oraclecloud.virtualnetwork.updatedrgattachment", "com.oraclecloud.virtualnetwork.deletedrgattachment", + "com.oraclecloud.virtualnetwork.createinternetgateway", "com.oraclecloud.virtualnetwork.updateinternetgateway", "com.oraclecloud.virtualnetwork.deleteinternetgateway", "com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment", + "com.oraclecloud.virtualnetwork.createlocalpeeringgateway", "com.oraclecloud.virtualnetwork.updatelocalpeeringgateway", "com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.begin", "com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end", "com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment", + "com.oraclecloud.natgateway.createnatgateway", "com.oraclecloud.natgateway.updatenatgateway", "com.oraclecloud.natgateway.deletenatgateway", "com.oraclecloud.natgateway.changenatgatewaycompartment", + "com.oraclecloud.servicegateway.attachserviceid", "com.oraclecloud.servicegateway.detachserviceid", "com.oraclecloud.servicegateway.createservicegateway", "com.oraclecloud.servicegateway.updateservicegateway", "com.oraclecloud.servicegateway.deleteservicegateway.begin", "com.oraclecloud.servicegateway.deleteservicegateway.end", "com.oraclecloud.servicegateway.changeservicegatewaycompartment" + ],"data":{}}`, + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":[ "com.oraclecloud.virtualnetwork.createinternetgateway", "com.oraclecloud.virtualnetwork.updateinternetgateway", "com.oraclecloud.virtualnetwork.createinternetgateway"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('networkGatewayChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no network gateway rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some network gateway changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all network gateway changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/openAllPortsProtocols.js b/plugins/oracle/networking/openAllPortsProtocols.js index 462c5136a..807dc62f1 100644 --- a/plugins/oracle/networking/openAllPortsProtocols.js +++ b/plugins/oracle/networking/openAllPortsProtocols.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open All Ports Protocols', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if security list has all ports or protocols open to the public', more_info: 'Security lists should be created on a per-service basis and avoid allowing all ports or protocols.', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securitylists.htm', diff --git a/plugins/oracle/networking/openAllPortsProtocols.spec.js b/plugins/oracle/networking/openAllPortsProtocols.spec.js new file mode 100644 index 000000000..458f689ec --- /dev/null +++ b/plugins/oracle/networking/openAllPortsProtocols.spec.js @@ -0,0 +1,213 @@ +var expect = require('chai').expect; +const plugin = require('./openAllPortsProtocols'); + +const securityLists = [ + + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "all", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK" + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "egressSecurityRules": [ + { + "destination": "0.0.0.0/0", + "destinationType": "CIDR_BLOCK", + "isStateless": false, + "protocol": "all" + } + ], + "freeformTags": {}, + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + }, + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } +]; + +const vcnData = [ + { + + "compartmentId": "compartment1", + "displayName": "vcn1", + "freeformTags": {}, + "id": "vcn1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z" + } +] +const createCache = (listsData, listsErr, vcnData, vcnErr) => { + return { + vcn: { + list: { + "us-ashburn-1": { + data: vcnData, + err: vcnErr + } + } + }, + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openAllPortsProtocols', function () { + describe('run', function () { + it('should give unknown if unable to query for VCNs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for VCNs') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + vcnData, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + vcnData, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + vcnData, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has all ports open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + vcnData, + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openCIFS.js b/plugins/oracle/networking/openCIFS.js index 25aa8194d..85a589453 100644 --- a/plugins/oracle/networking/openCIFS.js +++ b/plugins/oracle/networking/openCIFS.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open CIFS', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if UDP port 445 for CIFS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services such ' + diff --git a/plugins/oracle/networking/openCIFS.spec.js b/plugins/oracle/networking/openCIFS.spec.js new file mode 100644 index 000000000..9ff23db46 --- /dev/null +++ b/plugins/oracle/networking/openCIFS.spec.js @@ -0,0 +1,307 @@ +var expect = require('chai').expect; +const plugin = require('./openCIFS'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 445, + "min": 445 + }, + "sourcePortRange": { + "max": 445, + "min": 445 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "udpOptions": { + "destinationPortRange": { + "max": 445, + "min": 445 + }, + "sourcePortRange": { + "max": 445, + "min": 445 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openCIFS', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has CIFS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has CIFS: UDP port 445 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has CIFS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has CIFS: UDP port 445 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openDNS.js b/plugins/oracle/networking/openDNS.js index 0a2f15cfa..49f11608e 100644 --- a/plugins/oracle/networking/openDNS.js +++ b/plugins/oracle/networking/openDNS.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open DNS', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP or UDP port 53 for DNS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open ' + 'to the public to function properly, more sensitive services such as DNS ' + diff --git a/plugins/oracle/networking/openDNS.spec.js b/plugins/oracle/networking/openDNS.spec.js new file mode 100644 index 000000000..2b55c5ead --- /dev/null +++ b/plugins/oracle/networking/openDNS.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openDNS'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 53, + "min": 53 + }, + "sourcePortRange": { + "max": 53, + "min": 53 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 53, + "min": 53 + }, + "sourcePortRange": { + "max": 53, + "min": 53 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openDNS', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has DNS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has DNS: UDP port 53 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has DNS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has DNS: TCP port 53 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openDocker.js b/plugins/oracle/networking/openDocker.js index 17e047c16..81dd97946 100644 --- a/plugins/oracle/networking/openDocker.js +++ b/plugins/oracle/networking/openDocker.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Docker', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 2375 or 2376 for Docker is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to ' + 'the public to function properly, more sensitive services such as Docker should ' + diff --git a/plugins/oracle/networking/openDocker.spec.js b/plugins/oracle/networking/openDocker.spec.js new file mode 100644 index 000000000..693ad6968 --- /dev/null +++ b/plugins/oracle/networking/openDocker.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openDocker'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 2376, + "min": 2375 + }, + "sourcePortRange": { + "max": 2376, + "min": 2375 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 2376, + "min":2375 + }, + "sourcePortRange": { + "max": 2376, + "min": 2375 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "2022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openDocker', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Docker port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has Docker: TCP port 2375,2376 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Docker port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has Docker: TCP port 2375,2376 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openFTP.js b/plugins/oracle/networking/openFTP.js index 5f202b9c8..1a4400090 100644 --- a/plugins/oracle/networking/openFTP.js +++ b/plugins/oracle/networking/openFTP.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open FTP', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 20 or 21 for FTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to ' + 'the public to function properly, more sensitive services such as FTP should ' + diff --git a/plugins/oracle/networking/openFTP.spec.js b/plugins/oracle/networking/openFTP.spec.js new file mode 100644 index 000000000..6394d1fd0 --- /dev/null +++ b/plugins/oracle/networking/openFTP.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openFTP'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 21, + "min": 20 + }, + "sourcePortRange": { + "max": 21, + "min": 20 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 21, + "min":20 + }, + "sourcePortRange": { + "max": 21, + "min": 20 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "2022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openFTP', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has FTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has FTP: TCP port 20,21 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has FTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has FTP: TCP port 20,21 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openHTTP.js b/plugins/oracle/networking/openHTTP.js new file mode 100644 index 000000000..a805a7c90 --- /dev/null +++ b/plugins/oracle/networking/openHTTP.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle/'); + +module.exports = { + title: 'Open HTTP', + category: 'Networking', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 80 for HTTP is open to the public', + more_info: 'While some ports are required to be open to ' + + 'the public to function properly, more sensitive services such as HTTP should ' + + 'be restricted to known IP addresses.', + recommended_action: 'Restrict TCP port 80 to known IP addresses', + link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securitylists.htm', + apis: ['vcn:list', 'securityList:list','networkSecurityGroup:list','securityRule:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var isSecurityRule = false; + + async.each(regions.securityList, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var ruleEmpty = false; + var listEmpty = false; + + var ports = { + 'tcp': [80] + }; + + var service = 'HTTP'; + + var getSecurityLists = helpers.addSource(cache, source, + ['securityList', 'list', region]); + + if (getSecurityLists && getSecurityLists.err) { + helpers.addResult(results, 3, + 'Unable to query for security lists: ' + + helpers.addError(getSecurityLists), region); + + } else if (getSecurityLists && + (!getSecurityLists.data || !getSecurityLists.data.length)) { + listEmpty = true; + + } else if (getSecurityLists) { + helpers.findOpenPorts(getSecurityLists.data, ports, + service, region, results, isSecurityRule); + } + + var getSecurityRules = helpers.addSource(cache, source, + ['securityRule', 'list', region]); + + if (getSecurityRules && getSecurityRules.err) { + helpers.addResult(results, 3, + 'Unable to query for security rules: ' + + helpers.addError(getSecurityRules), region); + + } else if (getSecurityRules && + (!getSecurityRules.data || !getSecurityRules.data.length)) { + ruleEmpty = true; + + } else if (getSecurityRules) { + var getSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroup', 'list', region]); + + isSecurityRule = true; + + helpers.findOpenPorts(getSecurityRules.data, ports, + service, region, results, isSecurityRule, getSecurityGroups); + } + + if (ruleEmpty && listEmpty) { + helpers.addResult(results, 0, + 'No security rules or lists found', region); + } else if (ruleEmpty) { + helpers.addResult(results, 0, 'No security rules found', region); + } else if (listEmpty) { + helpers.addResult(results, 0, 'No security lists found', region); + } + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/openHTTP.spec.js b/plugins/oracle/networking/openHTTP.spec.js new file mode 100644 index 000000000..4852664da --- /dev/null +++ b/plugins/oracle/networking/openHTTP.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openHTTP'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 81, + "min": 80 + }, + "sourcePortRange": { + "max": 21, + "min": 20 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 81, + "min":80 + }, + "sourcePortRange": { + "max": 21, + "min": 20 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "2022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openHTTP', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has HTTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has HTTP: TCP port 80 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has HTTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has HTTP: TCP port 80 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openHadoopNameNode.js b/plugins/oracle/networking/openHadoopNameNode.js index 4b988da47..6a9017dca 100644 --- a/plugins/oracle/networking/openHadoopNameNode.js +++ b/plugins/oracle/networking/openHadoopNameNode.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Hadoop HDFS NameNode Metadata Service', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 8020 for HDFS NameNode metadata service is open ' + 'to the public.', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the ' + diff --git a/plugins/oracle/networking/openHadoopNameNode.spec.js b/plugins/oracle/networking/openHadoopNameNode.spec.js new file mode 100644 index 000000000..ed31033fa --- /dev/null +++ b/plugins/oracle/networking/openHadoopNameNode.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openHadoopNameNode'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 8020, + "min": 8020 + }, + "sourcePortRange": { + "max": 8020, + "min": 8020 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-01-09T13:02:8020.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "802022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "802022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 8020, + "min":8020 + }, + "sourcePortRange": { + "max": 8020, + "min": 8020 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "802022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openHadoopNameNode', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has NameNodeMetadata port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has NameNodeMetadata: TCP port 8020 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has NameNodeMetadata port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has NameNodeMetadata: TCP port 8020 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openHadoopNameNodeWebUI.js b/plugins/oracle/networking/openHadoopNameNodeWebUI.js index da919deb3..5dc02230f 100644 --- a/plugins/oracle/networking/openHadoopNameNodeWebUI.js +++ b/plugins/oracle/networking/openHadoopNameNodeWebUI.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Hadoop HDFS NameNode WebUI', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 50070 and 50470 for Hadoop/HDFS NameNode ' + 'WebUI service is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to ' + diff --git a/plugins/oracle/networking/openHadoopNameNodeWebUI.spec.js b/plugins/oracle/networking/openHadoopNameNodeWebUI.spec.js new file mode 100644 index 000000000..5e0b290dd --- /dev/null +++ b/plugins/oracle/networking/openHadoopNameNodeWebUI.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openHadoopNameNodeWebUI'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 50470, + "min": 50070 + }, + "sourcePortRange": { + "max": 50470, + "min": 50070 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-01-09T13:02:8020.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "802022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "802022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 50470, + "min": 50070 + }, + "sourcePortRange": { + "max": 50470, + "min": 50070 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "802022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "802022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openHadoopNameNodeWebUI', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has NameNodeWebUI port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has NameNodeWebUI: TCP port 50070,50470 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has NameNodeWebUI port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has NameNodeWebUI: TCP port 50070,50470 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openKibana.js b/plugins/oracle/networking/openKibana.js index bbaae7755..7ab8bac1a 100644 --- a/plugins/oracle/networking/openKibana.js +++ b/plugins/oracle/networking/openKibana.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Kibana', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5601 for Kibana is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open ' + 'to the public to function properly, more sensitive services such as Kibana ' + diff --git a/plugins/oracle/networking/openKibana.spec.js b/plugins/oracle/networking/openKibana.spec.js new file mode 100644 index 000000000..fe6c70dcc --- /dev/null +++ b/plugins/oracle/networking/openKibana.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openKibana'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 5601, + "min": 5601 + }, + "sourcePortRange": { + "max": 5601, + "min": 5601 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 5601, + "min": 5601 + }, + "sourcePortRange": { + "max": 5601, + "min": 5601 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openKibana', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Kibana port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has Kibana: TCP port 5601 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Kibana port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has Kibana: TCP port 5601 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openMySQL.js b/plugins/oracle/networking/openMySQL.js index 88b6f3429..f30f7325b 100644 --- a/plugins/oracle/networking/openMySQL.js +++ b/plugins/oracle/networking/openMySQL.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open MySQL', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 4333 or 3306 for MySQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the ' + 'public to function properly, more sensitive services such as MySQL should be ' + diff --git a/plugins/oracle/networking/openMySQL.spec.js b/plugins/oracle/networking/openMySQL.spec.js new file mode 100644 index 000000000..227041a22 --- /dev/null +++ b/plugins/oracle/networking/openMySQL.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openMySQL'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 4333, + "min": 3306 + }, + "sourcePortRange": { + "max": 4333, + "min": 3306 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "330622-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "330622-01-09T13:02:3306.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "330622-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "330622-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 4333, + "min":3306 + }, + "sourcePortRange": { + "max": 4333, + "min": 3306 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "330622-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "330622-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openMySQL', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has MySQL port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has MySQL: TCP port 4333,3306 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has MySQL port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has MySQL: TCP port 4333,3306 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openNetBIOS.js b/plugins/oracle/networking/openNetBIOS.js index 580e6d18f..d1e01c9b0 100644 --- a/plugins/oracle/networking/openNetBIOS.js +++ b/plugins/oracle/networking/openNetBIOS.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open NetBIOS', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if UDP port 137 or 138 for NetBIOS is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to ' + 'the public to function properly, more sensitive services such as NetBIOS ' + diff --git a/plugins/oracle/networking/openNetBIOS.spec.js b/plugins/oracle/networking/openNetBIOS.spec.js new file mode 100644 index 000000000..c6ca311c4 --- /dev/null +++ b/plugins/oracle/networking/openNetBIOS.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openNetBIOS'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 138, + "min": 137 + }, + "sourcePortRange": { + "max": 138, + "min": 137 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "udpOptions": { + "destinationPortRange": { + "max": 138, + "min":137 + }, + "sourcePortRange": { + "max": 138, + "min": 137 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "2022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openNetBIOS', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has NetBIOS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has NetBIOS: UDP port 137,138 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has NetBIOS port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has NetBIOS: UDP port 137,138 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openOracle.js b/plugins/oracle/networking/openOracle.js index 3b4152b46..890ba30b9 100644 --- a/plugins/oracle/networking/openOracle.js +++ b/plugins/oracle/networking/openOracle.js @@ -4,11 +4,13 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Oracle', category: 'Networking', - description: 'Determine if TCP port 1521 for Oracle is open to the public', + domain: 'Network Access Control', + severity: 'High', + description: 'Determine if TCP port 1521, 2483 for Oracle is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open ' + 'to the public to function properly, more sensitive services such as Oracle ' + 'should be restricted to known IP addresses.', - recommended_action: 'Restrict TCP ports 1521 to known IP addresses', + recommended_action: 'Restrict TCP ports 1521, 2483 to known IP addresses', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Concepts/securitylists.htm', apis: ['vcn:list', 'securityList:list','networkSecurityGroup:list','securityRule:list'], @@ -25,7 +27,7 @@ module.exports = { var listEmpty = false; var ports = { - 'tcp': [1521] + 'tcp': [1521, 2483] }; var service = 'Oracle'; @@ -86,4 +88,4 @@ module.exports = { }); } -}; \ No newline at end of file +}; diff --git a/plugins/oracle/networking/openOracle.spec.js b/plugins/oracle/networking/openOracle.spec.js new file mode 100644 index 000000000..f9512f8dd --- /dev/null +++ b/plugins/oracle/networking/openOracle.spec.js @@ -0,0 +1,304 @@ +var expect = require('chai').expect; +const plugin = require('./openOracle'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 1521, + "min": 1521 + }, + "sourcePortRange": { + "max": 1521, + "min": 1521 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 1521, + "min": 1521 + }, + "sourcePortRange": { + "max": 1521, + "min": 1521 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openOracle', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Oracle port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Oracle port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openOracleAutoDataWarehouse.js b/plugins/oracle/networking/openOracleAutoDataWarehouse.js index 67a41c602..9d47aa1d3 100644 --- a/plugins/oracle/networking/openOracleAutoDataWarehouse.js +++ b/plugins/oracle/networking/openOracleAutoDataWarehouse.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Autonomous Data Warehouse', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 1522 for Autonomous Data Warehouse is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the public ' + 'to function properly, more sensitive services such as Autonomous Data Warehouse should ' + diff --git a/plugins/oracle/networking/openOracleAutoDataWarehouse.spec.js b/plugins/oracle/networking/openOracleAutoDataWarehouse.spec.js new file mode 100644 index 000000000..3d4de0d9c --- /dev/null +++ b/plugins/oracle/networking/openOracleAutoDataWarehouse.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openOracleAutoDataWarehouse'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 1522, + "min": 1522 + }, + "sourcePortRange": { + "max": 1522, + "min": 1522 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 1522, + "min": 1522 + }, + "sourcePortRange": { + "max": 1522, + "min": 1522 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openOracleAutoDataWarehouse', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Autonomous Data Warehouse port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has Autonomous Data Warehouse: TCP port 1522 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Autonomous Data Warehouse port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has Autonomous Data Warehouse: TCP port 1522 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openPostgreSQL.js b/plugins/oracle/networking/openPostgreSQL.js index 06413417a..a0297023c 100644 --- a/plugins/oracle/networking/openPostgreSQL.js +++ b/plugins/oracle/networking/openPostgreSQL.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open PostgreSQL', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 5432 for PostgreSQL is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to ' + 'the public to function properly, more sensitive services such as PostgreSQL ' + diff --git a/plugins/oracle/networking/openPostgreSQL.spec.js b/plugins/oracle/networking/openPostgreSQL.spec.js new file mode 100644 index 000000000..549d66e3d --- /dev/null +++ b/plugins/oracle/networking/openPostgreSQL.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openPostgreSQL'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 5432, + "min": 5432 + }, + "sourcePortRange": { + "max": 5432, + "min": 5432 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 5432, + "min": 5432 + }, + "sourcePortRange": { + "max": 5432, + "min": 5432 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openPostgreSQL', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has PostgresSQL port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has PostgresSQL: TCP port 5432 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has PostgresSQL port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has PostgresSQL: TCP port 5432 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openRDP.js b/plugins/oracle/networking/openRDP.js index 9d71e0fbc..d075b03ae 100644 --- a/plugins/oracle/networking/openRDP.js +++ b/plugins/oracle/networking/openRDP.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open RDP', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 3389 for RDP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services such ' + diff --git a/plugins/oracle/networking/openRDP.spec.js b/plugins/oracle/networking/openRDP.spec.js new file mode 100644 index 000000000..85dfc08a5 --- /dev/null +++ b/plugins/oracle/networking/openRDP.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openRDP'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 3389, + "min": 3389 + }, + "sourcePortRange": { + "max": 3389, + "min": 3389 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 3389, + "min": 3389 + }, + "sourcePortRange": { + "max": 3389, + "min": 3389 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openRDP', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has RDP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has RDP: TCP port 3389 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has RDP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has RDP: TCP port 3389 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openRPC.js b/plugins/oracle/networking/openRPC.js index bb553131e..e03860a2c 100644 --- a/plugins/oracle/networking/openRPC.js +++ b/plugins/oracle/networking/openRPC.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open RPC', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 135 for RPC is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services such ' + diff --git a/plugins/oracle/networking/openRPC.spec.js b/plugins/oracle/networking/openRPC.spec.js new file mode 100644 index 000000000..4d31455ca --- /dev/null +++ b/plugins/oracle/networking/openRPC.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openRPC'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 135, + "min": 135 + }, + "sourcePortRange": { + "max": 135, + "min": 135 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 135, + "min": 135 + }, + "sourcePortRange": { + "max": 135, + "min": 135 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openRPC', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has RPC port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has RPC: TCP port 135 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has RPC port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has RPC: TCP port 135 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openSMBoTCP.js b/plugins/oracle/networking/openSMBoTCP.js index b60c361b4..4ca3dd6de 100644 --- a/plugins/oracle/networking/openSMBoTCP.js +++ b/plugins/oracle/networking/openSMBoTCP.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open SMBoTCP', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 445 for Windows SMB over TCP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the ' + 'public to function properly, more sensitive services such as SMB should be ' + diff --git a/plugins/oracle/networking/openSMBoTCP.spec.js b/plugins/oracle/networking/openSMBoTCP.spec.js new file mode 100644 index 000000000..3bbc8ea20 --- /dev/null +++ b/plugins/oracle/networking/openSMBoTCP.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openSMBoTCP'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 445, + "min": 445 + }, + "sourcePortRange": { + "max": 445, + "min": 445 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 445, + "min": 445 + }, + "sourcePortRange": { + "max": 445, + "min": 445 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openSMBoTCP', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has SMBoTCP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has SMBoTCP: TCP port 445 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has SMBoTCP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has SMBoTCP: TCP port 445 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openSMTP.js b/plugins/oracle/networking/openSMTP.js index 03a36c344..9b71023f9 100644 --- a/plugins/oracle/networking/openSMTP.js +++ b/plugins/oracle/networking/openSMTP.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open SMTP', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 25 for SMTP is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services ' + diff --git a/plugins/oracle/networking/openSMTP.spec.js b/plugins/oracle/networking/openSMTP.spec.js new file mode 100644 index 000000000..20cd9ae7e --- /dev/null +++ b/plugins/oracle/networking/openSMTP.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openSMTP'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 25, + "min": 25 + }, + "sourcePortRange": { + "max": 25, + "min": 25 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 25, + "min": 25 + }, + "sourcePortRange": { + "max": 25, + "min": 25 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openSMTP', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has SMTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has SMTP: TCP port 25 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has SMTP port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has SMTP: TCP port 25 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openSQLServer.js b/plugins/oracle/networking/openSQLServer.js index 0858b33cd..721a9cb87 100644 --- a/plugins/oracle/networking/openSQLServer.js +++ b/plugins/oracle/networking/openSQLServer.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open SQLServer', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 1433 or UDP port 1434 for SQL Server is open to ' + 'the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open to the ' + diff --git a/plugins/oracle/networking/openSQLServer.spec.js b/plugins/oracle/networking/openSQLServer.spec.js new file mode 100644 index 000000000..27e8c328d --- /dev/null +++ b/plugins/oracle/networking/openSQLServer.spec.js @@ -0,0 +1,306 @@ +var expect = require('chai').expect; +const plugin = require('./openSQLServer'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "udpOptions": { + "destinationPortRange": { + "max": 1434, + "min": 1434 + }, + "sourcePortRange": { + "max": 1434, + "min": 1434 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 1433, + "min": 1433 + }, + "sourcePortRange": { + "max": 1433, + "min": 1433 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openSQLServer', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has SQL Server port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has SQL Server: UDP port 1434 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has SQL Server port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has SQL Server: TCP port 1433 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openSSH.js b/plugins/oracle/networking/openSSH.js index 1a5344cc1..b77572835 100644 --- a/plugins/oracle/networking/openSSH.js +++ b/plugins/oracle/networking/openSSH.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open SSH', category: 'Networking', + domain: 'Network Access Control', + severity: 'High', description: 'Determine if TCP port 22 for SSH is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services ' + diff --git a/plugins/oracle/networking/openSSH.spec.js b/plugins/oracle/networking/openSSH.spec.js new file mode 100644 index 000000000..6a8408e1d --- /dev/null +++ b/plugins/oracle/networking/openSSH.spec.js @@ -0,0 +1,292 @@ +var expect = require('chai').expect; +const plugin = require('./openSSH'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + }, + "sourcePortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.422Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + }, + "sourcePortRange": { + "max": 22, + "min": 22 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openSSH', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has SSH port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has SSH: TCP port 22 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has SSH port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has SSH: TCP port 22 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openSalt.js b/plugins/oracle/networking/openSalt.js index 290643925..c45e75d94 100644 --- a/plugins/oracle/networking/openSalt.js +++ b/plugins/oracle/networking/openSalt.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Salt', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP ports 4505 or 4506 for the Salt master are open to the public', more_info: 'Active Salt vulnerabilities, CVE-2020-11651 and CVE-2020-11652 are exploiting Salt instances exposed to the internet. These ports should be closed immediately.', recommended_action: 'Restrict TCP ports 4505 and 4506 to known IP addresses', diff --git a/plugins/oracle/networking/openSalt.spec.js b/plugins/oracle/networking/openSalt.spec.js new file mode 100644 index 000000000..09699826f --- /dev/null +++ b/plugins/oracle/networking/openSalt.spec.js @@ -0,0 +1,313 @@ +var expect = require('chai').expect; +const plugin = require('./openSalt'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 4506, + "min": 4505 + }, + "sourcePortRange": { + "max": 4506, + "min": 4505 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "6", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 22, + "min": 22 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.425Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 4506, + "min":4505 + }, + "sourcePortRange": { + "max": 4506, + "min": 4505 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "definedTags": { + "Oracle-Tags": { + "CreatedBy": "muhammad.akhtar.ext@khulnasoft.com", + "CreatedOn": "2022-01-09T13:03:47.837Z" + } + }, + "displayName": "firstgroup", + "freeformTags": {}, + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openSalt', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Salt port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has Salt: TCP port 4505,4506 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Salt port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has Salt: TCP port 4505,4506 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openTelnet.js b/plugins/oracle/networking/openTelnet.js index 0c495062a..0529527d4 100644 --- a/plugins/oracle/networking/openTelnet.js +++ b/plugins/oracle/networking/openTelnet.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open Telnet', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 23 for Telnet is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be ' + 'open to the public to function properly, more sensitive services such ' + diff --git a/plugins/oracle/networking/openTelnet.spec.js b/plugins/oracle/networking/openTelnet.spec.js new file mode 100644 index 000000000..b32cc664b --- /dev/null +++ b/plugins/oracle/networking/openTelnet.spec.js @@ -0,0 +1,292 @@ +var expect = require('chai').expect; +const plugin = require('./openTelnet'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 23, + "min": 23 + }, + "sourcePortRange": { + "max": 23, + "min": 23 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.422Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 23, + "min": 23 + }, + "sourcePortRange": { + "max": 23, + "min": 23 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openTelnet', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has Telnet port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has Telnet: TCP port 23 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has Telnet port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has Telnet: TCP port 23 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openVNCClient.js b/plugins/oracle/networking/openVNCClient.js index 38623ae96..fa809456c 100644 --- a/plugins/oracle/networking/openVNCClient.js +++ b/plugins/oracle/networking/openVNCClient.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open VNC Client', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5500 for VNC Client is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open ' + 'to the public to function properly, more sensitive services such as VNC ' + diff --git a/plugins/oracle/networking/openVNCClient.spec.js b/plugins/oracle/networking/openVNCClient.spec.js new file mode 100644 index 000000000..abe8038fd --- /dev/null +++ b/plugins/oracle/networking/openVNCClient.spec.js @@ -0,0 +1,292 @@ +var expect = require('chai').expect; +const plugin = require('./openVNCClient'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 5500, + "min": 5500 + }, + "sourcePortRange": { + "max": 5500, + "min": 5500 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.422Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 5500, + "min": 5500 + }, + "sourcePortRange": { + "max": 5500, + "min": 5500 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openVNCClient', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has VNC Client port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has VNC Client: TCP port 5500 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has VNC Client port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has VNC Client: TCP port 5500 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/openVNCServer.js b/plugins/oracle/networking/openVNCServer.js index c5a8066b7..761114763 100644 --- a/plugins/oracle/networking/openVNCServer.js +++ b/plugins/oracle/networking/openVNCServer.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Open VNC Server', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Determine if TCP port 5900 for VNC Server is open to the public', more_info: 'While some ports such as HTTP and HTTPS are required to be open ' + 'to the public to function properly, more sensitive services such as VNC ' + diff --git a/plugins/oracle/networking/openVNCServer.spec.js b/plugins/oracle/networking/openVNCServer.spec.js new file mode 100644 index 000000000..c61177d9e --- /dev/null +++ b/plugins/oracle/networking/openVNCServer.spec.js @@ -0,0 +1,292 @@ +var expect = require('chai').expect; +const plugin = require('./openVNCServer'); + +const securityLists = [ + { + "compartmentId": "compartment-1", + "displayName": "list1", + "egressSecurityRules": [], + "freeformTags": {}, + "id": "list1", + "ingressSecurityRules": [ + { + "isStateless": false, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "tcpOptions": { + "destinationPortRange": { + "max": 5900, + "min": 5900 + }, + "sourcePortRange": { + "max": 5900, + "min": 5900 + } + } + } + ], + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-04-17T01:16:32.366Z", + "vcnId": "vcn1" + }, + { + "compartmentId": "compartment-1", + "displayName": "list2", + "id": "list2", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:02:20.422Z", + "vcnId": "vcn1" + } + ]; + +const securityRules = [ + { + "direction": "INGRESS", + "id": "1", + "isStateless": false, + "isValid": true, + "protocol": "all", + "source": "10.1.0.0/16", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-01-09T13:03:48.937Z", + "networkSecurityGroups": "group1" + }, + { + "direction": "INGRESS", + "id": "2", + "isStateless": false, + "isValid": true, + "protocol": "17", + "source": "0.0.0.0/0", + "sourceType": "CIDR_BLOCK", + "timeCreated": "2022-04-17T12:16:46.679Z", + "tcpOptions": { + "destinationPortRange": { + "max": 5900, + "min": 5900 + }, + "sourcePortRange": { + "max": 5900, + "min": 5900 + } + }, + "networkSecurityGroups": "group1" + } + ] +const networkSecurityGroups = [ + { + "compartmentId": "compartment-1", + "displayName": "firstgroup", + "id": "group1", + "lifecycleState": "AVAILABLE", + "timeCreated": "2022-01-09T13:03:47.999Z", + "vcnId": "vcn1" + } + ] +const createCache = (listsData, listsErr, groupsData, groupsErr, rulesData, rulesErr,) => { + return { + + securityList: { + list: { + "us-ashburn-1": { + data: listsData, + err: listsErr + } + } + }, + securityRule: { + list: { + "us-ashburn-1": { + data: rulesData, + err: rulesErr + } + } + }, + networkSecurityGroup: { + list: { + "us-ashburn-1": { + data: groupsData, + err: groupsErr + } + } + }, + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + }; +}; + + + +describe('openVNCServer', function () { + describe('run', function () { + it('should give unknown if unable to query for security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security lists') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown if unable to query for security rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + securityLists[1], + null, + networkSecurityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no security lists or ruls are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No security rules or lists found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + networkSecurityGroups, + null, + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in security lists', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should PASS if no open ports found in network security groups', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should FAIL if security list has VNC Server port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('has VNC Server: TCP port 5900 open to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[0]], + null, + networkSecurityGroups, + null, + [securityRules[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + it('should FAIL if network security group has VNC Server port open to public', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[1].status).to.equal(2) + expect(results[1].message).to.include('has VNC Server: TCP port 5900 open to') + expect(results[1].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [securityLists[1]], + null, + networkSecurityGroups, + null, + [securityRules[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + }); +}); diff --git a/plugins/oracle/networking/routeTableChanges.js b/plugins/oracle/networking/routeTableChanges.js new file mode 100644 index 000000000..8cf83e4e3 --- /dev/null +++ b/plugins/oracle/networking/routeTableChanges.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Route Table Changes', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure an event rule is configured for route table changes.', + more_info: 'Monitoring changes to route tables like create, update and delete will help in identifying changes to the traffic flows.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for route table changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.virtualnetwork.createroutetable' }, + { displayName: 'Update', value: 'com.oraclecloud.virtualnetwork.updateroutetable' }, + { displayName: 'Delete', value: 'com.oraclecloud.virtualnetwork.deleteroutetable' }, + { displayName: 'Change Compartment', value: 'com.oraclecloud.virtualnetwork.changeroutetablecompartment'} + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Route Table', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/routeTableChanges.spec.js b/plugins/oracle/networking/routeTableChanges.spec.js new file mode 100644 index 000000000..da24820fd --- /dev/null +++ b/plugins/oracle/networking/routeTableChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./routeTableChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createroutetable","com.oraclecloud.virtualnetwork.updateroutetable","com.oraclecloud.virtualnetwork.deleteroutetable", "com.oraclecloud.virtualnetwork.changeroutetablecompartment"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createroutetable","com.oraclecloud.virtualnetwork.updateroutetable","com.oraclecloud.virtualnetwork.deleteroutetable"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('routeTableChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no route table rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some route table changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all route table changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/securityGroupChanges.js b/plugins/oracle/networking/securityGroupChanges.js new file mode 100644 index 000000000..73ddbefc2 --- /dev/null +++ b/plugins/oracle/networking/securityGroupChanges.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Security Group Changes', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure an event rule is configured for security group changes.', + more_info: 'Monitoring changes to security groups like create, update and delete will help in identifying changes to the security controls.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for security group changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.virtualnetwork.createnetworksecuritygroup' }, + { displayName: 'Update', value: 'com.oraclecloud.virtualnetwork.updatenetworksecuritygroup' }, + { displayName: 'Delete', value: 'com.oraclecloud.virtualnetwork.deletenetworksecuritygroup' }, + { displayName: 'Change Compartment', value: 'com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment'} + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Security Group', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/securityGroupChanges.spec.js b/plugins/oracle/networking/securityGroupChanges.spec.js new file mode 100644 index 000000000..75c23120a --- /dev/null +++ b/plugins/oracle/networking/securityGroupChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./securityGroupChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createnetworksecuritygroup","com.oraclecloud.virtualnetwork.updatenetworksecuritygroup","com.oraclecloud.virtualnetwork.deletenetworksecuritygroup", "com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createnetworksecuritygroup","com.oraclecloud.virtualnetwork.updatenetworksecuritygroup","com.oraclecloud.virtualnetwork.deletenetworksecuritygroup"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('securityGroupChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no security group rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some security group changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all security group changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/securityListChanges.js b/plugins/oracle/networking/securityListChanges.js new file mode 100644 index 000000000..1318a8dfd --- /dev/null +++ b/plugins/oracle/networking/securityListChanges.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Security List Changes', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure an event rule is configured for security list changes.', + more_info: 'Monitoring changes to security lists like create, update and delete will help in identifying changes to the security controls.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for security list changes like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.virtualnetwork.createsecuritylist' }, + { displayName: 'Update', value: 'com.oraclecloud.virtualnetwork.updatesecuritylist' }, + { displayName: 'Delete', value: 'com.oraclecloud.virtualnetwork.deletesecuritylist' }, + { displayName: 'Change Compartment', value: 'com.oraclecloud.virtualnetwork.changesecuritylistcompartment'} + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'Security List', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/securityListChanges.spec.js b/plugins/oracle/networking/securityListChanges.spec.js new file mode 100644 index 000000000..dfd417a0c --- /dev/null +++ b/plugins/oracle/networking/securityListChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./securityListChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createsecuritylist","com.oraclecloud.virtualnetwork.updatesecuritylist","com.oraclecloud.virtualnetwork.deletesecuritylist", "com.oraclecloud.virtualnetwork.changesecuritylistcompartment"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createsecuritylist","com.oraclecloud.virtualnetwork.updatesecuritylist","com.oraclecloud.virtualnetwork.deletesecuritylist"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('securityListChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no security list rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some security list changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all security list changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/statelessSecurityRules.js b/plugins/oracle/networking/statelessSecurityRules.js index d0b53502b..c7241618e 100644 --- a/plugins/oracle/networking/statelessSecurityRules.js +++ b/plugins/oracle/networking/statelessSecurityRules.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Stateless Security Rules', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Ensure all security rules are stateless.', more_info: 'Stateless security rules are one-way-rules that help mitigate ' + 'DDoS attacks and speed up network traffic.', @@ -22,7 +24,7 @@ module.exports = { var securityLists = helpers.addSource(cache, source, ['securityList', 'list', region]); - if (!securityLists || securityLists.err) { + if (!securityLists || securityLists.err || !securityLists.data) { helpers.addResult(results, 3, 'Unable to query for security lists: ' + helpers.addError(securityLists), region); return rcb(); @@ -59,7 +61,7 @@ module.exports = { var securityRules = helpers.addSource(cache, source, ['securityRule', 'list', region]); - if (!securityRules || securityRules.err) { + if (!securityRules || securityRules.err || !securityRules.data) { helpers.addResult(results, 3, 'Unable to query for security rules: ' + helpers.addError(securityRules), region); return rcb(); @@ -69,6 +71,7 @@ module.exports = { helpers.addResult(results, 0, 'No security rules found', region); return rcb(); } + var statefulNSGId = []; var securityGroupName; securityRules.data.forEach(securityRule => { diff --git a/plugins/oracle/networking/subnetMultiAd.js b/plugins/oracle/networking/subnetMultiAd.js index d7205915a..7ab541fc3 100644 --- a/plugins/oracle/networking/subnetMultiAd.js +++ b/plugins/oracle/networking/subnetMultiAd.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Subnet Multi AD', category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', description: 'Detects subnets that are not regional', more_info: 'Creating a regional subnet ensures a highly available system. Regional subnets span across multiple availability domains increasing the availability and durability of the resources launched within it.', link: 'https://docs.cloud.oracle.com/iaas/Content/Network/Tasks/managingVCNs.htm', diff --git a/plugins/oracle/networking/vcnChanges.js b/plugins/oracle/networking/vcnChanges.js new file mode 100644 index 000000000..8672963fc --- /dev/null +++ b/plugins/oracle/networking/vcnChanges.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'VCN Changes', + category: 'Networking', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensure an event rule is configured for VCN changes.', + more_info: 'Monitoring changes to VCNs will help in identifying changes to the security posture.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm', + recommended_action: 'Configure an event rule for changes to VCNs like create, update and delete.', + apis: ['rules:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.rules, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var rules = helpers.addSource(cache, source, + ['rules', 'list', region]); + + if (!rules) return rcb(); + + if (rules.err || !rules.data) { + helpers.addResult(results, 3, + 'Unable to query for rules: ' + helpers.addError(rules), region); + return rcb(); + } + if (!rules.data.length) { + helpers.addResult(results, 2, 'No rules found', region); + return rcb(); + } + + const compartment = rules.data[0].compartmentId; + const eventsToCheck = [ + { displayName: 'Create', value: 'com.oraclecloud.virtualnetwork.createvcn' }, + { displayName: 'Update', value: 'com.oraclecloud.virtualnetwork.updatevcn' }, + { displayName: 'Delete', value: 'com.oraclecloud.virtualnetwork.deletevcn' }, + ]; + + helpers.checkEventRules(rules.data, eventsToCheck, 'VCN', compartment, region, results); + + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/networking/vcnChanges.spec.js b/plugins/oracle/networking/vcnChanges.spec.js new file mode 100644 index 000000000..a51463a44 --- /dev/null +++ b/plugins/oracle/networking/vcnChanges.spec.js @@ -0,0 +1,165 @@ +var expect = require('chai').expect; +var plugin = require('./vcnChanges'); + +const rules = [ + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createvcn","com.oraclecloud.virtualnetwork.updatevcn","com.oraclecloud.virtualnetwork.deletevcn", "com.oraclecloud.virtualnetwork.updatevcnstate", "com.oraclecloud.virtualnetwork.updatevcncapabilities"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.rule1', + displayName: 'Rule 1', + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.virtualnetwork.createvcn","com.oraclecloud.virtualnetwork.updatevcn"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-03T23:57:52.769Z', + }, + { + id: 'ocid1.eventrule.oc1.iad.abuwcljsa53hgf43hbifj3qmhewwgttyedhcngvbb3yyqqzgegtc4dpz2zca', + displayName: 'rule 2', + description: null, + lifecycleState: 'ACTIVE', + condition: '{"eventType":["com.oraclecloud.objectstorage.createbucket"],"data":{}}', + compartmentId: 'ocid1.tenancy.oc1.111111111', + isEnabled: true, + timeCreated: '2022-07-04T01:06:54.834Z' + } + +]; + + +const createCache = (err, rules) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + rules: { + list: { + 'us-ashburn-1': { + err: err, + data: rules + } + } + }, + } +}; + +describe('vcnChanges', function () { + describe('run', function () { + it('should give unknown result if unable to query for rules', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['err'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no rules found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No rules found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if no vcn rules are configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('No event rules are configured for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[2]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if Event rules are missing for some vcn changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('Event rules are missing for') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if event rules are configured for all vcn changes', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('Event rules are configured for all') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [rules[0]] + ); + + plugin.run(cache, {}, callback); + }) + + + }); +}); \ No newline at end of file diff --git a/plugins/oracle/networking/wafPublicIpEnabled.js b/plugins/oracle/networking/wafPublicIpEnabled.js index df7ffbf25..b23014e87 100644 --- a/plugins/oracle/networking/wafPublicIpEnabled.js +++ b/plugins/oracle/networking/wafPublicIpEnabled.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'WAF Public IP Enabled', category: 'Networking', + domain: 'Network Access Control', + severity: 'Low', description: 'Ensures all public IPs have WAF enabled', more_info: 'Every Public IP address should have a firewall enabled to control access to the endpoints. Enabling a Web Application Firewall follows security best practices and helps prevent malicious attempts to access the network.', recommended_action: 'Ensure all Public IPs have WAF enabled', diff --git a/plugins/oracle/objectstore/bucketCMKEncryption.js b/plugins/oracle/objectstore/bucketCMKEncryption.js new file mode 100644 index 000000000..1c2aa9f76 --- /dev/null +++ b/plugins/oracle/objectstore/bucketCMKEncryption.js @@ -0,0 +1,90 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Bucket CMK Encryption', + category: 'Object Store', + domain: 'Storage', + severity: 'High', + description: 'Ensure that Oracle Object Store buckets have encryption enabled using desired protection level.', + more_info: 'By default, all object store buckets are encrypted using an Oracle-managed master encryption key. To have better control over how your object store buckets are encrypted, you can use Customer-Managed Keys (CMKs).', + recommended_action: 'Ensure that all object store buckets have desired encryption level.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/encryption.htm', + apis: ['namespace:get', 'bucket:list', 'bucket:get', 'vault:list', 'keys:list'], + settings: { + bucket_encryption_level: { + name: 'Bucket Encryption Level', + description: 'Desired protection level for Object store buckets. default: oracle-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key', + regex: '^(default|cloudcmek|cloudhsm)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var keysObj = {}; + + let desiredEncryptionLevelStr = settings.bucket_encryption_level || this.settings.bucket_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + async.series([ + function(cb) { + async.each(regions.keys, function(region, rcb) { + let keys = helpers.addSource( + cache, source, ['keys', 'list', region]); + if (keys && keys.data && keys.data.length) helpers.listToObj(keysObj, keys.data, 'id'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.bucket, function(region, rcb) { + if (helpers.checkRegionSubscription(cache, source, results, region)) { + var getBucket = helpers.addSource(cache, source, + ['bucket', 'get', region]); + + if (!getBucket) return rcb(); + + if (getBucket.err || !getBucket.data) { + helpers.addResult(results, 3, + 'Unable to query for object store bucket details: ' + helpers.addError(getBucket), region); + } else if (!getBucket.data.length) { + helpers.addResult(results, 0, 'No object store bucket details to check', region); + } else { + + getBucket.data.forEach(function(bucket) { + + let currentEncryptionLevel =1; //default + + if (bucket.kmsKeyId) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[bucket.kmsKeyId], helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Object store bucket (${bucket.name}) has encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, region, bucket.id); + } else { + helpers.addResult(results, 2, + `Object store bucket (${bucket.name}) has encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, region, bucket.id); + } + }); + } + } + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/oracle/objectstore/bucketCMKEncryption.spec.js b/plugins/oracle/objectstore/bucketCMKEncryption.spec.js new file mode 100644 index 000000000..c96a14bd8 --- /dev/null +++ b/plugins/oracle/objectstore/bucketCMKEncryption.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var plugin = require('./bucketCMKEncryption'); + +const getBucket = [ + { + "namespace": 'ns-1', + "name": 'my-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "publicAccessType": 'NoPublicAccess', + }, + { + "namespace": 'ns-1', + "name": 'akhtar-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "publicAccessType": 'ObjectRead', + "kmsKeyId": 'key-1' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + bucket: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + }, + keys: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "key-1", + "lifecycleState": "ENABLED", + "timeCreated": "2022-04-30T19:49:12.841Z", + "vaultId": "vault-1", + "protectionMode": "SOFTWARE", + "algorithm": "AES" + } + ], + + } + } + } + } +}; + +describe('bucketCMKEncryption', function () { + describe('run', function () { + it('should give unknown result if unable to query for object store bucket details', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for object store bucket details') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + undefined + + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no object store buckets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No object store bucket details to check') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if bucket does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is less') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if bucket has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is greater than or equal to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketObjectEvents.js b/plugins/oracle/objectstore/bucketObjectEvents.js new file mode 100644 index 000000000..367f0735c --- /dev/null +++ b/plugins/oracle/objectstore/bucketObjectEvents.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Bucket Object Events', + category: 'Object Store', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures object store buckets can emit object events.', + more_info: 'Object store buckets should be configured to emit object events in order to help monitor and keep track of bucket state changes.', + recommended_action: 'Ensure all object store buckets are allowed to emit object events.', + link: 'https://docs.cloud.oracle.com/iaas/Content/Object/Tasks/managingbuckets.htm', + apis: ['namespace:get','bucket:list', 'bucket:get'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.bucket, function(region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var getBucket = helpers.addSource(cache, source, + ['bucket', 'get', region]); + + if (!getBucket) return rcb(); + + if (getBucket.err || !getBucket.data) { + helpers.addResult(results, 3, + 'Unable to query for object store bucket details: ' + helpers.addError(getBucket), region); + } else if (!getBucket.data.length) { + helpers.addResult(results, 0, 'No object store bucket details to check', region); + } else { + + getBucket.data.forEach(function(bucket) { + if (bucket.objectEventsEnabled) { + helpers.addResult(results, 0, + `Object store bucket (${bucket.name}) can emit object events.`, region, bucket.id); + } else { + helpers.addResult(results, 2, + `Object store bucket (${bucket.name}) cannot emit object events.`, region, bucket.id); + } + }); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketObjectEvents.spec.js b/plugins/oracle/objectstore/bucketObjectEvents.spec.js new file mode 100644 index 000000000..9faad5563 --- /dev/null +++ b/plugins/oracle/objectstore/bucketObjectEvents.spec.js @@ -0,0 +1,134 @@ +var expect = require('chai').expect; +var plugin = require('./bucketObjectEvents'); + +const getBucket = [ + { + "namespace": 'idacicrnmktm', + "name": 'my-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "objectEventsEnabled": false + }, + { + "namespace": 'idacicrnmktm', + "name": 'akhtar-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "objectEventsEnabled": true + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + bucket: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketObjectEvents', function () { + describe('run', function () { + it('should give unknown result if unable to query for object store bucket details', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for object store bucket details') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['hello'], + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no object store buckets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No object store bucket details to check') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if bucket cannot emit object events', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('cannot emit object events') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if bucket can emit object events', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('can emit object events') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketPublicAccessType.js b/plugins/oracle/objectstore/bucketPublicAccessType.js index 2337d7429..a4bb3f710 100644 --- a/plugins/oracle/objectstore/bucketPublicAccessType.js +++ b/plugins/oracle/objectstore/bucketPublicAccessType.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Bucket Public Access Type', category: 'Object Store', + domain: 'Storage', + severity: 'Critical', description: 'Ensures object store buckets do not allow global write, delete, or read permissions', more_info: 'Object store buckets can be configured to allow anyone, regardless of whether they are an Oracle cloud user or not, to write objects to a bucket or delete objects. This option should not be configured unless there is a strong business requirement.', recommended_action: 'Disable global all users policies on all object store buckets and ensure the bucket is configured with the least privileges.', diff --git a/plugins/oracle/objectstore/bucketPublicAccessType.spec.js b/plugins/oracle/objectstore/bucketPublicAccessType.spec.js new file mode 100644 index 000000000..6b9df711a --- /dev/null +++ b/plugins/oracle/objectstore/bucketPublicAccessType.spec.js @@ -0,0 +1,134 @@ +var expect = require('chai').expect; +var plugin = require('./bucketPublicAccessType'); + +const getBucket = [ + { + "namespace": 'idacicrnmktm', + "name": 'my-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "publicAccessType": 'NoPublicAccess', + }, + { + "namespace": 'idacicrnmktm', + "name": 'akhtar-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "publicAccessType": 'ObjectRead' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + bucket: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketPublicAccessType', function () { + describe('run', function () { + it('should give unknown result if unable to query for object store bucket details', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for object store bucket details') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['hello'], + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no object store buckets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No object store bucket details to check') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if bucket allows public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('allows') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if bucket does not allow public access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('does not allow public access') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[0]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketVersioning.js b/plugins/oracle/objectstore/bucketVersioning.js new file mode 100644 index 000000000..2a2f011de --- /dev/null +++ b/plugins/oracle/objectstore/bucketVersioning.js @@ -0,0 +1,52 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Bucket Versioning', + category: 'Object Store', + domain: 'Storage', + severity: 'Low', + description: 'Ensures object store buckets have bucket versioning enabled.', + more_info: 'To minimize data loss in case of inadvertent or malicious deletes, make sure that all your object store buckets are configured with object versioning.', + recommended_action: 'Enable bucket versioning for all object store buckets.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingversioning.htm', + apis: ['namespace:get', 'bucket:list', 'bucket:get'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.bucket, function(region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var getBucket = helpers.addSource(cache, source, + ['bucket', 'get', region]); + + if (!getBucket) return rcb(); + + if (getBucket.err || !getBucket.data) { + helpers.addResult(results, 3, + 'Unable to query for object store bucket details: ' + helpers.addError(getBucket), region); + } else if (!getBucket.data.length) { + helpers.addResult(results, 0, 'No object store bucket details to check', region); + } else { + getBucket.data.forEach(function(bucket) { + if (bucket.versioning && + bucket.versioning === 'Enabled') { + helpers.addResult(results, 0, + `Object store bucket (${bucket.name}) has versioning enabled`, region, bucket.id); + } else { + helpers.addResult(results, 2, + `Object store bucket (${bucket.name}) does not have versioning enabled`, region, bucket.id); + } + }); + } + } + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketVersioning.spec.js b/plugins/oracle/objectstore/bucketVersioning.spec.js new file mode 100644 index 000000000..2501b495d --- /dev/null +++ b/plugins/oracle/objectstore/bucketVersioning.spec.js @@ -0,0 +1,134 @@ +var expect = require('chai').expect; +var plugin = require('./bucketVersioning'); + +const getBucket = [ + { + "namespace": 'idacicrnmktm', + "name": 'my-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "versioning": 'Disabled', + }, + { + "namespace": 'idacicrnmktm', + "name": 'akhtar-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + "versioning": 'Enabled' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + bucket: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('bucketVersioning', function () { + describe('run', function () { + it('should give unknown result if unable to query for object store bucket details', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for object store bucket details') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['hello'], + undefined + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no object store buckets', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No object store bucket details to check') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if bucket has versioning enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('has versioning enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[1]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if bucket does not have versioning enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('does not have versioning enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getBucket[0]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketWriteLogsEnabled.js b/plugins/oracle/objectstore/bucketWriteLogsEnabled.js new file mode 100644 index 000000000..751a6fa0f --- /dev/null +++ b/plugins/oracle/objectstore/bucketWriteLogsEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Bucket Write Logs Enabled', + category: 'Object Store', + domain: 'Management and Governance', + severity: 'Medium', + description: 'Ensures write level Object Storage logging is enabled for all buckets.', + more_info: 'Enabling write level logging for object store buckets will provide you more visibility into changes to objects in your buckets.', + link: 'https://docs.cloud.oracle.com/iaas/Content/Object/Tasks/managingbuckets.htm', + recommended_action: 'Enable write level logging for each object store.', + apis: ['namespace:get', 'bucket:list', 'logGroup:list', 'log:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.bucket, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var buckets = helpers.addSource(cache, source, + ['bucket', 'list', region]); + + if (!buckets) return rcb(); + + if (buckets.err || !buckets.data) { + helpers.addResult(results, 3, + 'Unable to query for object store buckets: ' + helpers.addError(buckets), region); + return rcb(); + } + + if (!buckets.data.length) { + helpers.addResult(results, 0, 'No object store buckets to check', region); + return rcb(); + } + + var logs = helpers.addSource(cache, source, + ['log', 'list', region]); + + buckets.data.forEach(bucket => { + let bucketLog = null; + + if (logs && !logs.err && logs.data && logs.data.length) { + bucketLog = logs.data.find(log => log.isEnabled && log.configuration + && Object.keys(log.configuration).length && log.configuration.source + && Object.keys(log.configuration.source).length && log.configuration.source.service === 'objectstorage' + && log.configuration.source.category === 'write' + && log.configuration.source.resource === bucket.name); + } + + if (bucketLog) { + helpers.addResult(results, 0, 'The bucket has write level logging enabled', region, bucket.id); + } else { + helpers.addResult(results, 2, 'The bucket does not have write level logging enabled', region, bucket.id); + } + }); + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/oracle/objectstore/bucketWriteLogsEnabled.spec.js b/plugins/oracle/objectstore/bucketWriteLogsEnabled.spec.js new file mode 100644 index 000000000..010dfe338 --- /dev/null +++ b/plugins/oracle/objectstore/bucketWriteLogsEnabled.spec.js @@ -0,0 +1,169 @@ +var expect = require('chai').expect; +var plugin = require('./bucketWriteLogsEnabled'); + +const buckets = [ + { + "namespace": 'idacicrnmktm', + "name": 'my-bucket', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + }, + { + "namespace": 'idacicrnmktm', + "name": 'my-bucket-1', + "id": 'ocid1.bucket.oc1.iad.111111111111111122222222222222222233333333333333333', + "compartmentId": 'ocid1.tenancy.oc1..11111111111111111222222222222222222333333333333333', + "createdBy": 'ocid1.user.oc1..11111111111111112222222222222223333333333333333', + "timeCreated": '2021-04-28T13:26:51.917Z', + } +]; + +const logs = [ + { + "id": "ocid1.log.oc1.log11111111", + "logGroupId": "ocid1.loggroup.oc1.loggroup11111", + "displayName": "bucket_log", + "isEnabled": true, + "lifecycleState": "ACTIVE", + "logType": "SERVICE", + "configuration": { + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "source": { + "sourceType": "OCISERVICE", + "service": "objectstorage", + "resource": "my-bucket-1", + "category": "write", + "parameters": {} + }, + "archiving": { + "isEnabled": false + } + }, + "freeformTags": {}, + "timeCreated": "2022-07-15T00:25:53.258Z", + "timeLastModified": "2022-07-15T00:25:53.258Z", + "retentionDuration": 30, + "compartmentId": "ocid1.tenancy.oc1.1111111111111", + "logGroups": "ocid1.loggroup.oc1.loggroup11111" + } +] + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + + bucket: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + log: { + list: { + 'us-ashburn-1': { + err: null, + data: logs + } + } + } + } +}; + +describe('bucketWriteLogsEnabled', function () { + describe('run', function () { + it('should give unknown result if a bucket error occurs or no data is present', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for object store buckets') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null, + ); + + plugin.run(cache, {}, callback); + }) + it('should give passing result if no buckets are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No object store buckets to check') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [], + ); + + plugin.run(cache, {}, callback); + }) + it('should give failing result if the bucket does not have write level logs enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('The bucket does not have write level logging enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [buckets[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if the bucket has write level logs enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('The bucket has write level logging enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [buckets[1]] + ); + + plugin.run(cache, {}, callback); + }) + }) +}) \ No newline at end of file diff --git a/plugins/oracle/objectstore/objectPolicyProtection.js b/plugins/oracle/objectstore/objectPolicyProtection.js index f636eeb8f..b20c2e3aa 100644 --- a/plugins/oracle/objectstore/objectPolicyProtection.js +++ b/plugins/oracle/objectstore/objectPolicyProtection.js @@ -1,9 +1,10 @@ -var async = require('async'); var helpers = require('../../../helpers/oracle'); module.exports = { title: 'Object Store Policy Protection', category: 'Object Store', + domain: 'Storage', + severity: 'Medium', description: 'Ensure policy statements have deletion protection for object store services unless it is an administrator group.', more_info: 'Adding deletion protection to Oracle object store policies mitigates unintended deletion of object store services by unauthorized users or groups.', link: 'https://docs.cloud.oracle.com/iaas/Content/Object/Tasks/managingobjects.htm', @@ -16,8 +17,8 @@ module.exports = { settings: { policy_group_admins: { name: 'Admin groups with delete permissions.', - description: 'The admin groups allowed to delete resources.', - regex: '(?im)^([a-z_](?:\\.\\-\\w|\\-\\.\\w|\\-\\w|\\.\\w|\\w)+)$', + description: 'Comma separated list of the admin groups allowed to delete resources.', + regex: '^.{1,255}$', default: 'Administrators' }, }, @@ -25,90 +26,55 @@ module.exports = { run: function (cache, settings, callback) { var results = []; var source = {}; - var regions = helpers.regions(settings.govcloud); + var config = { policy_group_admins: settings.policy_group_admins || this.settings.policy_group_admins.default }; - - async.each(regions.default, function (region, rcb) { - - var policies = helpers.addSource(cache, source, - ['policy', 'list', region]); - - if (!policies) return rcb(); - - if (policies.err || !policies.data) { - helpers.addResult(results, 3, - 'Unable to query for policies: ' + helpers.addError(policies), region); - return rcb(); - } - - if (!policies.data.length) { - helpers.addResult(results, 0, 'No policies found', region); - return rcb(); + + var region = helpers.objectFirstKey(cache['regionSubscription']['list']); + + var policies = helpers.addSource(cache, source, + ['policy', 'list', region]); + + if (!policies) return callback(null, results, source); + + if (policies.err || !policies.data) { + helpers.addResult(results, 3, + 'Unable to query for policies: ' + helpers.addError(policies), region); + return callback(null, results, source); + } + + if (!policies.data.length) { + helpers.addResult(results, 0, 'No policies found', region); + return callback(null, results, source); + } + var policyProtection = true; + var entered = false; + + var resourceTypes = ['objects', 'buckets']; + policies.data.forEach(policy => { + if (policy.statements && + policy.statements.length) { + entered = true; + policy.statements.forEach(statement => { + var statementObj = helpers.normalizePolicyStatement(statement); + var statementPasses = helpers.testStatement(statementObj, resourceTypes, config.policy_group_admins); + + if (!statementPasses) { + policyProtection = false; + + helpers.addResult(results, 2, + `${statementObj['subjectType']}${statementObj['subject']} has the ability to delete all object store services in ${statementObj['location']}`, region, policy.id); + } + }); } - var policyProtection = true; - var entered = false; - policies.data.forEach(policy => { - if (policy.statements && - policy.statements.length) { - entered = true; - policy.statements.forEach(statement => { - - const statementLower = statement.toLowerCase(); - - if (statementLower.indexOf('allow') > -1 && - (statementLower.indexOf('manage') > -1 || - statementLower.indexOf('use') > -1) && - (statementLower.indexOf('request.permission') === -1 && - statementLower.indexOf('!=') === -1 && - statementLower.indexOf('_delete') === -1 && - (statementLower.indexOf('object_') === -1 || - statementLower.indexOf('bucket_') === -1)) && - (statementLower.indexOf('objects') > -1 || - statementLower.indexOf('buckets') > -1 || - statementLower.indexOf('all-resources') > -1)) { - - policyProtection = false; - var statementArr = statementLower.split(' '); - var statementNormalArr = statement.split(' '); - var severity = 2; - - if (statementArr[1] === 'any-user' || statementArr[1] === 'dynamic-group') { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[6] === 'tenancy' ? 'tenancy' : statementArr[6]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[7]; - var groupType = statementArr[1]; - } else { - var groupName = statementArr[2] === 'to' ? '' : statementNormalArr[2]; - var compartment = statementArr[7] === 'tenancy' ? 'tenancy' : statementArr[7]; - var compartmentName = (!statementArr[7] || statementArr[7] === 'tenancy') ? '' : statementNormalArr[8]; - var groupType = 'The ' + statementArr[1]; - } - - if (groupName === config.policy_group_admins.toLowerCase()) return; - if (statementArr.indexOf('request.user.name') > -1) { - groupType = 'The user'; - groupName = statementArr[statementArr.length - 1]; - severity = 1; - } - - helpers.addResult(results, severity, - `${groupType} ${groupName} has the ability to delete all object store services in ${compartment} ${compartmentName}`, region, policy.id); - } - }); - } - }); + }); - if (policyProtection && entered) { - helpers.addResult(results, 0, 'All policies have object store delete protection enabled', region); - } + if (policyProtection && entered) { + helpers.addResult(results, 0, 'All policies have object store delete protection enabled', region); + } - rcb(); - }, function () { - // Global checking goes here - callback(null, results, source); - }); + callback(null, results, source); } }; \ No newline at end of file diff --git a/plugins/oracle/objectstore/objectPolicyProtection.spec.js b/plugins/oracle/objectstore/objectPolicyProtection.spec.js index 96e15ec94..495222e79 100644 --- a/plugins/oracle/objectstore/objectPolicyProtection.spec.js +++ b/plugins/oracle/objectstore/objectPolicyProtection.spec.js @@ -170,7 +170,7 @@ describe('objectPolicyProtection', function () { [ { "statements": [ - "Allow group Developers to manage objects in tenancy where request.permission!='OBJECT_DELETE'", + "Allow group Developers to read objects in tenancy where target.user.name == 'OBJECT_DELETE'", ], "id": "ocid1.policy.oc1..aaaaaaaapil3afuz45oxyvd3u73otqbsj4atjdorao6nvfr3yjqumnniscka", "compartmentId": "ocid1.tenancy.oc1..aaaaaaaao43aqdrzuacodg7ffqv2zeauftjyjkwhnbrugt44ympzeiblxx7q", diff --git a/plugins/oracle/objectstore/preAuthRequestsAccess.js b/plugins/oracle/objectstore/preAuthRequestsAccess.js index a6aad4bc0..9e9de975f 100644 --- a/plugins/oracle/objectstore/preAuthRequestsAccess.js +++ b/plugins/oracle/objectstore/preAuthRequestsAccess.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Pre-Authenticated Requests Access', category: 'Object Store', + domain: 'Storage', + severity: 'High', description: 'Ensure that pre-authenticated requests have least privilege access.', more_info: 'Pre-authenticated requests allow for users who are not in the tenancy to access buckets, ensuring least access prevents malicious entities from leveraging this type of access to edit or delete objects in a bucket.', recommended_action: 'When creating pre-authenticated Requests, ensure only object read permissions are selected.', diff --git a/plugins/oracle/objectstore/preAuthRequestsExpiry.js b/plugins/oracle/objectstore/preAuthRequestsExpiry.js index 554495594..4286bcee3 100644 --- a/plugins/oracle/objectstore/preAuthRequestsExpiry.js +++ b/plugins/oracle/objectstore/preAuthRequestsExpiry.js @@ -4,6 +4,8 @@ var helpers = require('../../../helpers/oracle/'); module.exports = { title: 'Pre-Authenticated Requests Expiry', category: 'Object Store', + domain: 'Storage', + severity: 'High', description: 'Ensure that pre-authenticated requests expire within a certain time.', more_info: 'Pre-authenticated requests allow for users who are not in the tenancy to access buckets, having a short expiration time-frame ensures that access does not last longer than intended.', recommended_action: 'When creating pre-authenticated Requests, ensure the expiration date-time is limited to the minimum time possible.', diff --git a/plugins/oracle/oke/okePrivateEndpoint.js b/plugins/oracle/oke/okePrivateEndpoint.js new file mode 100644 index 000000000..b92c3ae82 --- /dev/null +++ b/plugins/oracle/oke/okePrivateEndpoint.js @@ -0,0 +1,60 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'OKE Private Endpoint', + category: 'OKE', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures the private endpoint setting is enabled for OKE clusters.', + more_info: 'OKE private endpoints can be used to route all traffic between the Kubernetes worker and control plane nodes over a private VCN endpoint rather than across the public internet.', + recommended_action: 'Enable the private endpoint setting for all OKE clusters.', + link: 'https://docs.oracle.com/en-us/iaas/Content/ContEng/Concepts/contengclustersnodes.htm#processes', + apis: ['cluster:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.cluster, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var clusters = helpers.addSource(cache, source, + ['cluster', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, + 'Unable to query for OKE clusters: ' + helpers.addError(clusters), region); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No OKE clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + if (cluster.lifecycleState && cluster.lifecycleState === 'DELETED') return; + + if (cluster.endpointConfig && cluster.endpointConfig.isPublicIpEnabled) { + helpers.addResult(results, 2, 'OKE cluster does not have private endpoint enabled', region, cluster.id); + } else { + helpers.addResult(results, 0, 'OKE cluster has private endpoint enabled', region, cluster.id); + } + }); + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + + + \ No newline at end of file diff --git a/plugins/oracle/oke/okePrivateEndpoint.spec.js b/plugins/oracle/oke/okePrivateEndpoint.spec.js new file mode 100644 index 000000000..8627618c0 --- /dev/null +++ b/plugins/oracle/oke/okePrivateEndpoint.spec.js @@ -0,0 +1,140 @@ +var expect = require('chai').expect; +var plugin = require('./okePrivateEndpoint'); + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + cluster: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + } + } +}; + +describe('okePrivateEndpoint', function () { + describe('run', function () { + it('should give unknown result if a cluster error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for OKE clusters') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no oke clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No OKE clusters found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if OKE cluster does not have private endpoint enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('does not have private endpoint enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "cluster1", + "name": "cluster1", + "endpointConfig": { + "isPublicIpEnabled": true + }, + "endpoints": { + "kubernetes": null, + "publicEndpoint": "10.0.0.0:8080", + "privateEndpoint": null, + }, + } + ] + + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if oke cluster has private endpoint enabled', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('has private endpoint enabled') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [ + { + "id": "cluster1", + "name": "cluster1", + "endpoints": { + "kubernetes": null, + "publicEndpoint": null, + "privateEndpoint": "10.0.0.0:8080", + }, + "endpointConfig": { + "isPublicIpEnabled": false + }, + } + ] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/oke/okeSecretsEncrypted.js b/plugins/oracle/oke/okeSecretsEncrypted.js new file mode 100644 index 000000000..c9ac51295 --- /dev/null +++ b/plugins/oracle/oke/okeSecretsEncrypted.js @@ -0,0 +1,98 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'OKE Secrets Encrypted', + category: 'OKE', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures the OKE secret objects have encryption enabled using desired protection level.', + more_info: 'By default, Kubernetes secret objects are encrypted using an Oracle-managed master encryption key. To have better control over the encryption process, you can use Customer-Managed Keys (CMKs).', + recommended_action: 'Ensure all OKE clusters have desired encryption level for secret objects.', + link: 'https://docs.oracle.com/en-us/iaas/Content/ContEng/Tasks/contengencryptingdata.htm', + apis: ['vault:list', 'keys:list', 'cluster:list', 'cluster:get'], + settings: { + oke_encryption_level: { + name: 'OKE Encryption Level', + description: 'Desired protection level for OKE Secrets. default: oracle-managed, cloudcmek: customer managed encryption keys, ' + + 'cloudhsm: customer managed HSM encryption key', + regex: '^(default|cloudcmek|cloudhsm)$', + default: 'cloudcmek' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + var keysObj = {}; + + let desiredEncryptionLevelStr = settings.oke_encryption_level || this.settings.oke_encryption_level.default; + var desiredEncryptionLevel = helpers.PROTECTION_LEVELS.indexOf(desiredEncryptionLevelStr); + + async.series([ + function(cb) { + async.each(regions.keys, function(region, rcb) { + let keys = helpers.addSource( + cache, source, ['keys', 'list', region]); + if (keys && keys.data && keys.data.length) helpers.listToObj(keysObj, keys.data, 'id'); + rcb(); + }, function() { + cb(); + }); + }, + function(cb) { + async.each(regions.cluster, function(region, rcb) { + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var clusters = helpers.addSource(cache, source, + ['cluster', 'get', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, + 'Unable to query for OKE clusters: ' + helpers.addError(clusters), region); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No OKE clusters found', region); + return rcb(); + } + + clusters.data.forEach(cluster => { + if (cluster.lifecycleState && cluster.lifecycleState === 'DELETED') return; + + let currentEncryptionLevel =1; //default + + if (cluster.kmsKeyId) { + currentEncryptionLevel = helpers.getProtectionLevel(keysObj[cluster.kmsKeyId], helpers.PROTECTION_LEVELS); + } + + let currentEncryptionLevelStr = helpers.PROTECTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `OKE cluster (${cluster.name}) has secret encryption level ${currentEncryptionLevelStr} which is greater than or equal to ${desiredEncryptionLevelStr}`, region, cluster.id); + } else { + helpers.addResult(results, 2, + `OKE cluster (${cluster.name}) has secret encryption level ${currentEncryptionLevelStr} which is less than ${desiredEncryptionLevelStr}`, region, cluster.id); + } + }); + } + + rcb(); + }, function() { + cb(); + }); + } + ], function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/oracle/oke/okeSecretsEncrypted.spec.js b/plugins/oracle/oke/okeSecretsEncrypted.spec.js new file mode 100644 index 000000000..2dbefe3b2 --- /dev/null +++ b/plugins/oracle/oke/okeSecretsEncrypted.spec.js @@ -0,0 +1,178 @@ +var expect = require('chai').expect; +var plugin = require('./okeSecretsEncrypted'); + +const getCluster = [ + { + "id": "cluster1", + "name": "cluster1", + "endpointConfig": { + "isPublicIpEnabled": true + }, + "endpoints": { + "kubernetes": null, + "publicEndpoint": "10.0.0.0:8080", + "privateEndpoint": null, + }, + }, + { + "id": "cluster1", + "name": "cluster1", + "endpointConfig": { + "isPublicIpEnabled": true + }, + "endpoints": { + "kubernetes": null, + "publicEndpoint": "10.0.0.0:8080", + "privateEndpoint": null, + }, + "kmsKeyId": 'key-1' + } +]; + +const createCache = (err, data) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + cluster: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + }, + keys: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "key-1", + "lifecycleState": "ENABLED", + "timeCreated": "2022-04-30T19:49:12.841Z", + "vaultId": "vault-1", + "protectionMode": "SOFTWARE", + "algorithm": "AES" + } + ], + + } + } + } + } +}; + +describe('okeSecretsEncrypted', function () { + describe('run', function () { + it('should give unknown result if a cluster error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for OKE clusters') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no oke clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No OKE clusters found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give failing result if oke cluster does not have desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is less') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getCluster[0]] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if oke cluster has desired encryption level', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is greater than or equal to') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [getCluster[1]] + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/oracle/oke/okeSecurityGroups.js b/plugins/oracle/oke/okeSecurityGroups.js new file mode 100644 index 000000000..863adaad0 --- /dev/null +++ b/plugins/oracle/oke/okeSecurityGroups.js @@ -0,0 +1,117 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'OKE Security Groups', + category: 'OKE', + domain: 'Containers', + severity: 'Medium', + description: 'Ensures the OKE clusters only allows inbound traffic on port 443.', + more_info: 'The OKE clusters only requires port 443 access. Security groups for the clusters should not add additional port access.', + link: 'https://docs.oracle.com/en-us/iaas/Content/Security/Reference/oke_security.htm', + recommended_action: 'Configure security groups for the OKE clusters to allow access only on port 443.', + apis: ['vcn:list', 'cluster:list', 'networkSecurityGroup:list', 'securityRule:list'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + async.each(regions.cluster, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var clusters = helpers.addSource(cache, source, + ['cluster', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, + 'Unable to query for OKE clusters: ' + helpers.addError(clusters), region); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No OKE clusters found', region); + return rcb(); + } + + var securityGroups = helpers.addSource(cache, source, + ['networkSecurityGroup', 'list', region]); + + if (!securityGroups || securityGroups.err || !securityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for network security groups: ' + helpers.addError(securityGroups), region); + return rcb(); + + } + + var securityRules = helpers.addSource(cache, source, + ['securityRule', 'list', region]); + + if (!securityRules || securityRules.err || !securityRules.data) { + helpers.addResult(results, 3, + 'Unable to query for security rules: ' + helpers.addError(securityRules), region); + return rcb(); + + } + + securityGroups.data.forEach(securityGroup => { + if (securityRules && securityRules.data.find(securityRule => securityRule.networkSecurityGroups === securityGroup.id)) { + securityGroup.securityRules = securityRules.data.filter(securityRule => securityRule.networkSecurityGroups === securityGroup.id); + } + }); + + clusters.data.forEach(cluster => { + if (!cluster.vcnId) return; + + var allowsOtherPorts = false; + const clusterSecurityGroups = securityGroups.data.filter(securityGroup => securityGroup.vcnId === cluster.vcnId); + + clusterSecurityGroups.forEach(group => { + + if (!group.securityRules || !group.securityRules.length) { + allowsOtherPorts = true; + return; + } + + if (group.securityRules) { + group.securityRules.map(rule => { + if (rule.direction === 'INGRESS') { + var ruleOptions = rule.tcpOptions || rule.udpOptions; + if (!ruleOptions) { + allowsOtherPorts = true; + } else if (ruleOptions.sourcePortRange + && ruleOptions.sourcePortRange.max && ruleOptions.sourcePortRange.max != 443 + && ruleOptions.sourcePortRange.minf && ruleOptions.sourcePortRange.minf != 443 + && ruleOptions.destinationPortRange + && ruleOptions.destinationPortRange.max && ruleOptions.destinationPortRange.max != 443 + && ruleOptions.destinationPortRange.minf && ruleOptions.destinationPortRange.minf != 443 + ) { + allowsOtherPorts = true; + } + } + } + ); + } + }); + + if (allowsOtherPorts) { + helpers.addResult(results, 2, 'OKE cluster security groups allow additional access on unnecessary ports', region, cluster.id); + } else { + helpers.addResult(results, 0, 'OKE cluster security groups do not allow additional access on unnecessary ports', region, cluster.id); + } + }); + } + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + + + \ No newline at end of file diff --git a/plugins/oracle/oke/okeSecurityGroups.spec.js b/plugins/oracle/oke/okeSecurityGroups.spec.js new file mode 100644 index 000000000..3663451e1 --- /dev/null +++ b/plugins/oracle/oke/okeSecurityGroups.spec.js @@ -0,0 +1,250 @@ +var expect = require('chai').expect; +var plugin = require('./okeSecurityGroups'); + +const clusters = [ + { + id: 'ocid1.cluster.oc1', + name: 'cluster1', + compartmentId: 'ocid1.tenancy.oc1.aaa111111', + endpointConfig: { + subnetId: 'ocid1.subnet.oc1.aaaaa', + isPublicIpEnabled: false + }, + vcnId: 'ocid1.vcn.oc1.vcn-1', + kubernetesVersion: 'v1.22.5' + } +] +const securityRules = [ + { + direction: 'INGRESS', + id: '1111', + isStateless: false, + isValid: true, + protocol: '6', + source: 'ocid1.networksecuritygroup.oc1.nsg-1', + sourceType: 'NETWORK_SECURITY_GROUP', + tcpOptions: { + destinationPortRange: { max: 443, min: 443 }, + sourcePortRange: { max: 443, min: 443 } + }, + timeCreated: '2022-06-26T22:08:03.792Z', + networkSecurityGroups: 'ocid1.networksecuritygroup.oc1.nsg-1' + }, + { + direction: 'INGRESS', + id: '111', + isStateless: false, + isValid: true, + protocol: 'all', + source: 'ocid1.networksecuritygroup.oc1.nsg-1', + sourceType: 'NETWORK_SECURITY_GROUP', + timeCreated: '2022-06-26T22:09:22.114Z', + networkSecurityGroups: 'ocid1.networksecuritygroup.oc1.nsg-1' + } + +]; + +const securityGroups = [ + { + compartmentId: 'ocid1.tenancy.oc1.11111', + displayName: 'firstgroup', + freeformTags: {}, + id: 'ocid1.networksecuritygroup.oc1.nsg-1', + lifecycleState: 'AVAILABLE', + timeCreated: '2022-01-09T13:03:47.999Z', + vcnId: 'ocid1.vcn.oc1.vcn-1' + } +] + +const createCache = (data, err, securityGroupData, securityGroupErr, securityRuleData, securityRuleErr) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + cluster: { + list: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + networkSecurityGroup: { + list: { + 'us-ashburn-1': { + err: securityGroupErr, + data: securityGroupData + } + } + }, + securityRule: { + list: { + 'us-ashburn-1': { + err: securityRuleErr, + data: securityRuleData + } + } + } + } +}; + +describe('okeSecurityGroups', function () { + describe('run', function () { + + it('should give unknown result if a cluster error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for OKE clusters') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + ['error'], + securityGroups, + null, + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no oke clusters are found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No OKE clusters found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + [], + null, + null, + securityGroups, + null, + securityRules, + null + + ); + + plugin.run(cache, {}, callback); + }) + + + it('should give unknown result if a security rule error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for security rules') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + clusters, + null, + securityGroups, + null, + null, + ['error'] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give unknown result if a security group error occurs', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for network security groups') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + clusters, + null, + null, + ['error'], + securityRules, + null + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if oke cluster security groups allow acces on unnecessary port ranges', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('OKE cluster security groups allow additional access on unnecessary ports') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + clusters, + null, + securityGroups, + null, + securityRules, + null + + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if oke cluster security groups do not allow acces on unnecessary port ranges', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('do not allow additional access on unnecessary ports') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + clusters, + null, + securityGroups, + null, + [securityRules[0]], + null + + ); + + plugin.run(cache, {}, callback); + }) + + + }) +}) \ No newline at end of file diff --git a/plugins/oracle/vaults/keyRotation.js b/plugins/oracle/vaults/keyRotation.js new file mode 100644 index 000000000..df69ed5f7 --- /dev/null +++ b/plugins/oracle/vaults/keyRotation.js @@ -0,0 +1,89 @@ +var async = require('async'); +var helpers = require('../../../helpers/oracle'); + +module.exports = { + title: 'Key Rotation', + category: 'Vaults', + domain: 'Application Integration', + severity: 'Medium', + description: 'Ensure that your OCI Vault Keys are periodically rotated.', + more_info: 'Rotating keys periodically limits the data encrypted under one key version. Key rotation thereby reduces the risk in case a key is ever compromised.', + link: 'https://docs.oracle.com/en-us/iaas/Content/KeyManagement/Tasks/managingkeys.htm', + recommended_action: 'Ensure that all your cryptographic keys are regenerated (rotated) after a specific period.', + apis: ['vault:list', 'keys:list', 'keys:get', 'keyVersions:list'], + settings: { + key_rotation_interval: { + name: 'Key Rotation Interval', + description: 'Return a failing result when keys exceed this number of days without being rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '365' + } + }, + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings.govcloud); + + var keyRotationInterval = parseInt(settings.key_rotation_interval || this.settings.key_rotation_interval.default); + + async.each(regions.keys, function(region, rcb){ + + if (helpers.checkRegionSubscription(cache, source, results, region)) { + + var keys = helpers.addSource(cache, source, + ['keys', 'get', region]); + + if (!keys) return rcb(); + + if (keys.err || !keys.data) { + helpers.addResult(results, 3, + 'Unable to query for cryptographic keys: ' + helpers.addError(keys), region); + return rcb(); + } + + if (!keys.data.length) { + helpers.addResult(results, 0, 'No cryptographic keys found', region); + return rcb(); + } + + var keyVersions = helpers.addSource(cache, source, + ['keyVersions', 'list', region]); + + if (!keyVersions) return rcb(); + + if (keyVersions.err || !keyVersions.data) { + helpers.addResult(results, 3, + 'Unable to query for cryptographic key versions: ' + helpers.addError(keyVersions), region); + return rcb(); + } + + if (!keyVersions.data.length) { + helpers.addResult(results, 0, 'No key versions found', region); + return rcb(); + } + + keys.data.forEach(key => { + const currentKeyVersion = keyVersions.data.find(version => version.id === key.currentKeyVersion); + + let timeCreated = currentKeyVersion ? currentKeyVersion.timeCreated : key.timeCreated; + var diffInDays = helpers.daysBetween(timeCreated, new Date()); + + if (diffInDays > keyRotationInterval) { + helpers.addResult(results, 2, + `Cryptographic Key was last rotated ${diffInDays} days ago which is greater than ${keyRotationInterval}`, region, key.id); + } else { + helpers.addResult(results, 0, + `Cryptographic Key was last rotated ${diffInDays} days ago which is equal to or less than ${keyRotationInterval}`, region, key.id); + } + }); + + + } + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/oracle/vaults/keyRotation.spec.js b/plugins/oracle/vaults/keyRotation.spec.js new file mode 100644 index 000000000..6d3f8ec03 --- /dev/null +++ b/plugins/oracle/vaults/keyRotation.spec.js @@ -0,0 +1,246 @@ +var expect = require('chai').expect; +var plugin = require('./keyRotation'); + +var passDate = new Date(); +passDate.setMonth(passDate.getMonth() - 1); +var failDate = new Date(); +failDate.setYear(failDate.getYear() - 1); + +const keys = [ + { + "compartmentId": "ocid1.tenancy.oc1..aaaa111111", + "currentKeyVersion": "ocid1.keyversion.oc1.iad.bbbbbbbb.version1", + "definedTags": {}, + "displayName": "key1", + "freeformTags": {}, + "id": "ocid1.key.oc1.iad.bbbbbbbb.key1", + "protectionMode": "HSM", + "lifecycleState": "ENABLED", + "timeCreated": failDate, + }, + { + "compartmentId": "ocid1.tenancy.oc1..aaaa111111", + "currentKeyVersion": "ocid1.keyversion.oc1.iad.bbbbbbb.version2", + "definedTags": {}, + "displayName": "key-1", + "freeformTags": {}, + "id": "ocid1.key.oc1.iad.bbbbbbb.key2", + "protectionMode": "SOFTWARE", + "lifecycleState": "ENABLED", + "timeCreated": failDate + } +]; + +const keyVersions = [ + { + "compartmentId": 'ocid1.tenancy.oc1..aaaa111111', + "id": 'ocid1.keyversion.oc1.iad.bbbbbbb.awemmjwzfoaaa.abuwcljrc4nsujs33cxy6o7bf5hcjtq5fce7cqk56o3bcxh4pwkobfbkhpba', + "keyId": 'ocid1.key.oc1.iad.bbbbbbb.key2', + "lifecycleState": 'ENABLED', + "origin": 'INTERNAL', + "timeCreated": failDate, + "timeOfDeletion": null, + "vaultId": 'ocid1.vault.oc1.iad.bbbbbbb.vault1', + "keys": 'ocid1.key.oc1.iad.bbbbbbb.key2' + }, + { + "compartmentId": 'ocid1.tenancy.oc1..aaaa111111', + "id": 'ocid1.keyversion.oc1.iad.bbbbbbb.version2', + "keyId": 'ocid1.key.oc1.iad.bbbbbbb.key2', + "lifecycleState": 'ENABLED', + "origin": 'INTERNAL', + "timeCreated": passDate, + "timeOfDeletion": null, + "vaultId": 'ocid1.vault.oc1.iad.bbbbbbb.vault1', + "keys": 'ocid1.key.oc1.iad.bbbbbbb.key2' + }, + { + "compartmentId": 'ocid1.tenancy.oc1..aaaa111111', + "id": 'ocid1.keyversion.oc1.iad.bbbbbbb.awemmjwzfoaaa.abuwcljrc4nsujs33cxy6o7bf5hcjtq5fce7cqk56o3bcxh4pwkobfbkhpba', + "keyId": 'ocid1.key.oc1.iad.bbbbbbbb.key1', + "lifecycleState": 'ENABLED', + "origin": 'INTERNAL', + "timeCreated": failDate, + "timeOfDeletion": null, + "vaultId": 'ocid1.vault.oc1.iad.bbbbbbb.vault1', + "keys": 'ocid1.key.oc1.iad.bbbbbbb.key2' + } +] + +const createCache = (err, data, versionErr, versionData) => { + return { + regionSubscription: { + "list": { + "us-ashburn-1": { + "data": [ + { + "regionKey": "IAD", + "regionName": "us-ashburn-1", + "status": "READY", + "isHomeRegion": true + }, + { + "regionKey": "LHR", + "regionName": "uk-london-1", + "status": "READY", + "isHomeRegion": false + }, + { + "regionKey": "PHX", + "regionName": "us-phoenix-1", + "status": "READY", + "isHomeRegion": false + } + ] + } + } + }, + keys: { + get: { + 'us-ashburn-1': { + err: err, + data: data + } + } + }, + keyVersions: { + list: { + 'us-ashburn-1': { + err: versionErr, + data: versionData + } + } + }, + vault: { + list: { + 'us-ashburn-1': { + data: [ + { + "compartmentId": "compartment-1", + "displayName": "vault-1", + "freeformTags": {}, + "id": "vault-1", + "lifecycleState": "ACTIVE", + }, + ] + } + } + } + } +}; + +describe('keyRotation', function () { + describe('run', function () { + it('should give unknown result if unable to query for cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for cryptographic keys') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + ['error'], + undefined + + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no cryptographic keys', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No cryptographic keys found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give unknown result if unable to query for cryptographic key versions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(3) + expect(results[0].message).to.include('Unable to query for cryptographic key versions') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + keys, + ['error'], + undefined + + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if no key versions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('No key versions found') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + keys, + null, + [] + ); + + plugin.run(cache, {}, callback); + }) + + it('should give failing result if cryptographic key has not been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(2) + expect(results[0].message).to.include('which is greater than') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [keys[0]], + null, + keyVersions + ); + + plugin.run(cache, {}, callback); + }) + + it('should give passing result if cryptographic key has been rotated within set days limit', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0) + expect(results[0].status).to.equal(0) + expect(results[0].message).to.include('which is equal to or less than') + expect(results[0].region).to.equal('us-ashburn-1') + done() + }; + + const cache = createCache( + null, + [keys[1]], + null, + keyVersions + ); + + plugin.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/postprocess/output.js b/postprocess/output.js index 3be8bb7a3..fdf30b4a8 100644 --- a/postprocess/output.js +++ b/postprocess/output.js @@ -1,4 +1,3 @@ -var csvWriter = require('csv-write-stream'); var fs = require('fs'); var ttytable = require('tty-table'); @@ -14,6 +13,16 @@ function commaSafe(str) { return str.replace(/,/g, ' '); } +function XMLSafe(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function log(msg, settings) { if (!settings.mocha) console.log(msg); } @@ -85,6 +94,7 @@ module.exports = { var headers = ['category', 'title', 'description', 'resource', 'region', 'statusWord', 'message']; if (settings.compliance) headers.push('compliance'); + var csvWriter = require('csv-write-stream'); var writer = csvWriter({headers: headers}); writer.pipe(stream); @@ -177,7 +187,7 @@ module.exports = { time = time.substr(0, time.indexOf('.')); this.testSuites[suiteName] = { - name: plugin.title + ': ' + (plugin.description || ''), + name: XMLSafe(plugin.title + ': ' + (plugin.description || '')), package: pluginKey, failures: 0, errors: 0, @@ -202,12 +212,12 @@ module.exports = { var name = result.region + '; ' + (result.resource || 'N/A') + '; ' + result.message; testSuite.testCases.push({ - name: name, + name: XMLSafe(name), classname: pluginKey, file: '', line: 0, - failure: failure, - error: error + failure: XMLSafe(failure), + error: XMLSafe(error) }); }, diff --git a/postprocess/output.spec.js b/postprocess/output.spec.js index ccec9c3a0..26c54c857 100644 --- a/postprocess/output.spec.js +++ b/postprocess/output.spec.js @@ -1,4 +1,3 @@ -var assert = require('assert'); var expect = require('chai').expect; var output = require('./output') @@ -66,6 +65,19 @@ describe('output', function () { expect(buffer.cache).to.include(' errors="1" '); expect(buffer.cache).to.include('error message'); }) + + it('content should not contain XML characters', function() { + var buffer = createOutputBuffer(); + var handler = output.createJunit(buffer, { mocha: true, junit: 'test.junit' }); + handler.writeResult({status: 0}, {title:'myTitle&<>"\''}, 'key'); + handler.writeResult({status: 2, message: 'fail message&<>"\''}, {title:'myTitleFail'}, 'key-fail'); + handler.writeResult({status: 3, message: 'error message&<>"\''}, {title:'myTitleError'}, 'key-error'); + handler.close(); + + expect(buffer.cache).to.include('myTitle&<>"''); + expect(buffer.cache).to.include(' fail message&<>"''); + expect(buffer.cache).to.include(' error message&<>"''); + }) }) describe('csv', function () { diff --git a/regions.spec.js b/regions.spec.js new file mode 100644 index 000000000..0d360c0e1 --- /dev/null +++ b/regions.spec.js @@ -0,0 +1,13 @@ +var expect = require('chai').expect; +var regRegions = require(__dirname + '/helpers/aws/regions.js'); +var govRegions = require(__dirname + '/helpers/aws/regions_gov.js'); +var chinaRegions = require(__dirname + '/helpers/aws/regions_china.js'); + +describe('AWS Regions', function() { + it('should have same regions for AWS, China, and GovCloud', function() { + Object.keys(regRegions).forEach(function(regRegion){ + expect(govRegions[regRegion], `AWS Region: ${regRegion} is not present in GovCloud region list`).to.be.an('array'); + expect(chinaRegions[regRegion], `AWS Region: ${regRegion} is not present in China region list`).to.be.an('array'); + }); + }); +});